car_sync/org_key_directory.rs
1//! Org-key directory — the publish/fetch surface for the client-side org-key
2//! agreement (the follow-up named in `crypto.rs` alongside the merged
3//! `wrap_org_key`/`unwrap_org_key` primitives).
4//!
5//! ## What this carries — and why it is NOT the oplog
6//!
7//! The org master key `K_org` is shared across an org's members by *wrapping*
8//! it once per member ([`crate::crypto::wrap_org_key`], ECIES over X25519) so
9//! every member can recover the same key while the relay only ever sees
10//! ciphertext. To distribute those wraps, members need a place to **publish**
11//! their [`WrappedOrgKey`] blobs and their X25519 **public keys**, and to
12//! **fetch** the ones addressed to them.
13//!
14//! That surface is a **key-value directory**, not an append-only op stream:
15//!
16//! - a wrapped blob is keyed by `(epoch, recipient_user_id)` within one org;
17//! - a member public key is keyed by `account_id`;
18//! - there is no sequence chain, no frontier, no GC horizon, no checkpoint
19//! dominance — the invariants that define [`crate::relay::Relay`].
20//!
21//! So it is deliberately a **separate trait**, not four more methods bolted
22//! onto `Relay`/`SyncTransport`. Bolting KV semantics onto the oplog state
23//! machine would let a blob be counted in a device frontier or swept by a GC
24//! pass that mistook it for a stale op. Routing org-key traffic around the
25//! oplog keeps both models honest. (This mirrors the crate's own idiom: a
26//! [`OrgKeyDirectory`] trait over a pure [`OrgKeyDirectoryState`] machine,
27//! driven by both an in-memory and an fs-backed reference — exactly as
28//! `Relay` is driven by `InMemoryRelay` + `FsRelay`.)
29//!
30//! ## Scope, and what this slice is (and is not)
31//!
32//! Like [`crate::relay::Relay`], the trait here is **single-directory**: one
33//! handle serves one org's directory (an [`FsOrgKeyDirectory`] is bound to one
34//! dir, just as an `FsRelay` is bound to one scope dir). The scope-keyed,
35//! network-faithful form (the `org:<id>` param, mirroring
36//! [`crate::net_relay::SyncTransport`]) and the Parslee/m365 backend come in a
37//! later slice — this one is the pure reference: no prod caller dispatches it,
38//! no `SyncTransport`/`car-parslee` change is forced, and it stays inert until
39//! the org-scope path is wired behind the cryptographer-audit gate.
40//!
41//! ## Trust boundary — publisher authz is CONFIDENTIALITY-critical, not availability
42//!
43//! This reference is a **dumb store**: `publish_*` is last-write-wins and does
44//! NOT authenticate the publisher. That is the right shape for a pure state
45//! machine — but do not mistake it for "just a DoS surface." The authz the
46//! backend slice must add ("only the account itself may publish its own pubkey,
47//! and only a legitimate `K_org` holder may publish a wrap") is a
48//! **confidentiality** control. Unauthenticated publish enables two attacks,
49//! both strictly worse than denial of service:
50//!
51//! - **Wrap-table poisoning → key substitution.** A member's X25519 public key
52//! is public (it is served from THIS directory). Anyone can therefore wrap an
53//! attacker-chosen `K_org'` against a victim's real pubkey and publish it;
54//! the victim's [`crate::crypto::unwrap_org_key`] *authenticates* it (the DH
55//! matches the victim's secret, the bound `user_id` is the victim's), so the
56//! victim adopts the attacker's key and encrypts future org data under it.
57//! - **Pubkey-table poisoning → genuine `K_org` leak.** Overwrite a victim's
58//! pubkey entry with the attacker's own pubkey; a legitimate holder of
59//! `K_org` then wraps the REAL `K_org` against that attacker pubkey (it
60//! believes it is the victim's), and the attacker unwraps it with their own
61//! secret and the victim's `user_id` — recovering the real org key.
62//!
63//! Neither attack is stopped by the AEAD / contributory-DH checks in
64//! `unwrap_org_key`: those only prove "some wrapper used my `user_id` and a
65//! pubkey matching my secret," and both inputs are public. The `recipient`
66//! field is likewise ADVISORY (see [`crate::crypto::WrappedOrgKey`]) and must
67//! not be routed or authorized on. So the backend MUST authenticate the
68//! publisher of every `publish_wrapped` / `publish_pubkey`; treating that as
69//! optional hardening is a key-compromise bug, not a UX one. The one thing the
70//! wrap ciphertext itself never leaks is `K_org` to a passive relay — but the
71//! pubkey-poisoning path above leaks it to an *active* publisher, which is why
72//! publish authz cannot be deferred as availability-only.
73
74use crate::crypto::WrappedOrgKey;
75use serde::{Deserialize, Serialize};
76use std::collections::BTreeMap;
77use std::fmt;
78use std::fs::{self, File, OpenOptions};
79use std::io::Write;
80use std::path::{Path, PathBuf};
81
82/// A member's published X25519 identity public key (the recipient key
83/// [`crate::crypto::wrap_org_key`] wraps against), addressed by `account_id`.
84/// `public_hex` is the lowercase hex of the 32-byte X25519 point produced by
85/// [`crate::crypto::x25519_public`]; the directory stores it opaquely and does
86/// not validate the point (validation happens at wrap/unwrap time).
87#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
88pub struct MemberPublicKey {
89 pub account_id: String,
90 pub public_hex: String,
91}
92
93/// Failures publishing to / fetching from an org-key directory.
94#[derive(Debug)]
95pub enum OrgKeyDirectoryError {
96 /// Persistence I/O (fs-backed directory only).
97 Io(std::io::Error),
98 /// The publisher is not authorized for this directory / key. This is a
99 /// LOAD-BEARING, distinct signal — NOT a transient failure to retry.
100 /// Because publisher authz here is confidentiality-critical (see the module
101 /// doc: unauthenticated publish enables key substitution + `K_org` leak),
102 /// the transport-backed directory must surface a backend authz refusal as
103 /// THIS variant, never collapse it into `Io` where a retry loop would treat
104 /// a security "no" as a network blip.
105 Unauthorized(String),
106}
107
108impl fmt::Display for OrgKeyDirectoryError {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 match self {
111 OrgKeyDirectoryError::Io(e) => write!(f, "org-key directory io error: {e}"),
112 OrgKeyDirectoryError::Unauthorized(m) => {
113 write!(f, "org-key directory unauthorized: {m}")
114 }
115 }
116 }
117}
118
119impl std::error::Error for OrgKeyDirectoryError {
120 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
121 match self {
122 OrgKeyDirectoryError::Io(e) => Some(e),
123 OrgKeyDirectoryError::Unauthorized(_) => None,
124 }
125 }
126}
127
128/// The publish/fetch surface for one org's directory.
129///
130/// Single-directory (no `org` param) — one handle serves one org, mirroring
131/// [`crate::relay::Relay`]. `&mut self` because the fs-backed reference takes
132/// an exclusive file lock and persists on write.
133pub trait OrgKeyDirectory {
134 /// Publish (or replace) the wrap for `(wrapped.epoch, wrapped.recipient)`.
135 /// Last-write-wins — re-publishing a fresh wrap of the same `K_org`
136 /// (a new ephemeral each time) for a recipient is expected and benign.
137 fn publish_wrapped(&mut self, wrapped: &WrappedOrgKey) -> Result<(), OrgKeyDirectoryError>;
138
139 /// Fetch the wrap addressed to `recipient_user_id` at exactly `epoch`, if
140 /// one has been published. Reads are pure (`&self`) — unlike
141 /// [`crate::relay::Relay`], this type has no per-call roster/checkpoint
142 /// reconciliation, so a fetch never rewrites state (the fs reference takes a
143 /// shared lock and returns; no fsync-on-read).
144 fn fetch_wrapped(
145 &self,
146 epoch: u64,
147 recipient_user_id: &str,
148 ) -> Result<Option<WrappedOrgKey>, OrgKeyDirectoryError>;
149
150 /// Every wrap addressed to `recipient_user_id`, newest epoch first — the
151 /// client picks the highest epoch it can [`crate::crypto::unwrap_org_key`].
152 fn fetch_wrapped_for(
153 &self,
154 recipient_user_id: &str,
155 ) -> Result<Vec<WrappedOrgKey>, OrgKeyDirectoryError>;
156
157 /// Publish (or replace) `account_id`'s X25519 public key.
158 fn publish_pubkey(
159 &mut self,
160 account_id: &str,
161 public_hex: &str,
162 ) -> Result<(), OrgKeyDirectoryError>;
163
164 /// Every member public key in this directory, ordered by `account_id`.
165 fn fetch_pubkeys(&self) -> Result<Vec<MemberPublicKey>, OrgKeyDirectoryError>;
166}
167
168/// The pure directory state — the same "trait over a pure state machine"
169/// shape as [`crate::relay::RelayState`]. Both references drive this; it holds
170/// no I/O and is deterministic.
171#[derive(Clone, Debug, Default, Serialize, Deserialize)]
172pub struct OrgKeyDirectoryState {
173 /// `recipient_user_id → (epoch → wrap)`. Nested so a recipient's wraps
174 /// across epochs stay grouped for `fetch_wrapped_for`. `BTreeMap<u64, _>`
175 /// serializes to a JSON object with stringified integer keys (serde_json).
176 ///
177 /// TODO(org-scope): the key is `(recipient, epoch)` only — `wrapped.org` is
178 /// NOT part of it because this handle is single-org by construction (one dir
179 /// = one org, like `FsRelay` = one scope). When the scope-keyed `org:<id>`
180 /// transport form lands, the key must include `org` (or the handle must
181 /// carry + verify it) or two orgs sharing a `(recipient, epoch)` in one
182 /// store would collide.
183 #[serde(default)]
184 wrapped: BTreeMap<String, BTreeMap<u64, WrappedOrgKey>>,
185 /// `account_id → X25519 public key hex`.
186 #[serde(default)]
187 pubkeys: BTreeMap<String, String>,
188}
189
190impl OrgKeyDirectoryState {
191 fn publish_wrapped(&mut self, wrapped: &WrappedOrgKey) {
192 self.wrapped
193 .entry(wrapped.recipient.clone())
194 .or_default()
195 .insert(wrapped.epoch, wrapped.clone());
196 }
197
198 fn fetch_wrapped(&self, epoch: u64, recipient_user_id: &str) -> Option<WrappedOrgKey> {
199 self.wrapped
200 .get(recipient_user_id)
201 .and_then(|by_epoch| by_epoch.get(&epoch))
202 .cloned()
203 }
204
205 fn fetch_wrapped_for(&self, recipient_user_id: &str) -> Vec<WrappedOrgKey> {
206 self.wrapped
207 .get(recipient_user_id)
208 .map(|by_epoch| by_epoch.values().rev().cloned().collect())
209 .unwrap_or_default()
210 }
211
212 fn publish_pubkey(&mut self, account_id: &str, public_hex: &str) {
213 self.pubkeys
214 .insert(account_id.to_string(), public_hex.to_string());
215 }
216
217 fn fetch_pubkeys(&self) -> Vec<MemberPublicKey> {
218 self.pubkeys
219 .iter()
220 .map(|(account_id, public_hex)| MemberPublicKey {
221 account_id: account_id.clone(),
222 public_hex: public_hex.clone(),
223 })
224 .collect()
225 }
226}
227
228/// In-memory reference — tests and in-process coordination (the
229/// [`crate::relay::InMemoryRelay`] analogue).
230#[derive(Clone, Debug, Default)]
231pub struct InMemoryOrgKeyDirectory {
232 state: OrgKeyDirectoryState,
233}
234
235impl InMemoryOrgKeyDirectory {
236 pub fn new() -> Self {
237 Self::default()
238 }
239}
240
241impl OrgKeyDirectory for InMemoryOrgKeyDirectory {
242 fn publish_wrapped(&mut self, wrapped: &WrappedOrgKey) -> Result<(), OrgKeyDirectoryError> {
243 self.state.publish_wrapped(wrapped);
244 Ok(())
245 }
246
247 fn fetch_wrapped(
248 &self,
249 epoch: u64,
250 recipient_user_id: &str,
251 ) -> Result<Option<WrappedOrgKey>, OrgKeyDirectoryError> {
252 Ok(self.state.fetch_wrapped(epoch, recipient_user_id))
253 }
254
255 fn fetch_wrapped_for(
256 &self,
257 recipient_user_id: &str,
258 ) -> Result<Vec<WrappedOrgKey>, OrgKeyDirectoryError> {
259 Ok(self.state.fetch_wrapped_for(recipient_user_id))
260 }
261
262 fn publish_pubkey(
263 &mut self,
264 account_id: &str,
265 public_hex: &str,
266 ) -> Result<(), OrgKeyDirectoryError> {
267 self.state.publish_pubkey(account_id, public_hex);
268 Ok(())
269 }
270
271 fn fetch_pubkeys(&self) -> Result<Vec<MemberPublicKey>, OrgKeyDirectoryError> {
272 Ok(self.state.fetch_pubkeys())
273 }
274}
275
276/// Fs-backed reference — one directory of one org's wraps + member pubkeys,
277/// the [`crate::relay::FsRelay`] analogue (the realistic single-host / shared
278/// dir). Every mutation runs under an exclusive lock file and persists the
279/// whole state via temp-write + fsync + atomic rename, matching `FsRelay`.
280#[derive(Debug)]
281pub struct FsOrgKeyDirectory {
282 dir: PathBuf,
283}
284
285impl FsOrgKeyDirectory {
286 pub fn open(dir: &Path) -> std::io::Result<Self> {
287 fs::create_dir_all(dir)?;
288 Ok(Self {
289 dir: dir.to_path_buf(),
290 })
291 }
292
293 fn state_path(&self) -> PathBuf {
294 self.dir.join("org-keys.json")
295 }
296
297 fn lock_path(&self) -> PathBuf {
298 self.dir.join("org-keys.lock")
299 }
300
301 /// Deserialize the state file; a missing file is an empty directory.
302 fn read_state(path: &Path) -> Result<OrgKeyDirectoryState, OrgKeyDirectoryError> {
303 match fs::read_to_string(path) {
304 Ok(raw) => serde_json::from_str(&raw)
305 .map_err(|e| OrgKeyDirectoryError::Io(std::io::Error::other(e))),
306 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
307 Ok(OrgKeyDirectoryState::default())
308 }
309 Err(e) => Err(OrgKeyDirectoryError::Io(e)),
310 }
311 }
312
313 /// Load state under a SHARED lock — a pure read that never rewrites the
314 /// file. Unlike [`crate::relay::FsRelay`], which rewrites on every call to
315 /// run roster liveness + checkpoint reconciliation, a directory fetch has
316 /// no such side work, so reads take a shared lock (concurrent readers) and
317 /// return — no fsync-on-read, and `fetch_*` stay `&self`.
318 fn load(&self) -> Result<OrgKeyDirectoryState, OrgKeyDirectoryError> {
319 let lock = OpenOptions::new()
320 .read(true)
321 .write(true)
322 .create(true)
323 .truncate(false)
324 .open(self.lock_path())
325 .map_err(OrgKeyDirectoryError::Io)?;
326 lock.lock_shared().map_err(OrgKeyDirectoryError::Io)?; // shared; released on drop
327 Self::read_state(&self.state_path())
328 }
329
330 /// Run `f` over the loaded state under the EXCLUSIVE lock, then persist —
331 /// same durable-write shape as [`crate::relay::FsRelay::with_state`].
332 fn with_state<T>(
333 &mut self,
334 f: impl FnOnce(&mut OrgKeyDirectoryState) -> T,
335 ) -> Result<T, OrgKeyDirectoryError> {
336 let lock = OpenOptions::new()
337 .read(true)
338 .write(true)
339 .create(true)
340 .truncate(false)
341 .open(self.lock_path())
342 .map_err(OrgKeyDirectoryError::Io)?;
343 lock.lock().map_err(OrgKeyDirectoryError::Io)?; // exclusive; released on drop
344
345 let mut state = Self::read_state(&self.state_path())?;
346
347 let result = f(&mut state);
348
349 let tmp = self.dir.join("org-keys.json.tmp");
350 {
351 let mut file = File::create(&tmp).map_err(OrgKeyDirectoryError::Io)?;
352 file.write_all(
353 serde_json::to_string(&state)
354 .map_err(|e| OrgKeyDirectoryError::Io(std::io::Error::other(e)))?
355 .as_bytes(),
356 )
357 .map_err(OrgKeyDirectoryError::Io)?;
358 file.sync_all().map_err(OrgKeyDirectoryError::Io)?;
359 }
360 fs::rename(&tmp, self.state_path()).map_err(OrgKeyDirectoryError::Io)?;
361 Ok(result)
362 }
363}
364
365impl OrgKeyDirectory for FsOrgKeyDirectory {
366 fn publish_wrapped(&mut self, wrapped: &WrappedOrgKey) -> Result<(), OrgKeyDirectoryError> {
367 self.with_state(|state| state.publish_wrapped(wrapped))
368 }
369
370 fn fetch_wrapped(
371 &self,
372 epoch: u64,
373 recipient_user_id: &str,
374 ) -> Result<Option<WrappedOrgKey>, OrgKeyDirectoryError> {
375 Ok(self.load()?.fetch_wrapped(epoch, recipient_user_id))
376 }
377
378 fn fetch_wrapped_for(
379 &self,
380 recipient_user_id: &str,
381 ) -> Result<Vec<WrappedOrgKey>, OrgKeyDirectoryError> {
382 Ok(self.load()?.fetch_wrapped_for(recipient_user_id))
383 }
384
385 fn publish_pubkey(
386 &mut self,
387 account_id: &str,
388 public_hex: &str,
389 ) -> Result<(), OrgKeyDirectoryError> {
390 self.with_state(|state| state.publish_pubkey(account_id, public_hex))
391 }
392
393 fn fetch_pubkeys(&self) -> Result<Vec<MemberPublicKey>, OrgKeyDirectoryError> {
394 Ok(self.load()?.fetch_pubkeys())
395 }
396}
397
398/// The shared behavioural contract for any [`OrgKeyDirectory`] — kept out of
399/// `mod tests` (as `pub(crate)`) so both the reference tests here AND the
400/// `LoopbackTransport`-backed `NetworkOrgKeyDirectory` tests in
401/// [`crate::net_relay`] drive the *same* sequence, proving the wire form is
402/// identical to these references before a byte crosses into `car-parslee`.
403#[cfg(test)]
404pub(crate) mod conformance {
405 use super::*;
406 use crate::crypto::{
407 derive_ed25519_identity, derive_x25519_identity, wrap_org_key, x25519_public,
408 StretchedMaster,
409 };
410
411 /// A real wrap for `recipient` at `epoch` — exercises the actual crypto (now
412 /// signed by a publisher) so the directory is proven to carry the genuine wire
413 /// type intact, not a hand-built stand-in. Uses issued-high-entropy masters
414 /// (skips Argon2id) so the conformance suite stays fast.
415 pub(crate) fn make_wrap(org: &str, epoch: u64, recipient: &str) -> WrappedOrgKey {
416 let k_org = [7u8; 32];
417 let sk = derive_x25519_identity(
418 &StretchedMaster::from_issued_high_entropy(b"login-secret-for-tests", recipient),
419 recipient,
420 );
421 let recipient_pub = x25519_public(&sk);
422 let signer = derive_ed25519_identity(
423 &StretchedMaster::from_issued_high_entropy(b"granter-login-for-tests", "acc_granter"),
424 "acc_granter",
425 );
426 wrap_org_key(
427 &k_org,
428 org,
429 epoch,
430 recipient,
431 &recipient_pub,
432 "acc_granter",
433 &signer,
434 )
435 .expect("wrap succeeds")
436 }
437
438 /// The behavioural contract every `OrgKeyDirectory` implementation must
439 /// satisfy — run against both references here, and reused verbatim against
440 /// the `LoopbackTransport`-backed directory in the next slice.
441 pub(crate) fn round_trip_suite(dir: &mut dyn OrgKeyDirectory) {
442 // wrapped: composite-key (epoch, recipient) round-trip
443 let w = make_wrap("acme", 1, "alice");
444 dir.publish_wrapped(&w).unwrap();
445 assert_eq!(dir.fetch_wrapped(1, "alice").unwrap().as_ref(), Some(&w));
446 // opaque payload preserved bit-identically (the AEAD envelope survives)
447 let got = dir.fetch_wrapped(1, "alice").unwrap().unwrap();
448 assert_eq!(got.envelope, w.envelope);
449 assert_eq!(got.ephemeral_pub, w.ephemeral_pub);
450 assert_eq!(got.car_wrap, w.car_wrap);
451
452 // isolation: wrong epoch / wrong recipient miss
453 assert!(dir.fetch_wrapped(2, "alice").unwrap().is_none());
454 assert!(dir.fetch_wrapped(1, "bob").unwrap().is_none());
455
456 // multiple epochs for one recipient, newest first
457 let w2 = make_wrap("acme", 5, "alice");
458 let w3 = make_wrap("acme", 3, "alice");
459 dir.publish_wrapped(&w2).unwrap();
460 dir.publish_wrapped(&w3).unwrap();
461 let epochs: Vec<u64> = dir
462 .fetch_wrapped_for("alice")
463 .unwrap()
464 .iter()
465 .map(|w| w.epoch)
466 .collect();
467 assert_eq!(epochs, vec![5, 3, 1], "newest epoch first");
468 assert!(dir.fetch_wrapped_for("nobody").unwrap().is_empty());
469
470 // pubkey round-trip + ordering
471 dir.publish_pubkey("bob", "beef").unwrap();
472 dir.publish_pubkey("alice", "cafe").unwrap();
473 let keys = dir.fetch_pubkeys().unwrap();
474 assert_eq!(
475 keys,
476 vec![
477 MemberPublicKey {
478 account_id: "alice".into(),
479 public_hex: "cafe".into()
480 },
481 MemberPublicKey {
482 account_id: "bob".into(),
483 public_hex: "beef".into()
484 },
485 ],
486 "ordered by account_id"
487 );
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::conformance::{make_wrap, round_trip_suite};
494 use super::*;
495 use serde_json::json;
496
497 #[test]
498 fn in_memory_round_trip() {
499 let mut dir = InMemoryOrgKeyDirectory::new();
500 round_trip_suite(&mut dir);
501 }
502
503 #[test]
504 fn fs_round_trip() {
505 let tmp = tempfile::tempdir().unwrap();
506 let mut dir = FsOrgKeyDirectory::open(tmp.path()).unwrap();
507 round_trip_suite(&mut dir);
508 }
509
510 #[test]
511 fn publish_wrapped_is_last_write_wins() {
512 let mut dir = InMemoryOrgKeyDirectory::new();
513 let mut w = make_wrap("acme", 1, "alice");
514 dir.publish_wrapped(&w).unwrap();
515 // a re-publish at the same (epoch, recipient) replaces, not duplicates
516 w.envelope = json!({"car_enc": "org-key-wrap/v1", "nonce": "00", "ct": "ff"});
517 dir.publish_wrapped(&w).unwrap();
518 assert_eq!(dir.fetch_wrapped_for("alice").unwrap().len(), 1);
519 assert_eq!(
520 dir.fetch_wrapped(1, "alice").unwrap().unwrap().envelope,
521 w.envelope
522 );
523 }
524
525 #[test]
526 fn publish_pubkey_is_last_write_wins() {
527 let mut dir = InMemoryOrgKeyDirectory::new();
528 dir.publish_pubkey("alice", "aaaa").unwrap();
529 dir.publish_pubkey("alice", "bbbb").unwrap();
530 let keys = dir.fetch_pubkeys().unwrap();
531 assert_eq!(keys.len(), 1);
532 assert_eq!(keys[0].public_hex, "bbbb");
533 }
534
535 #[test]
536 fn fs_persists_across_handles() {
537 let tmp = tempfile::tempdir().unwrap();
538 let w = make_wrap("acme", 2, "alice");
539 {
540 let mut dir = FsOrgKeyDirectory::open(tmp.path()).unwrap();
541 dir.publish_wrapped(&w).unwrap();
542 dir.publish_pubkey("alice", "cafe").unwrap();
543 }
544 // a fresh handle over the same dir sees the persisted state (reads are
545 // `&self` now, so the handle needs no `mut`)
546 let dir = FsOrgKeyDirectory::open(tmp.path()).unwrap();
547 assert_eq!(dir.fetch_wrapped(2, "alice").unwrap().as_ref(), Some(&w));
548 assert_eq!(dir.fetch_pubkeys().unwrap()[0].public_hex, "cafe");
549 }
550
551 /// Pins the `#[serde(default)]` forward-compat guarantee: a state file
552 /// written by an older build (missing one or both fields, or `{}`) still
553 /// loads as an empty-for-that-field directory rather than failing.
554 #[test]
555 fn state_loads_from_files_missing_fields() {
556 let empty: OrgKeyDirectoryState = serde_json::from_str("{}").unwrap();
557 assert!(empty.fetch_pubkeys().is_empty());
558 assert!(empty.fetch_wrapped_for("alice").is_empty());
559
560 // only pubkeys present → wrapped defaults
561 let partial: OrgKeyDirectoryState =
562 serde_json::from_str(r#"{"pubkeys":{"alice":"ff"}}"#).unwrap();
563 assert_eq!(partial.fetch_pubkeys().len(), 1);
564 assert!(partial.fetch_wrapped_for("alice").is_empty());
565 }
566
567 /// A corrupt state file must SURFACE as an error on both read and write —
568 /// never panic, and never silently reset to an empty directory (which would
569 /// drop everyone's published keys). This is the "someone overwrote the
570 /// file" case the module doc warns about.
571 #[test]
572 fn corrupt_state_file_surfaces_as_error() {
573 let tmp = tempfile::tempdir().unwrap();
574 let mut dir = FsOrgKeyDirectory::open(tmp.path()).unwrap();
575 dir.publish_pubkey("alice", "cafe").unwrap(); // creates the state file
576 std::fs::write(tmp.path().join("org-keys.json"), b"{ not json").unwrap();
577
578 assert!(matches!(
579 dir.fetch_pubkeys(),
580 Err(OrgKeyDirectoryError::Io(_))
581 ));
582 assert!(matches!(
583 dir.publish_pubkey("bob", "beef"),
584 Err(OrgKeyDirectoryError::Io(_))
585 ));
586 }
587}