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