a simple rust terminal ui (tui) for setting up alternative plc rotation keys driven by: secure enclave hardware (not synced) or software-based keys (synced to icloud)
plc
secure-enclave
touchid
icloud
atproto
1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
5pub struct PlcService {
6 #[serde(rename = "type")]
7 pub service_type: String,
8 pub endpoint: String,
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct PlcOperation {
14 #[serde(rename = "type")]
15 pub op_type: String,
16 pub rotation_keys: Vec<String>,
17 pub verification_methods: BTreeMap<String, String>,
18 pub also_known_as: Vec<String>,
19 pub services: BTreeMap<String, PlcService>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub prev: Option<String>,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub sig: Option<String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct PlcState {
29 pub did: String,
30 pub rotation_keys: Vec<String>,
31 pub verification_methods: BTreeMap<String, String>,
32 pub also_known_as: Vec<String>,
33 pub services: BTreeMap<String, PlcService>,
34}
35
36/// Represents a single change in a PLC operation diff.
37#[derive(Debug, Clone)]
38pub struct ChangeEntry {
39 pub kind: String, // "added", "removed", "modified"
40 pub description: String,
41}
42
43/// Diff between two PLC states.
44#[derive(Debug, Clone)]
45pub struct OperationDiff {
46 pub changes: Vec<ChangeEntry>,
47}
48
49/// Serialize a PLC operation for signing (without sig field).
50/// Produces canonical DAG-CBOR with keys sorted by length then lexicographic.
51pub fn serialize_for_signing(op: &PlcOperation) -> anyhow::Result<Vec<u8>> {
52 let mut signing_op = op.clone();
53 signing_op.sig = None;
54 // Serialize to JSON first, then re-serialize to DAG-CBOR via serde_json::Value.
55 // serde_ipld_dagcbor sorts map keys in DAG-CBOR canonical order when serializing
56 // from a serde_json::Value (which uses a BTreeMap internally).
57 let json_val = serde_json::to_value(&signing_op)?;
58 let bytes = serde_ipld_dagcbor::to_vec(&json_val)?;
59 Ok(bytes)
60}
61
62/// Serialize a signed PLC operation to canonical DAG-CBOR (for CID computation).
63pub fn serialize_to_dag_cbor(op: &PlcOperation) -> anyhow::Result<Vec<u8>> {
64 let json_val = serde_json::to_value(op)?;
65 let bytes = serde_ipld_dagcbor::to_vec(&json_val)?;
66 Ok(bytes)
67}
68
69/// Compute CIDv1 (dag-cbor + sha256) of a signed operation.
70pub fn compute_cid(op: &PlcOperation) -> anyhow::Result<String> {
71 use sha2::{Digest, Sha256};
72
73 let bytes = serde_ipld_dagcbor::to_vec(op)?;
74 let hash = Sha256::digest(&bytes);
75
76 // CIDv1: version(1) + codec(dag-cbor=0x71) + multihash(sha256=0x12, len=0x20, digest)
77 let mut cid_bytes = Vec::new();
78 // CID version 1
79 cid_bytes.push(0x01);
80 // dag-cbor codec
81 cid_bytes.push(0x71);
82 // sha2-256 multihash
83 cid_bytes.push(0x12);
84 cid_bytes.push(0x20);
85 cid_bytes.extend_from_slice(&hash);
86
87 // Encode as base32lower with 'b' prefix
88 let encoded = base32_encode(&cid_bytes);
89 Ok(format!("b{}", encoded))
90}
91
92fn base32_encode(data: &[u8]) -> String {
93 const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
94 let mut result = String::new();
95 let mut buffer: u64 = 0;
96 let mut bits = 0;
97
98 for &byte in data {
99 buffer = (buffer << 8) | byte as u64;
100 bits += 8;
101 while bits >= 5 {
102 bits -= 5;
103 result.push(ALPHABET[((buffer >> bits) & 0x1f) as usize] as char);
104 }
105 }
106
107 if bits > 0 {
108 buffer <<= 5 - bits;
109 result.push(ALPHABET[(buffer & 0x1f) as usize] as char);
110 }
111
112 result
113}
114
115/// Build an update operation from current state and desired changes.
116pub fn build_update_operation(
117 current_state: &PlcState,
118 prev_cid: &str,
119 new_rotation_keys: Option<Vec<String>>,
120 new_verification_methods: Option<BTreeMap<String, String>>,
121 new_also_known_as: Option<Vec<String>>,
122 new_services: Option<BTreeMap<String, PlcService>>,
123) -> PlcOperation {
124 PlcOperation {
125 op_type: "plc_operation".to_string(),
126 rotation_keys: new_rotation_keys.unwrap_or_else(|| current_state.rotation_keys.clone()),
127 verification_methods: new_verification_methods
128 .unwrap_or_else(|| current_state.verification_methods.clone()),
129 also_known_as: new_also_known_as
130 .unwrap_or_else(|| current_state.also_known_as.clone()),
131 services: new_services.unwrap_or_else(|| current_state.services.clone()),
132 prev: Some(prev_cid.to_string()),
133 sig: None,
134 }
135}
136
137/// Compute diff between current state and a proposed operation.
138pub fn compute_diff(current: &PlcState, proposed: &PlcOperation) -> OperationDiff {
139 let mut changes = Vec::new();
140
141 // Compare rotation keys
142 if current.rotation_keys != proposed.rotation_keys {
143 for (i, key) in proposed.rotation_keys.iter().enumerate() {
144 if i >= current.rotation_keys.len() {
145 changes.push(ChangeEntry {
146 kind: "added".to_string(),
147 description: format!("rotationKeys[{}]: {}", i, truncate_key(key)),
148 });
149 } else if current.rotation_keys[i] != *key {
150 changes.push(ChangeEntry {
151 kind: "modified".to_string(),
152 description: format!(
153 "rotationKeys[{}]: {} -> {}",
154 i,
155 truncate_key(¤t.rotation_keys[i]),
156 truncate_key(key)
157 ),
158 });
159 }
160 }
161 for i in proposed.rotation_keys.len()..current.rotation_keys.len() {
162 changes.push(ChangeEntry {
163 kind: "removed".to_string(),
164 description: format!(
165 "rotationKeys[{}]: {}",
166 i,
167 truncate_key(¤t.rotation_keys[i])
168 ),
169 });
170 }
171 }
172
173 // Compare also_known_as
174 if current.also_known_as != proposed.also_known_as {
175 changes.push(ChangeEntry {
176 kind: "modified".to_string(),
177 description: format!(
178 "alsoKnownAs: {:?} -> {:?}",
179 current.also_known_as, proposed.also_known_as
180 ),
181 });
182 }
183
184 // Compare verification methods
185 if current.verification_methods != proposed.verification_methods {
186 changes.push(ChangeEntry {
187 kind: "modified".to_string(),
188 description: "verificationMethods changed".to_string(),
189 });
190 }
191
192 // Compare services
193 for (name, svc) in &proposed.services {
194 match current.services.get(name) {
195 Some(current_svc) if current_svc.endpoint != svc.endpoint => {
196 changes.push(ChangeEntry {
197 kind: "modified".to_string(),
198 description: format!("services.{}.endpoint: {} -> {}", name, current_svc.endpoint, svc.endpoint),
199 });
200 }
201 None => {
202 changes.push(ChangeEntry {
203 kind: "added".to_string(),
204 description: format!("services.{}", name),
205 });
206 }
207 _ => {}
208 }
209 }
210
211 if changes.is_empty() {
212 changes.push(ChangeEntry {
213 kind: "modified".to_string(),
214 description: "No visible changes".to_string(),
215 });
216 }
217
218 OperationDiff { changes }
219}
220
221fn truncate_key(key: &str) -> String {
222 if key.len() > 30 {
223 format!("{}...", &key[..30])
224 } else {
225 key.to_string()
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn test_serialize_for_signing_omits_sig() {
235 let op = PlcOperation {
236 op_type: "plc_operation".to_string(),
237 rotation_keys: vec!["did:key:z123".to_string()],
238 verification_methods: BTreeMap::new(),
239 also_known_as: vec![],
240 services: BTreeMap::new(),
241 prev: Some("bafytest".to_string()),
242 sig: Some("should_be_omitted".to_string()),
243 };
244
245 let bytes = serialize_for_signing(&op).unwrap();
246 // Deserialize back and check sig is absent
247 let val: serde_json::Value =
248 serde_ipld_dagcbor::from_slice(&bytes).unwrap();
249 assert!(val.get("sig").is_none());
250 }
251
252 #[test]
253 fn test_compute_cid_format() {
254 let op = PlcOperation {
255 op_type: "plc_operation".to_string(),
256 rotation_keys: vec!["did:key:z123".to_string()],
257 verification_methods: BTreeMap::new(),
258 also_known_as: vec![],
259 services: BTreeMap::new(),
260 prev: None,
261 sig: Some("testsig".to_string()),
262 };
263
264 let cid = compute_cid(&op).unwrap();
265 assert!(cid.starts_with("bafyrei"), "CID should start with bafyrei, got: {}", cid);
266 }
267
268 #[test]
269 fn test_build_update_operation() {
270 let state = PlcState {
271 did: "did:plc:test".to_string(),
272 rotation_keys: vec!["did:key:old".to_string()],
273 verification_methods: BTreeMap::new(),
274 also_known_as: vec!["at://test.bsky.social".to_string()],
275 services: BTreeMap::new(),
276 };
277
278 let op = build_update_operation(
279 &state,
280 "bafytest",
281 Some(vec!["did:key:new".to_string(), "did:key:old".to_string()]),
282 None,
283 None,
284 None,
285 );
286
287 assert_eq!(op.rotation_keys.len(), 2);
288 assert_eq!(op.rotation_keys[0], "did:key:new");
289 assert_eq!(op.prev, Some("bafytest".to_string()));
290 assert!(op.sig.is_none());
291 }
292
293 // --- Additional tests ---
294
295 #[test]
296 fn test_build_update_preserves_unchanged_fields() {
297 let mut vm = BTreeMap::new();
298 vm.insert("atproto".to_string(), "did:key:zVeri".to_string());
299
300 let mut services = BTreeMap::new();
301 services.insert(
302 "atproto_pds".to_string(),
303 PlcService {
304 service_type: "AtprotoPersonalDataServer".to_string(),
305 endpoint: "https://pds.example.com".to_string(),
306 },
307 );
308
309 let state = PlcState {
310 did: "did:plc:test".to_string(),
311 rotation_keys: vec!["did:key:rot1".to_string()],
312 verification_methods: vm.clone(),
313 also_known_as: vec!["at://alice.test".to_string()],
314 services: services.clone(),
315 };
316
317 // Only change rotation keys, rest should come from state
318 let op = build_update_operation(
319 &state,
320 "bafyprev",
321 Some(vec!["did:key:new".to_string()]),
322 None,
323 None,
324 None,
325 );
326
327 assert_eq!(op.verification_methods, vm);
328 assert_eq!(op.also_known_as, vec!["at://alice.test"]);
329 assert_eq!(op.services.len(), 1);
330 assert_eq!(op.services["atproto_pds"].endpoint, "https://pds.example.com");
331 assert_eq!(op.op_type, "plc_operation");
332 }
333
334 #[test]
335 fn test_build_update_all_fields_changed() {
336 let state = PlcState {
337 did: "did:plc:test".to_string(),
338 rotation_keys: vec!["did:key:old".to_string()],
339 verification_methods: BTreeMap::new(),
340 also_known_as: vec![],
341 services: BTreeMap::new(),
342 };
343
344 let mut new_vm = BTreeMap::new();
345 new_vm.insert("atproto".to_string(), "did:key:zNewVeri".to_string());
346
347 let mut new_svc = BTreeMap::new();
348 new_svc.insert(
349 "atproto_pds".to_string(),
350 PlcService {
351 service_type: "AtprotoPersonalDataServer".to_string(),
352 endpoint: "https://new-pds.example.com".to_string(),
353 },
354 );
355
356 let op = build_update_operation(
357 &state,
358 "bafyprev",
359 Some(vec!["did:key:new".to_string()]),
360 Some(new_vm.clone()),
361 Some(vec!["at://new.handle".to_string()]),
362 Some(new_svc.clone()),
363 );
364
365 assert_eq!(op.rotation_keys, vec!["did:key:new"]);
366 assert_eq!(op.verification_methods, new_vm);
367 assert_eq!(op.also_known_as, vec!["at://new.handle"]);
368 assert_eq!(op.services, new_svc);
369 }
370
371 #[test]
372 fn test_serialize_for_signing_roundtrip() {
373 let mut vm = BTreeMap::new();
374 vm.insert("atproto".to_string(), "did:key:zVeri".to_string());
375
376 let op = PlcOperation {
377 op_type: "plc_operation".to_string(),
378 rotation_keys: vec!["did:key:z1".to_string(), "did:key:z2".to_string()],
379 verification_methods: vm,
380 also_known_as: vec!["at://test.bsky.social".to_string()],
381 services: BTreeMap::new(),
382 prev: Some("bafytest".to_string()),
383 sig: None,
384 };
385
386 let bytes = serialize_for_signing(&op).unwrap();
387
388 // Should be valid CBOR that deserializes back
389 let val: serde_json::Value = serde_ipld_dagcbor::from_slice(&bytes).unwrap();
390 assert_eq!(val["type"], "plc_operation");
391 assert!(val.get("sig").is_none());
392 assert_eq!(val["rotationKeys"].as_array().unwrap().len(), 2);
393 }
394
395 #[test]
396 fn test_serialize_deterministic() {
397 let op = PlcOperation {
398 op_type: "plc_operation".to_string(),
399 rotation_keys: vec!["did:key:z1".to_string()],
400 verification_methods: BTreeMap::new(),
401 also_known_as: vec![],
402 services: BTreeMap::new(),
403 prev: Some("bafytest".to_string()),
404 sig: None,
405 };
406
407 // Serialize twice, should get identical bytes
408 let bytes1 = serialize_for_signing(&op).unwrap();
409 let bytes2 = serialize_for_signing(&op).unwrap();
410 assert_eq!(bytes1, bytes2, "DAG-CBOR serialization should be deterministic");
411 }
412
413 #[test]
414 fn test_compute_cid_deterministic() {
415 let op = PlcOperation {
416 op_type: "plc_operation".to_string(),
417 rotation_keys: vec!["did:key:z1".to_string()],
418 verification_methods: BTreeMap::new(),
419 also_known_as: vec![],
420 services: BTreeMap::new(),
421 prev: None,
422 sig: Some("sig123".to_string()),
423 };
424
425 let cid1 = compute_cid(&op).unwrap();
426 let cid2 = compute_cid(&op).unwrap();
427 assert_eq!(cid1, cid2, "CID computation should be deterministic");
428 }
429
430 #[test]
431 fn test_compute_cid_different_ops_different_cids() {
432 let op1 = PlcOperation {
433 op_type: "plc_operation".to_string(),
434 rotation_keys: vec!["did:key:z1".to_string()],
435 verification_methods: BTreeMap::new(),
436 also_known_as: vec![],
437 services: BTreeMap::new(),
438 prev: None,
439 sig: Some("sig1".to_string()),
440 };
441
442 let op2 = PlcOperation {
443 op_type: "plc_operation".to_string(),
444 rotation_keys: vec!["did:key:z2".to_string()], // different key
445 verification_methods: BTreeMap::new(),
446 also_known_as: vec![],
447 services: BTreeMap::new(),
448 prev: None,
449 sig: Some("sig2".to_string()),
450 };
451
452 let cid1 = compute_cid(&op1).unwrap();
453 let cid2 = compute_cid(&op2).unwrap();
454 assert_ne!(cid1, cid2);
455 }
456
457 #[test]
458 fn test_compute_cid_length() {
459 let op = PlcOperation {
460 op_type: "plc_operation".to_string(),
461 rotation_keys: vec![],
462 verification_methods: BTreeMap::new(),
463 also_known_as: vec![],
464 services: BTreeMap::new(),
465 prev: None,
466 sig: Some("s".to_string()),
467 };
468
469 let cid = compute_cid(&op).unwrap();
470 // CIDv1 with base32: 'b' prefix + base32(1 + 1 + 1 + 1 + 32 = 36 bytes)
471 // base32 of 36 bytes = ceil(36*8/5) = 58 chars
472 assert!(cid.len() > 50, "CID should be reasonably long: {}", cid);
473 assert!(cid.starts_with("b")); // base32lower prefix
474 }
475
476 #[test]
477 fn test_base32_encode_empty() {
478 assert_eq!(base32_encode(&[]), "");
479 }
480
481 #[test]
482 fn test_base32_encode_known_vector() {
483 // RFC 4648 test vectors (lowercase)
484 assert_eq!(base32_encode(b"f"), "my");
485 assert_eq!(base32_encode(b"fo"), "mzxq");
486 assert_eq!(base32_encode(b"foo"), "mzxw6");
487 assert_eq!(base32_encode(b"foob"), "mzxw6yq");
488 assert_eq!(base32_encode(b"fooba"), "mzxw6ytb");
489 assert_eq!(base32_encode(b"foobar"), "mzxw6ytboi");
490 }
491
492 #[test]
493 fn test_compute_diff_no_changes() {
494 let state = PlcState {
495 did: "did:plc:test".to_string(),
496 rotation_keys: vec!["did:key:k1".to_string()],
497 verification_methods: BTreeMap::new(),
498 also_known_as: vec!["at://test".to_string()],
499 services: BTreeMap::new(),
500 };
501
502 let op = PlcOperation {
503 op_type: "plc_operation".to_string(),
504 rotation_keys: vec!["did:key:k1".to_string()],
505 verification_methods: BTreeMap::new(),
506 also_known_as: vec!["at://test".to_string()],
507 services: BTreeMap::new(),
508 prev: Some("bafyprev".to_string()),
509 sig: None,
510 };
511
512 let diff = compute_diff(&state, &op);
513 assert_eq!(diff.changes.len(), 1);
514 assert!(diff.changes[0].description.contains("No visible changes"));
515 }
516
517 #[test]
518 fn test_compute_diff_rotation_key_added() {
519 let state = PlcState {
520 did: "did:plc:test".to_string(),
521 rotation_keys: vec!["did:key:k1".to_string()],
522 verification_methods: BTreeMap::new(),
523 also_known_as: vec![],
524 services: BTreeMap::new(),
525 };
526
527 let op = PlcOperation {
528 op_type: "plc_operation".to_string(),
529 rotation_keys: vec![
530 "did:key:k1".to_string(),
531 "did:key:k2".to_string(),
532 ],
533 verification_methods: BTreeMap::new(),
534 also_known_as: vec![],
535 services: BTreeMap::new(),
536 prev: None,
537 sig: None,
538 };
539
540 let diff = compute_diff(&state, &op);
541 let added: Vec<_> = diff.changes.iter().filter(|c| c.kind == "added").collect();
542 assert_eq!(added.len(), 1);
543 assert!(added[0].description.contains("rotationKeys[1]"));
544 }
545
546 #[test]
547 fn test_compute_diff_rotation_key_removed() {
548 let state = PlcState {
549 did: "did:plc:test".to_string(),
550 rotation_keys: vec![
551 "did:key:k1".to_string(),
552 "did:key:k2".to_string(),
553 "did:key:k3".to_string(),
554 ],
555 verification_methods: BTreeMap::new(),
556 also_known_as: vec![],
557 services: BTreeMap::new(),
558 };
559
560 let op = PlcOperation {
561 op_type: "plc_operation".to_string(),
562 rotation_keys: vec!["did:key:k1".to_string()],
563 verification_methods: BTreeMap::new(),
564 also_known_as: vec![],
565 services: BTreeMap::new(),
566 prev: None,
567 sig: None,
568 };
569
570 let diff = compute_diff(&state, &op);
571 let removed: Vec<_> = diff.changes.iter().filter(|c| c.kind == "removed").collect();
572 assert_eq!(removed.len(), 2);
573 }
574
575 #[test]
576 fn test_compute_diff_rotation_key_modified() {
577 let state = PlcState {
578 did: "did:plc:test".to_string(),
579 rotation_keys: vec!["did:key:old".to_string()],
580 verification_methods: BTreeMap::new(),
581 also_known_as: vec![],
582 services: BTreeMap::new(),
583 };
584
585 let op = PlcOperation {
586 op_type: "plc_operation".to_string(),
587 rotation_keys: vec!["did:key:new".to_string()],
588 verification_methods: BTreeMap::new(),
589 also_known_as: vec![],
590 services: BTreeMap::new(),
591 prev: None,
592 sig: None,
593 };
594
595 let diff = compute_diff(&state, &op);
596 let modified: Vec<_> = diff.changes.iter().filter(|c| c.kind == "modified").collect();
597 assert_eq!(modified.len(), 1);
598 assert!(modified[0].description.contains("rotationKeys[0]"));
599 }
600
601 #[test]
602 fn test_compute_diff_handle_changed() {
603 let state = PlcState {
604 did: "did:plc:test".to_string(),
605 rotation_keys: vec![],
606 verification_methods: BTreeMap::new(),
607 also_known_as: vec!["at://old.handle".to_string()],
608 services: BTreeMap::new(),
609 };
610
611 let op = PlcOperation {
612 op_type: "plc_operation".to_string(),
613 rotation_keys: vec![],
614 verification_methods: BTreeMap::new(),
615 also_known_as: vec!["at://new.handle".to_string()],
616 services: BTreeMap::new(),
617 prev: None,
618 sig: None,
619 };
620
621 let diff = compute_diff(&state, &op);
622 let aka_changes: Vec<_> = diff
623 .changes
624 .iter()
625 .filter(|c| c.description.contains("alsoKnownAs"))
626 .collect();
627 assert_eq!(aka_changes.len(), 1);
628 }
629
630 #[test]
631 fn test_compute_diff_verification_methods_changed() {
632 let mut old_vm = BTreeMap::new();
633 old_vm.insert("atproto".to_string(), "did:key:old".to_string());
634
635 let mut new_vm = BTreeMap::new();
636 new_vm.insert("atproto".to_string(), "did:key:new".to_string());
637
638 let state = PlcState {
639 did: "did:plc:test".to_string(),
640 rotation_keys: vec![],
641 verification_methods: old_vm,
642 also_known_as: vec![],
643 services: BTreeMap::new(),
644 };
645
646 let op = PlcOperation {
647 op_type: "plc_operation".to_string(),
648 rotation_keys: vec![],
649 verification_methods: new_vm,
650 also_known_as: vec![],
651 services: BTreeMap::new(),
652 prev: None,
653 sig: None,
654 };
655
656 let diff = compute_diff(&state, &op);
657 let vm_changes: Vec<_> = diff
658 .changes
659 .iter()
660 .filter(|c| c.description.contains("verificationMethods"))
661 .collect();
662 assert_eq!(vm_changes.len(), 1);
663 }
664
665 #[test]
666 fn test_compute_diff_service_endpoint_changed() {
667 let mut old_svc = BTreeMap::new();
668 old_svc.insert(
669 "atproto_pds".to_string(),
670 PlcService {
671 service_type: "AtprotoPersonalDataServer".to_string(),
672 endpoint: "https://old-pds.example.com".to_string(),
673 },
674 );
675
676 let mut new_svc = BTreeMap::new();
677 new_svc.insert(
678 "atproto_pds".to_string(),
679 PlcService {
680 service_type: "AtprotoPersonalDataServer".to_string(),
681 endpoint: "https://new-pds.example.com".to_string(),
682 },
683 );
684
685 let state = PlcState {
686 did: "did:plc:test".to_string(),
687 rotation_keys: vec![],
688 verification_methods: BTreeMap::new(),
689 also_known_as: vec![],
690 services: old_svc,
691 };
692
693 let op = PlcOperation {
694 op_type: "plc_operation".to_string(),
695 rotation_keys: vec![],
696 verification_methods: BTreeMap::new(),
697 also_known_as: vec![],
698 services: new_svc,
699 prev: None,
700 sig: None,
701 };
702
703 let diff = compute_diff(&state, &op);
704 let svc_changes: Vec<_> = diff
705 .changes
706 .iter()
707 .filter(|c| c.description.contains("services.atproto_pds"))
708 .collect();
709 assert_eq!(svc_changes.len(), 1);
710 assert!(svc_changes[0].description.contains("old-pds"));
711 assert!(svc_changes[0].description.contains("new-pds"));
712 }
713
714 #[test]
715 fn test_compute_diff_service_added() {
716 let state = PlcState {
717 did: "did:plc:test".to_string(),
718 rotation_keys: vec![],
719 verification_methods: BTreeMap::new(),
720 also_known_as: vec![],
721 services: BTreeMap::new(),
722 };
723
724 let mut new_svc = BTreeMap::new();
725 new_svc.insert(
726 "atproto_pds".to_string(),
727 PlcService {
728 service_type: "AtprotoPersonalDataServer".to_string(),
729 endpoint: "https://pds.example.com".to_string(),
730 },
731 );
732
733 let op = PlcOperation {
734 op_type: "plc_operation".to_string(),
735 rotation_keys: vec![],
736 verification_methods: BTreeMap::new(),
737 also_known_as: vec![],
738 services: new_svc,
739 prev: None,
740 sig: None,
741 };
742
743 let diff = compute_diff(&state, &op);
744 let added: Vec<_> = diff.changes.iter().filter(|c| c.kind == "added").collect();
745 assert_eq!(added.len(), 1);
746 assert!(added[0].description.contains("services.atproto_pds"));
747 }
748
749 #[test]
750 fn test_truncate_key_short() {
751 assert_eq!(truncate_key("short"), "short");
752 }
753
754 #[test]
755 fn test_truncate_key_long() {
756 let long_key = "did:key:zDnaeLongKeyThatExceedsThirtyCharactersForSure";
757 let truncated = truncate_key(long_key);
758 assert!(truncated.ends_with("..."));
759 assert_eq!(truncated.len(), 33); // 30 chars + "..."
760 }
761
762 #[test]
763 fn test_truncate_key_exactly_30() {
764 let key = "a".repeat(30);
765 assert_eq!(truncate_key(&key), key); // no truncation
766 }
767
768 #[test]
769 fn test_plc_operation_json_serialization() {
770 let mut services = BTreeMap::new();
771 services.insert(
772 "atproto_pds".to_string(),
773 PlcService {
774 service_type: "AtprotoPersonalDataServer".to_string(),
775 endpoint: "https://pds.example.com".to_string(),
776 },
777 );
778
779 let op = PlcOperation {
780 op_type: "plc_operation".to_string(),
781 rotation_keys: vec!["did:key:z1".to_string()],
782 verification_methods: BTreeMap::new(),
783 also_known_as: vec!["at://test.handle".to_string()],
784 services,
785 prev: Some("bafytest".to_string()),
786 sig: None,
787 };
788
789 let json = serde_json::to_value(&op).unwrap();
790 assert_eq!(json["type"], "plc_operation");
791 assert!(json.get("sig").is_none()); // skip_serializing_if
792 assert_eq!(json["prev"], "bafytest");
793 assert_eq!(json["rotationKeys"][0], "did:key:z1");
794 assert_eq!(json["alsoKnownAs"][0], "at://test.handle");
795 }
796
797 #[test]
798 fn test_plc_operation_json_with_sig() {
799 let op = PlcOperation {
800 op_type: "plc_operation".to_string(),
801 rotation_keys: vec![],
802 verification_methods: BTreeMap::new(),
803 also_known_as: vec![],
804 services: BTreeMap::new(),
805 prev: None,
806 sig: Some("base64urlsig".to_string()),
807 };
808
809 let json = serde_json::to_value(&op).unwrap();
810 assert_eq!(json["sig"], "base64urlsig");
811 assert!(json.get("prev").is_none()); // skip_serializing_if Option::is_none
812 }
813
814 #[test]
815 fn test_plc_state_deserialization() {
816 let json = serde_json::json!({
817 "did": "did:plc:test123",
818 "rotationKeys": ["did:key:z1", "did:key:z2"],
819 "verificationMethods": {"atproto": "did:key:zV"},
820 "alsoKnownAs": ["at://alice.test"],
821 "services": {
822 "atproto_pds": {
823 "type": "AtprotoPersonalDataServer",
824 "endpoint": "https://pds.example.com"
825 }
826 }
827 });
828
829 let state: PlcState = serde_json::from_value(json).unwrap();
830 assert_eq!(state.did, "did:plc:test123");
831 assert_eq!(state.rotation_keys.len(), 2);
832 assert_eq!(state.verification_methods["atproto"], "did:key:zV");
833 assert_eq!(state.also_known_as[0], "at://alice.test");
834 assert_eq!(state.services["atproto_pds"].endpoint, "https://pds.example.com");
835 }
836
837 #[test]
838 fn test_dag_cbor_field_ordering() {
839 // DAG-CBOR sorts map keys by length then lexicographic.
840 // Verify our serialization is consistent.
841 let op = PlcOperation {
842 op_type: "plc_operation".to_string(),
843 rotation_keys: vec!["did:key:z1".to_string()],
844 verification_methods: BTreeMap::new(),
845 also_known_as: vec![],
846 services: BTreeMap::new(),
847 prev: Some("bafytest".to_string()),
848 sig: None,
849 };
850
851 let bytes = serialize_for_signing(&op).unwrap();
852
853 // Round-trip: serialize -> deserialize -> serialize should be identical
854 let val: serde_json::Value = serde_ipld_dagcbor::from_slice(&bytes).unwrap();
855 // Re-construct the operation from deserialized values
856 let bytes2 = serde_ipld_dagcbor::to_vec(&val).unwrap();
857 // The bytes should be identical (deterministic encoding)
858 assert_eq!(bytes, bytes2, "DAG-CBOR round-trip should produce identical bytes");
859 }
860
861 #[test]
862 fn test_dag_cbor_key_names_and_order() {
863 let mut services = BTreeMap::new();
864 services.insert("atproto_pds".to_string(), PlcService {
865 service_type: "AtprotoPersonalDataServer".to_string(),
866 endpoint: "https://pds.example.com".to_string(),
867 });
868 let mut vm = BTreeMap::new();
869 vm.insert("atproto".to_string(), "did:key:zV".to_string());
870
871 let op = PlcOperation {
872 op_type: "plc_operation".to_string(),
873 rotation_keys: vec!["did:key:z1".to_string()],
874 verification_methods: vm,
875 also_known_as: vec!["at://test.handle".to_string()],
876 services,
877 prev: Some("bafytest".to_string()),
878 sig: None,
879 };
880
881 let bytes = serialize_for_signing(&op).unwrap();
882
883 // Verify key names are correct by round-tripping through JSON
884 let val: serde_json::Value = serde_ipld_dagcbor::from_slice(&bytes).unwrap();
885 let obj = val.as_object().unwrap();
886 assert!(obj.contains_key("type"), "should have 'type' key");
887 assert!(obj.contains_key("prev"), "should have 'prev' key");
888 assert!(obj.contains_key("rotationKeys"), "should have 'rotationKeys' key");
889 assert!(obj.contains_key("alsoKnownAs"), "should have 'alsoKnownAs' key");
890 assert!(obj.contains_key("services"), "should have 'services' key");
891 assert!(obj.contains_key("verificationMethods"), "should have 'verificationMethods' key");
892 assert!(!obj.contains_key("sig"), "sig should be absent");
893
894 // Verify deterministic round-trip (proves canonical DAG-CBOR ordering)
895 let bytes2 = serde_ipld_dagcbor::to_vec(&val).unwrap();
896 assert_eq!(bytes, bytes2, "DAG-CBOR round-trip should produce identical bytes");
897 }
898}