im_core/internal/secure_direct/
control.rs1use 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}