Skip to main content

scm_sdk/error/
problem.rs

1//! Small allowlisted diagnostics. Arbitrary service text and extensions never survive.
2use super::{Error, ErrorKind, Result};
3use serde::Serialize;
4use serde_json::Value;
5use std::{fmt, time::Duration};
6
7const WITHHELD: &str = "[withheld]";
8const SAFE_TEXT: &[&str] = &[
9    "Bad Request",
10    "Unauthorized",
11    "Forbidden",
12    "Not Found",
13    "Method Not Allowed",
14    "Conflict",
15    "Unsupported Media Type",
16    "Unprocessable Entity",
17    "Too Many Requests",
18    "Internal Server Error",
19    "Bad Gateway",
20    "Service Unavailable",
21    "Gateway Timeout",
22    "Validation failed",
23    "Invalid request payload",
24    "Access denied",
25    "must not be blank",
26    "must not be null",
27    "must not be empty",
28    "is required",
29    "Required request part 'json' is not present",
30    "Required request part 'file' is not present",
31    "Content-Type 'application/octet-stream' is not supported",
32    "Content-Type 'text/plain' is not supported",
33];
34const SAFE_PROPERTIES: &[&str] = &[
35    "name",
36    "category",
37    "description",
38    "type",
39    "classification",
40    "tags",
41    "file",
42    "json",
43    "originalFileName",
44    "original_file_name",
45    "regionName",
46    "region_name",
47    "isCaseSensitive",
48    "is_case_sensitive",
49    "profileType",
50    "profile_type",
51    "detectionRules",
52    "detection_rules",
53    "dataPatterns",
54    "data_patterns",
55    "supportedConfidenceLevels",
56    "supported_confidence_levels",
57];
58
59/// Failure origin. A failed response-body read is a network failure even with a status.
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
61#[serde(rename_all = "lowercase")]
62#[non_exhaustive]
63pub enum FailureKind {
64    Http,
65    Network,
66}
67
68/// The frozen AIRS exception taxonomy, separate from the native failure category.
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70#[non_exhaustive]
71pub enum ErrorType {
72    ServerSideError,
73    ClientSideError,
74    UserRequestPayloadError,
75    MissingVariable,
76    SdkError,
77    OAuthError,
78    ResponseValidation,
79}
80impl ErrorType {
81    pub const fn as_str(self) -> &'static str {
82        match self {
83            Self::ServerSideError => "AISEC_SERVER_SIDE_ERROR",
84            Self::ClientSideError => "AISEC_CLIENT_SIDE_ERROR",
85            Self::UserRequestPayloadError => "AISEC_USER_REQUEST_PAYLOAD_ERROR",
86            Self::MissingVariable => "AISEC_MISSING_VARIABLE",
87            Self::SdkError => "AISEC_SDK_ERROR",
88            Self::OAuthError => "AISEC_OAUTH_ERROR",
89            Self::ResponseValidation => "AISEC_RESPONSE_VALIDATION",
90        }
91    }
92}
93
94#[derive(Clone, PartialEq, Eq, Serialize)]
95pub struct ProblemFieldError {
96    property: &'static str,
97    reason: &'static str,
98}
99impl ProblemFieldError {
100    pub fn property(&self) -> &'static str {
101        self.property
102    }
103    pub fn reason(&self) -> &'static str {
104        self.reason
105    }
106}
107impl fmt::Debug for ProblemFieldError {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.write_str("ProblemFieldError([REDACTED])")
110    }
111}
112
113/// Sanitized RFC 7807 fields; no response body or unknown extension is retained.
114#[derive(Clone, PartialEq, Eq, Serialize)]
115pub struct ProblemDetails {
116    #[serde(skip_serializing_if = "Option::is_none")]
117    title: Option<&'static str>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    detail: Option<&'static str>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    errors: Option<Vec<ProblemFieldError>>,
122    redacted: bool,
123}
124impl ProblemDetails {
125    /// Recognize a problem envelope and preserve only exact frozen protocol vocabulary.
126    pub fn from_value(value: &Value) -> Option<Self> {
127        let record = value.as_object()?;
128        let title_or_detail = record.contains_key("title") || record.contains_key("detail");
129        if !title_or_detail
130            && (!record.contains_key("errors")
131                || ["message", "error_message", "msg", "data", "error"]
132                    .iter()
133                    .any(|key| record.contains_key(*key)))
134        {
135            return None;
136        }
137        let mut result = Self {
138            title: None,
139            detail: None,
140            errors: None,
141            redacted: false,
142        };
143        if let Some(value) = record.get("title").filter(|value| !value.is_null()) {
144            result.title = Some(sanitize(value, SAFE_TEXT, &mut result.redacted));
145        }
146        if let Some(value) = record.get("detail").filter(|value| !value.is_null()) {
147            result.detail = Some(sanitize(value, SAFE_TEXT, &mut result.redacted));
148        }
149        if let Some(errors) = record.get("errors").and_then(Value::as_array) {
150            result.errors = Some(
151                errors
152                    .iter()
153                    .take(20)
154                    .map(|entry| ProblemFieldError {
155                        property: sanitize(
156                            entry.get("property").unwrap_or(&Value::Null),
157                            SAFE_PROPERTIES,
158                            &mut result.redacted,
159                        ),
160                        reason: sanitize(
161                            entry.get("reason").unwrap_or(&Value::Null),
162                            SAFE_TEXT,
163                            &mut result.redacted,
164                        ),
165                    })
166                    .collect(),
167            );
168            result.redacted |= errors.len() > 20;
169        } else if record.get("errors").is_some_and(|value| !value.is_null()) {
170            result.redacted = true;
171        }
172        Some(result)
173    }
174    /// Parse at most 1 MiB; malformed or unrelated JSON is not a problem envelope.
175    pub fn parse(bytes: &[u8]) -> Result<Option<Self>> {
176        if bytes.len() > 1024 * 1024 {
177            return Err(Error::new(ErrorKind::Limit));
178        }
179        Ok(crate::json_decode::from_slice(bytes)
180            .ok()
181            .as_ref()
182            .and_then(Self::from_value))
183    }
184    pub fn title(&self) -> Option<&'static str> {
185        self.title
186    }
187    pub fn detail(&self) -> Option<&'static str> {
188        self.detail
189    }
190    pub fn errors(&self) -> Option<&[ProblemFieldError]> {
191        self.errors.as_deref()
192    }
193    pub fn redacted(&self) -> bool {
194        self.redacted
195    }
196}
197impl fmt::Debug for ProblemDetails {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        f.debug_struct("ProblemDetails")
200            .field("redacted", &self.redacted)
201            .field("field_count", &self.errors.as_ref().map_or(0, Vec::len))
202            .finish_non_exhaustive()
203    }
204}
205fn sanitize(value: &Value, allowed: &'static [&'static str], redacted: &mut bool) -> &'static str {
206    if let Some(value) = value.as_str()
207        && let Some(known) = allowed.iter().copied().find(|known| *known == value)
208    {
209        return known;
210    }
211    *redacted = true;
212    WITHHELD
213}
214
215/// Checked public exception metadata, also used for application-created SDK errors.
216#[derive(Clone, Default)]
217pub struct ErrorMetadata {
218    pub(super) problem: Option<ProblemDetails>,
219    pub(super) failure_kind: Option<FailureKind>,
220    pub(super) status: Option<u16>,
221    pub(super) retry_after: Option<Duration>,
222}
223impl ErrorMetadata {
224    pub fn new() -> Self {
225        Self::default()
226    }
227    pub fn with_problem(mut self, value: ProblemDetails) -> Self {
228        self.problem = Some(value);
229        self
230    }
231    pub fn with_failure_kind(mut self, value: FailureKind) -> Self {
232        self.failure_kind = Some(value);
233        self
234    }
235    pub fn with_status(mut self, value: u16) -> Result<Self> {
236        if !(100..=999).contains(&value) {
237            return Err(Error::new(ErrorKind::Validation));
238        }
239        self.status = Some(value);
240        Ok(self)
241    }
242    pub fn with_retry_after(mut self, value: Duration) -> Self {
243        self.retry_after = Some(value);
244        self
245    }
246    pub fn problem(&self) -> Option<&ProblemDetails> {
247        self.problem.as_ref()
248    }
249    pub fn failure_kind(&self) -> Option<FailureKind> {
250        self.failure_kind
251    }
252    pub fn status(&self) -> Option<u16> {
253        self.status
254    }
255    pub fn retry_after(&self) -> Option<Duration> {
256        self.retry_after
257    }
258}
259impl fmt::Debug for ErrorMetadata {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        f.debug_struct("ErrorMetadata")
262            .field("status", &self.status)
263            .field("failure_kind", &self.failure_kind)
264            .field("retry_after", &self.retry_after)
265            .field("has_problem", &self.problem.is_some())
266            .finish()
267    }
268}
269
270/// Parse source-compatible body guidance for metadata. It does not authorize replay.
271pub(crate) fn body_retry_after(bytes: &[u8]) -> Option<Duration> {
272    let body: Value = crate::json_decode::from_slice(bytes).ok()?;
273    let retry = body.get("retry_after")?.as_object()?;
274    let interval = retry.get("interval")?.as_f64()?;
275    if !interval.is_finite() || interval < 0.0 {
276        return None;
277    }
278    let unit = retry.get("unit")?.as_str()?;
279    if unit.len() > 16 {
280        return None;
281    }
282    let seconds = match unit.to_ascii_lowercase().as_str() {
283        "ms" | "msec" | "msecs" | "millisecond" | "milliseconds" => interval / 1000.0,
284        "s" | "sec" | "secs" | "second" | "seconds" => interval,
285        "m" | "min" | "mins" | "minute" | "minutes" => interval * 60.0,
286        _ => return None,
287    };
288    Duration::try_from_secs_f64(seconds).ok()
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use serde_json::json;
295    #[test]
296    fn allowlists_are_exact_and_unknown_text_never_survives() {
297        for text in SAFE_TEXT {
298            let problem = ProblemDetails::from_value(&json!({"title":text})).unwrap();
299            assert_eq!(problem.title(), Some(*text));
300            assert!(!problem.redacted());
301        }
302        let problem = ProblemDetails::from_value(&json!({"title":"echoed secret", "detail":17,
303            "errors":[{"property":"signed-secret-url","reason":"prompt"}], "extension":"secret"}))
304        .unwrap();
305        assert_eq!(problem.title(), Some(WITHHELD));
306        assert_eq!(problem.detail(), Some(WITHHELD));
307        assert!(problem.redacted());
308        assert_eq!(problem.errors().unwrap()[0].property(), WITHHELD);
309        let encoded = serde_json::to_string(&problem).unwrap();
310        assert!(!encoded.contains("secret"));
311        assert!(!format!("{problem:?}").contains(WITHHELD));
312        for property in SAFE_PROPERTIES {
313            let problem = ProblemDetails::from_value(
314                &json!({"errors":[{"property":property,"reason":"is required"}]}),
315            )
316            .unwrap();
317            assert_eq!(problem.errors().unwrap()[0].property(), *property);
318            assert!(!problem.redacted());
319        }
320    }
321    #[test]
322    fn unrelated_errors_null_and_malformed_entries_match_source() {
323        for value in [
324            json!(null),
325            json!([]),
326            json!({}),
327            json!({"message":"legacy","errors":[]}),
328        ] {
329            assert!(ProblemDetails::from_value(&value).is_none());
330        }
331        let value = json!({"title":null,"detail":null,"errors":null});
332        let result = ProblemDetails::from_value(&value).unwrap();
333        assert_eq!(
334            serde_json::to_value(&result).unwrap(),
335            json!({"redacted":false})
336        );
337        let problem = ProblemDetails::from_value(&json!({"errors":[null, {}, 1]})).unwrap();
338        assert!(problem.redacted());
339        assert_eq!(problem.errors().unwrap().len(), 3);
340        let problem = ProblemDetails::from_value(
341            &json!({"errors":vec![json!({"property":"name","reason":"is required"}); 21]}),
342        )
343        .unwrap();
344        assert_eq!(problem.errors().unwrap().len(), 20);
345        assert!(problem.redacted());
346        assert!(ProblemDetails::parse(&vec![b' '; 1024 * 1024 + 1]).is_err());
347    }
348    #[test]
349    fn body_retry_metadata_is_bounded_and_does_not_authorize_replay() {
350        for (unit, expected) in [("MS", 0.002), ("seconds", 2.0), ("MIN", 120.0)] {
351            let bytes =
352                serde_json::to_vec(&json!({"retry_after":{"interval":2,"unit":unit}})).unwrap();
353            assert_eq!(
354                body_retry_after(&bytes),
355                Some(Duration::from_secs_f64(expected))
356            );
357        }
358        for value in [
359            json!({"interval":-1,"unit":"s"}),
360            json!({"interval":"2","unit":"s"}),
361            json!({"interval":1,"unit":"days"}),
362        ] {
363            assert!(
364                body_retry_after(&serde_json::to_vec(&json!({"retry_after":value})).unwrap())
365                    .is_none()
366            );
367        }
368    }
369}