Skip to main content

wekan_cli/client/
decoding.rs

1//! Strict response decoding with diagnostics that never include field values.
2
3use serde::{Serialize, de::DeserializeOwned};
4use serde_json::Value;
5use serde_path_to_error::Segment;
6
7use super::ClientError;
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ResponseDecodeKind {
12    InvalidJson,
13    UnknownField,
14    MissingField,
15    InvalidType,
16    InvalidValue,
17}
18
19impl ResponseDecodeKind {
20    fn description(self) -> &'static str {
21        match self {
22            Self::InvalidJson => "invalid JSON",
23            Self::UnknownField => "unknown field",
24            Self::MissingField => "missing required field",
25            Self::InvalidType => "invalid field type",
26            Self::InvalidValue => "invalid field value",
27        }
28    }
29}
30
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct ResponseDiagnostic {
33    /// JSON Pointer-style diagnostic location; names are escaped and bounded.
34    /// An empty string identifies the response root.
35    pub path: String,
36    pub kind: ResponseDecodeKind,
37}
38
39fn protocol_error(
40    operation: &str,
41    diagnostic: ResponseDiagnostic,
42    success_status_received: bool,
43) -> ClientError {
44    let location = if diagnostic.path.is_empty() {
45        "the response root"
46    } else {
47        &diagnostic.path
48    };
49    ClientError::Protocol {
50        message: format!(
51            "the {operation} response contains {} at {location}",
52            diagnostic.kind.description()
53        ),
54        diagnostic: Some(diagnostic),
55        success_status_received,
56    }
57}
58
59pub(super) fn parse_json(body: &[u8], operation: &str) -> Result<Value, ClientError> {
60    serde_json::from_slice(body).map_err(|_| {
61        protocol_error(
62            operation,
63            ResponseDiagnostic {
64                path: String::new(),
65                kind: ResponseDecodeKind::InvalidJson,
66            },
67            true,
68        )
69    })
70}
71
72pub(super) fn decode_json<T: DeserializeOwned>(
73    body: &[u8],
74    operation: &str,
75) -> Result<T, ClientError> {
76    let mut deserializer = serde_json::Deserializer::from_slice(body);
77    let value = serde_path_to_error::deserialize(&mut deserializer)
78        .map_err(|error| decoding_error(error, operation, true))?;
79    deserializer.end().map_err(|_| {
80        protocol_error(
81            operation,
82            ResponseDiagnostic {
83                path: String::new(),
84                kind: ResponseDecodeKind::InvalidJson,
85            },
86            true,
87        )
88    })?;
89    Ok(value)
90}
91
92pub(super) fn decode_value<T: DeserializeOwned>(
93    value: Value,
94    operation: &str,
95    success_status_received: bool,
96) -> Result<T, ClientError> {
97    serde_path_to_error::deserialize(value)
98        .map_err(|error| decoding_error(error, operation, success_status_received))
99}
100
101pub(super) fn decoding_error(
102    error: serde_path_to_error::Error<serde_json::Error>,
103    operation: &str,
104    success_status_received: bool,
105) -> ClientError {
106    if error.inner().is_syntax() || error.inner().is_eof() {
107        return protocol_error(
108            operation,
109            ResponseDiagnostic {
110                path: String::new(),
111                kind: ResponseDecodeKind::InvalidJson,
112            },
113            success_status_received,
114        );
115    }
116    // Inspect only Serde's fixed error prefixes. Never render its error text:
117    // invalid-type/value diagnostics can contain passwords or returned tokens.
118    let message = error.inner().to_string();
119    let (kind, field) = if message.starts_with("unknown field `") {
120        (ResponseDecodeKind::UnknownField, None)
121    } else if message.starts_with("missing field `") {
122        (ResponseDecodeKind::MissingField, quoted_field(&message))
123    } else if message.starts_with("invalid type:") {
124        (ResponseDecodeKind::InvalidType, None)
125    } else {
126        (ResponseDecodeKind::InvalidValue, None)
127    };
128    let mut segments: Vec<String> = error
129        .path()
130        .iter()
131        .map(|segment| match segment {
132            Segment::Seq { index } => index.to_string(),
133            Segment::Map { key } => key.clone(),
134            Segment::Enum { .. } => "?".to_owned(),
135            Segment::Unknown => "?".to_owned(),
136        })
137        .collect();
138    if let Some(field) = field {
139        if segments.last().map(String::as_str) != Some(field) {
140            segments.push(field.to_owned());
141        }
142    }
143    let mut path = String::new();
144    for segment in segments.iter().take(16) {
145        path.push('/');
146        // Bound and escape server-controlled field names, including terminal
147        // controls. JSON Pointer metacharacters are escaped independently.
148        let safe: String = segment
149            .chars()
150            .take(80)
151            .flat_map(char::escape_default)
152            .collect();
153        path.push_str(&safe.replace('~', "~0").replace('/', "~1"));
154        if segment.chars().count() > 80 {
155            path.push_str("[truncated]");
156        }
157    }
158    if segments.len() > 16 {
159        path.push_str("/[truncated]");
160    }
161    protocol_error(
162        operation,
163        ResponseDiagnostic { path, kind },
164        success_status_received,
165    )
166}
167
168fn quoted_field(message: &str) -> Option<&str> {
169    message.split('`').nth(1)
170}
171
172#[cfg(test)]
173mod tests {
174    use serde_json::json;
175
176    use super::*;
177    use crate::client::{ListDocument, ListSummary};
178
179    #[test]
180    fn strict_diagnostics_identify_fields_without_rendering_values() {
181        for (response, expected_path, expected_kind) in [
182            (
183                json!([{"_id":"list-1","title":"Todo","modifiedAt":null,"cardsModifiedAt":null,"future":"secret-value"}]),
184                "/0/future",
185                ResponseDecodeKind::UnknownField,
186            ),
187            (
188                json!([{"_id":"list-1","modifiedAt":null,"cardsModifiedAt":null}]),
189                "/0/title",
190                ResponseDecodeKind::MissingField,
191            ),
192            (
193                json!([{"_id":"list-1","title":"Todo","modifiedAt":{"secret-value":true},"cardsModifiedAt":null}]),
194                "/0/modifiedAt",
195                ResponseDecodeKind::InvalidType,
196            ),
197        ] {
198            let error =
199                decode_value::<Vec<ListSummary>>(response, "list collection", true).unwrap_err();
200            assert!(!format!("{error:?}").contains("secret-value"));
201            let ClientError::Protocol {
202                diagnostic: Some(diagnostic),
203                success_status_received: true,
204                ..
205            } = error
206            else {
207                panic!("expected a strict decoding diagnostic")
208            };
209            assert_eq!(diagnostic.path, expected_path);
210            assert_eq!(diagnostic.kind, expected_kind);
211        }
212    }
213
214    #[test]
215    fn nested_diagnostics_and_invalid_json_preserve_locations() {
216        let response = json!({
217            "_id":"list-1","title":"Todo","archived":false,"boardId":"board-1",
218            "createdAt":"2026-08-28T00:00:00Z","modifiedAt":"2026-08-28T00:00:00Z","type":"list",
219            "wipLimit":{"value":3,"enabled":true,"soft":false,"future":true}
220        });
221        let error = decode_value::<ListDocument>(response, "list", true).unwrap_err();
222        let ClientError::Protocol {
223            diagnostic: Some(diagnostic),
224            ..
225        } = error
226        else {
227            panic!("expected diagnostic")
228        };
229        assert_eq!(diagnostic.path, "/wipLimit/future");
230        assert_eq!(diagnostic.kind, ResponseDecodeKind::UnknownField);
231        for body in [b"{secret-value".as_slice(), b"{} secret-value"] {
232            let error = decode_json::<Value>(body, "list").unwrap_err();
233            assert!(!format!("{error:?}").contains("secret-value"));
234            let ClientError::Protocol {
235                diagnostic: Some(diagnostic),
236                ..
237            } = error
238            else {
239                panic!("expected diagnostic")
240            };
241            assert_eq!(diagnostic.path, "");
242            assert_eq!(diagnostic.kind, ResponseDecodeKind::InvalidJson);
243        }
244    }
245
246    #[test]
247    fn server_controlled_paths_are_bounded_and_terminal_safe() {
248        let key = format!("future\u{1b}[31m/~{}", "x".repeat(500));
249        let response = json!([{"_id":"list-1","title":"Todo","modifiedAt":null,"cardsModifiedAt":null,key:true}]);
250        let error =
251            decode_value::<Vec<ListSummary>>(response, "list collection", true).unwrap_err();
252        assert!(!error.to_string().contains('\u{1b}'));
253        let ClientError::Protocol {
254            diagnostic: Some(diagnostic),
255            ..
256        } = error
257        else {
258            panic!("expected diagnostic")
259        };
260        assert!(diagnostic.path.contains("~1~0"));
261        assert!(diagnostic.path.ends_with("[truncated]"));
262        assert!(diagnostic.path.len() < 150);
263    }
264
265    #[test]
266    fn byte_decoding_rejects_duplicate_fields_without_echoing_values() {
267        let body = br#"{"_id":"list-1","_id":"secret-value","title":"Todo","modifiedAt":null,"cardsModifiedAt":null}"#;
268        let error = decode_json::<ListSummary>(body, "list").unwrap_err();
269        assert!(!format!("{error:?}").contains("secret-value"));
270        assert!(matches!(
271            error,
272            ClientError::Protocol {
273                diagnostic: Some(_),
274                ..
275            }
276        ));
277    }
278}