1use didbot_policy::{
27 Asked, Change, ClientId, DeploymentAttributes, Diff, Lookup, Subject, SubjectAttributes,
28 SubjectKind, Universal, WriteAction,
29};
30use serde_json::Value;
31use time::OffsetDateTime;
32
33#[derive(Debug, Clone, PartialEq)]
48pub struct OwnedChange<'a> {
49 pub path: String,
52 pub before: Option<&'a Value>,
54 pub after: Option<&'a Value>,
56}
57
58impl OwnedChange<'_> {
59 #[must_use]
65 pub fn as_change(&self) -> Change<'_> {
66 Change {
67 path: &self.path,
68 before: self.before,
69 after: self.after,
70 }
71 }
72}
73
74#[must_use]
76pub fn borrow<'a>(owned: &'a [OwnedChange<'_>]) -> Vec<Change<'a>> {
77 owned.iter().map(OwnedChange::as_change).collect()
78}
79
80#[must_use]
100pub fn diff<'a>(before: Option<&'a Value>, after: Option<&'a Value>) -> Vec<OwnedChange<'a>> {
101 let mut changes = Vec::new();
102 diff_into("", before, after, &mut changes);
103 changes
104}
105
106fn join(parent: &str, key: &str) -> String {
107 if parent.is_empty() {
108 key.to_owned()
109 } else {
110 format!("{parent}.{key}")
111 }
112}
113
114fn diff_into<'a>(
115 path: &str,
116 before: Option<&'a Value>,
117 after: Option<&'a Value>,
118 out: &mut Vec<OwnedChange<'a>>,
119) {
120 if before == after {
121 return;
122 }
123
124 let before_object_or_absent = before.is_none() || matches!(before, Some(Value::Object(_)));
125 let after_object_or_absent = after.is_none() || matches!(after, Some(Value::Object(_)));
126 if before_object_or_absent && after_object_or_absent {
127 let mut keys: Vec<&str> = Vec::new();
128 if let Some(Value::Object(map)) = before {
129 keys.extend(map.keys().map(String::as_str));
130 }
131 if let Some(Value::Object(map)) = after {
132 keys.extend(map.keys().map(String::as_str));
133 }
134 keys.sort_unstable();
135 keys.dedup();
136 if keys.is_empty() {
137 out.push(OwnedChange {
142 path: path.to_owned(),
143 before,
144 after,
145 });
146 } else {
147 for key in keys {
148 let child_before = before.and_then(|value| value.get(key));
149 let child_after = after.and_then(|value| value.get(key));
150 diff_into(&join(path, key), child_before, child_after, out);
151 }
152 }
153 return;
154 }
155
156 let before_array_or_absent = before.is_none() || matches!(before, Some(Value::Array(_)));
157 let after_array_or_absent = after.is_none() || matches!(after, Some(Value::Array(_)));
158 if before_array_or_absent && after_array_or_absent {
159 let before_len = before.and_then(Value::as_array).map_or(0, Vec::len);
160 let after_len = after.and_then(Value::as_array).map_or(0, Vec::len);
161 let len = before_len.max(after_len);
162 if len == 0 {
163 out.push(OwnedChange {
164 path: path.to_owned(),
165 before,
166 after,
167 });
168 } else {
169 for index in 0..len {
170 let child_before = before.and_then(|value| value.get(index));
171 let child_after = after.and_then(|value| value.get(index));
172 diff_into(&format!("{path}[{index}]"), child_before, child_after, out);
173 }
174 }
175 return;
176 }
177
178 out.push(OwnedChange {
183 path: path.to_owned(),
184 before,
185 after,
186 });
187}
188
189#[allow(clippy::too_many_arguments)]
205#[must_use]
206pub fn write_subject<'a>(
207 account: &'a str,
208 kind: SubjectKind<'a>,
209 handle: Option<&'a str>,
210 pds_did: &'a str,
211 pds_hostname: &'a str,
212 operator_did: Option<&'a str>,
213 client_id: Option<&'a ClientId>,
214 collection: &'a str,
215 action: WriteAction,
216 changes: &'a [Change<'a>],
217 before: Option<&'a serde_json::Value>,
218 after: Option<&'a serde_json::Value>,
219 lookups: &'a [Lookup<'a>],
220 now: OffsetDateTime,
221) -> Subject<'a> {
222 Subject::Write {
223 account,
224 attributes: SubjectAttributes {
225 kind,
226 handle,
227 depth: None,
228 },
229 universal: universal(pds_did, pds_hostname, operator_did, now),
230 client_id,
231 collection,
232 action,
233 diff: Diff { changes },
234 before,
235 after,
236 lookups,
237 }
238}
239
240#[allow(clippy::too_many_arguments)]
247#[must_use]
248pub fn blob_subject<'a>(
249 account: &'a str,
250 kind: SubjectKind<'a>,
251 handle: Option<&'a str>,
252 pds_did: &'a str,
253 pds_hostname: &'a str,
254 client_id: Option<&'a ClientId>,
255 mime_type: &'a str,
256 sniffed: Option<&'a str>,
257 size: Option<u64>,
258 now: OffsetDateTime,
259) -> Subject<'a> {
260 Subject::Blob {
261 account,
262 attributes: SubjectAttributes {
263 kind,
264 handle,
265 depth: None,
266 },
267 universal: universal(pds_did, pds_hostname, None, now),
268 client_id,
269 mime_type,
270 sniffed,
271 size,
272 }
273}
274
275#[must_use]
282pub fn grant_subject<'a>(
283 pds_did: &'a str,
284 pds_hostname: &'a str,
285 client_id: &'a ClientId,
286 requested_scopes: &'a [&'a str],
287 now: OffsetDateTime,
288) -> Subject<'a> {
289 Subject::Grant {
290 universal: universal(pds_did, pds_hostname, None, now),
291 client_id,
292 requested_scopes,
293 }
294}
295
296#[allow(clippy::too_many_arguments)]
300#[must_use]
301pub fn token_subject<'a>(
302 account: &'a str,
303 kind: SubjectKind<'a>,
304 handle: Option<&'a str>,
305 pds_did: &'a str,
306 pds_hostname: &'a str,
307 client_id: &'a ClientId,
308 scopes: &'a [&'a str],
309 now: OffsetDateTime,
310) -> Subject<'a> {
311 Subject::Token {
312 account,
313 attributes: SubjectAttributes {
314 kind,
315 handle,
316 depth: None,
317 },
318 universal: universal(pds_did, pds_hostname, None, now),
319 client_id,
320 scopes,
321 }
322}
323
324#[allow(clippy::too_many_arguments)]
328#[must_use]
329pub fn create_subject<'a>(
330 account: &'a str,
331 attributes: Option<SubjectAttributes<'a>>,
332 pds_did: &'a str,
333 pds_hostname: &'a str,
334 kind: SubjectKind<'a>,
335 asked: Asked,
336 (pool, zone): (&'a str, &'a str),
337 (name, label): (Option<&'a str>, Option<&'a str>),
338 now: OffsetDateTime,
339) -> Subject<'a> {
340 Subject::Create {
341 account,
342 attributes,
343 universal: universal(pds_did, pds_hostname, None, now),
344 kind,
345 asked,
346 pool,
347 zone,
348 name,
349 label,
350 }
351}
352
353fn universal<'a>(
355 pds_did: &'a str,
356 pds_hostname: &'a str,
357 operator_did: Option<&'a str>,
358 now: OffsetDateTime,
359) -> Universal<'a> {
360 Universal {
361 deployment: DeploymentAttributes {
362 pds_did,
363 pds_hostname,
364 operator_did,
365 },
366 now,
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use serde_json::json;
374
375 #[test]
376 fn a_create_lists_every_leaf_path_with_no_before() {
377 let after = json!({"text": "hi", "labels": {"values": [{"val": "x"}]}});
378 let changes = diff(None, Some(&after));
379 let mut paths: Vec<&str> = changes.iter().map(|c| c.path.as_str()).collect();
380 paths.sort_unstable();
381 assert_eq!(paths, vec!["labels.values[0].val", "text"]);
382 assert!(changes.iter().all(|c| c.before.is_none()));
383 }
384
385 #[test]
386 fn a_delete_lists_every_leaf_path_with_no_after() {
387 let before = json!({"text": "hi", "tags": ["a", "b"]});
388 let changes = diff(Some(&before), None);
389 let mut paths: Vec<&str> = changes.iter().map(|c| c.path.as_str()).collect();
390 paths.sort_unstable();
391 assert_eq!(paths, vec!["tags[0]", "tags[1]", "text"]);
392 assert!(
393 !changes.is_empty(),
394 "a delete of a non-empty record must never produce an empty diff"
395 );
396 assert!(changes.iter().all(|c| c.after.is_none()));
397 }
398
399 #[test]
400 fn an_update_reports_only_what_actually_changed() {
401 let before = json!({"text": "hi", "unrelated": "same"});
402 let after = json!({"text": "bye", "unrelated": "same"});
403 let changes = diff(Some(&before), Some(&after));
404 assert_eq!(changes.len(), 1);
405 assert_eq!(changes[0].path, "text");
406 assert_eq!(changes[0].before, Some(&json!("hi")));
407 assert_eq!(changes[0].after, Some(&json!("bye")));
408 }
409
410 #[test]
411 fn clearing_a_field_is_a_value_change_not_a_deletion_of_the_record() {
412 let before = json!({"text": "hi", "displayName": "Bot"});
413 let after = json!({"text": "hi", "displayName": ""});
414 let changes = diff(Some(&before), Some(&after));
415 assert_eq!(changes.len(), 1);
416 assert_eq!(changes[0].path, "displayName");
417 assert_eq!(changes[0].before, Some(&json!("Bot")));
418 assert_eq!(changes[0].after, Some(&json!("")));
419 }
420
421 #[test]
422 fn an_empty_object_replacing_absence_is_its_own_change() {
423 let after = json!({"metadata": {}});
424 let changes = diff(None, Some(&after));
425 let mut paths: Vec<&str> = changes.iter().map(|c| c.path.as_str()).collect();
426 paths.sort_unstable();
427 assert_eq!(paths, vec!["metadata"]);
428 assert_eq!(changes[0].after, Some(&json!({})));
429 }
430
431 #[test]
432 fn nothing_to_diff_produces_no_changes() {
433 assert!(diff(None, None).is_empty());
434 let same = json!({"a": 1});
435 assert!(diff(Some(&same), Some(&same)).is_empty());
436 }
437}