Skip to main content

im_core/internal/secure_direct/
control.rs

1use serde_json::{Map, Value};
2
3pub const SECURE_ACK_SYSTEM_TYPE: &str = "awiki.direct.secure_ack.v1";
4pub const SECURE_INIT_SYSTEM_TYPE: &str = "awiki.direct.secure_init.v1";
5
6pub fn build_secure_ack_payload(session_id: &str, acked_message_id: &str) -> Map<String, Value> {
7    Map::from_iter([
8        (
9            "system_type".to_owned(),
10            Value::String(SECURE_ACK_SYSTEM_TYPE.to_owned()),
11        ),
12        (
13            "session_id".to_owned(),
14            Value::String(session_id.trim().to_owned()),
15        ),
16        (
17            "acked_message_id".to_owned(),
18            Value::String(acked_message_id.trim().to_owned()),
19        ),
20    ])
21}
22
23pub fn build_secure_init_payload() -> Map<String, Value> {
24    Map::from_iter([
25        (
26            "system_type".to_owned(),
27            Value::String(SECURE_INIT_SYSTEM_TYPE.to_owned()),
28        ),
29        ("reason".to_owned(), Value::String("manual_init".to_owned())),
30    ])
31}
32
33pub fn is_secure_ack_plaintext(plaintext: &Map<String, Value>) -> bool {
34    is_secure_control_plaintext(plaintext, SECURE_ACK_SYSTEM_TYPE)
35}
36
37pub fn is_secure_init_plaintext(plaintext: &Map<String, Value>) -> bool {
38    is_secure_control_plaintext(plaintext, SECURE_INIT_SYSTEM_TYPE)
39}
40
41pub fn secure_ack_session_id(plaintext: &Map<String, Value>) -> String {
42    let Some(payload) = map_from_value(plaintext.get("payload")) else {
43        return String::new();
44    };
45    string_from_value(payload.get("session_id"))
46}
47
48pub fn is_pending_confirmation_error(message: Option<&str>) -> bool {
49    let Some(message) = message else {
50        return false;
51    };
52    let message = message.to_ascii_lowercase();
53    message.contains("pending confirmation") || message.contains("pending-confirmation")
54}
55
56fn is_secure_control_plaintext(plaintext: &Map<String, Value>, system_type: &str) -> bool {
57    if string_from_value(plaintext.get("application_content_type")) != "application/json" {
58        return false;
59    }
60    let Some(payload) = map_from_value(plaintext.get("payload")) else {
61        return false;
62    };
63    string_from_value(payload.get("system_type")) == system_type
64}
65
66fn map_from_value(value: Option<&Value>) -> Option<Map<String, Value>> {
67    match value {
68        Some(Value::Object(object)) => Some(object.clone()),
69        Some(Value::String(value)) if !value.trim().is_empty() => {
70            serde_json::from_str::<Map<String, Value>>(value).ok()
71        }
72        _ => None,
73    }
74}
75
76fn string_from_value(value: Option<&Value>) -> String {
77    value.and_then(Value::as_str).unwrap_or_default().to_owned()
78}
79
80#[cfg(test)]
81mod tests {
82    use serde_json::{json, Map, Value};
83
84    use super::*;
85
86    #[test]
87    fn secure_ack_payload_trims_session_and_acked_message_ids() {
88        assert_eq!(
89            Value::Object(build_secure_ack_payload(" session-1 \n", "\tmsg-9 ")),
90            json!({
91                "system_type": SECURE_ACK_SYSTEM_TYPE,
92                "session_id": "session-1",
93                "acked_message_id": "msg-9",
94            })
95        );
96    }
97
98    #[test]
99    fn secure_init_payload_matches_manual_init_control_payload() {
100        assert_eq!(
101            Value::Object(build_secure_init_payload()),
102            json!({
103                "system_type": SECURE_INIT_SYSTEM_TYPE,
104                "reason": "manual_init",
105            })
106        );
107    }
108
109    #[test]
110    fn secure_control_plaintext_detection_accepts_object_and_json_string_payloads() {
111        assert!(is_secure_ack_plaintext(&plaintext_with_payload(json!({
112            "system_type": SECURE_ACK_SYSTEM_TYPE,
113            "session_id": "session-1",
114            "acked_message_id": "msg-9",
115        }))));
116        assert!(is_secure_init_plaintext(&plaintext_with_payload(json!({
117            "system_type": SECURE_INIT_SYSTEM_TYPE,
118            "reason": "manual_init",
119        }))));
120        assert!(is_secure_ack_plaintext(&plaintext_with_payload(json!(
121            r#"{"system_type":"awiki.direct.secure_ack.v1","session_id":"session-from-string"}"#
122        ))));
123    }
124
125    #[test]
126    fn secure_control_plaintext_detection_rejects_non_matching_shapes() {
127        let valid_ack_payload = json!({
128            "system_type": SECURE_ACK_SYSTEM_TYPE,
129            "session_id": "session-1",
130            "acked_message_id": "msg-9",
131        });
132
133        let mut missing_content_type = plaintext_with_payload(valid_ack_payload.clone());
134        missing_content_type.remove("application_content_type");
135        assert!(!is_secure_ack_plaintext(&missing_content_type));
136
137        let mut wrong_content_type = plaintext_with_payload(valid_ack_payload);
138        wrong_content_type.insert("application_content_type".to_owned(), json!("text/plain"));
139        assert!(!is_secure_ack_plaintext(&wrong_content_type));
140
141        assert!(!is_secure_ack_plaintext(&plaintext_with_payload(json!(
142            "not-an-object"
143        ))));
144        assert!(!is_secure_ack_plaintext(&plaintext_with_payload(json!({
145            "system_type": SECURE_INIT_SYSTEM_TYPE,
146            "reason": "manual_init",
147        }))));
148        assert!(!is_secure_init_plaintext(&plaintext_with_payload(json!({
149            "system_type": SECURE_ACK_SYSTEM_TYPE,
150            "session_id": "session-1",
151            "acked_message_id": "msg-9",
152        }))));
153    }
154
155    #[test]
156    fn secure_ack_session_id_reads_only_string_session_from_payload() {
157        assert_eq!(
158            secure_ack_session_id(&plaintext_with_payload(json!({
159                "system_type": SECURE_ACK_SYSTEM_TYPE,
160                "session_id": "session-1",
161            }))),
162            "session-1"
163        );
164        assert_eq!(
165            secure_ack_session_id(&plaintext_with_payload(json!({
166                "system_type": SECURE_ACK_SYSTEM_TYPE,
167                "session_id": 42,
168            }))),
169            ""
170        );
171        assert_eq!(
172            secure_ack_session_id(&plaintext_with_payload(json!("not-an-object"))),
173            ""
174        );
175        assert_eq!(
176            secure_ack_session_id(&plaintext_with_payload(json!(
177                r#"{"session_id":"session-1"}"#
178            ))),
179            "session-1"
180        );
181    }
182
183    #[test]
184    fn pending_confirmation_error_detection_matches_legacy_string_checks() {
185        assert!(!is_pending_confirmation_error(None));
186        assert!(is_pending_confirmation_error(Some(
187            "remote returned PENDING CONFIRMATION for peer"
188        )));
189        assert!(is_pending_confirmation_error(Some(
190            "secure state is Pending-Confirmation"
191        )));
192        assert!(!is_pending_confirmation_error(Some("confirmation pending")));
193    }
194
195    fn plaintext_with_payload(payload: Value) -> Map<String, Value> {
196        Map::from_iter([
197            (
198                "application_content_type".to_owned(),
199                json!("application/json"),
200            ),
201            ("payload".to_owned(), payload),
202        ])
203    }
204}