1use std::collections::BTreeSet;
5use std::path::{Path, PathBuf};
6
7use sha2::{Digest, Sha256};
8
9use offline_protocol_mls::storage::MlsStorage;
10
11use super::pair::PAIRING_ID_BYTES;
12use super::records::{self, BoundedRead, StoreLock, SweepMemo, MAX_FRAME_BYTES, MAX_HEADER_BYTES};
13use super::{account_directory, lock_directory, FileStoreError};
14use crate::protocol::state_crypto::StateRecordCipher;
15use crate::protocol::{sealed_state_key_types, stored_state_record_key, StoredRecordKey};
16use crate::protocol_state_storage::{
17 ProtocolStateError, ProtocolStateResult, ProtocolStateStorage,
18};
19
20const TYPE_PREFIX: &str = "t_";
22
23const ENTRY_PREFIX: &str = "k_";
25
26const LOCK_FILE: &str = "state-store.lock";
29
30const PAIRING_FILE: &str = "state-store.pair";
35
36const SEALED_STATE_PROBE_LIMIT: usize = 64;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67#[non_exhaustive]
68pub enum SealedState {
69 Empty,
71 Opens,
74 Foreign,
80 KeyDamaged,
86}
87
88enum SealedCategory {
90 Empty,
91 Opens,
92 NoneOpens,
93}
94
95pub struct FileProtocolStateStorage {
111 directory: PathBuf,
112 lock: StoreLock,
113 swept: SweepMemo,
114}
115
116impl std::fmt::Debug for FileProtocolStateStorage {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 f.debug_struct("FileProtocolStateStorage")
119 .field("directory", &self.directory)
120 .finish_non_exhaustive()
121 }
122}
123
124fn digest(components: &[&str]) -> String {
125 let mut sha = Sha256::new();
126 for component in components {
127 sha.update(component.as_bytes());
128 sha.update([0u8]);
129 }
130 hex::encode(sha.finalize())
131}
132
133fn type_directory_name(key_type: &str) -> String {
134 format!("{TYPE_PREFIX}{}", digest(&[key_type]))
135}
136
137fn entry_name(key_type: &str, key_id: &str) -> String {
138 format!("{ENTRY_PREFIX}{}", digest(&[key_type, key_id]))
139}
140
141impl FileProtocolStateStorage {
142 pub fn open(root: impl AsRef<Path>, namespace: &str) -> Result<Self, FileStoreError> {
153 let directory = account_directory(root.as_ref(), namespace)?;
154 let directory_lock = lock_directory(&directory, LOCK_FILE)?;
155 Ok(Self {
156 directory,
157 lock: StoreLock::new(directory_lock),
158 swept: SweepMemo::default(),
159 })
160 }
161
162 pub fn close(&self) {
169 self.lock.close();
170 }
171
172 pub fn sealed_state(&self, secure: &dyn MlsStorage) -> Result<SealedState, FileStoreError> {
182 let _guard = self
183 .lock
184 .open()
185 .ok_or_else(|| FileStoreError::Closed(self.directory.clone()))?;
186 let key = stored_state_record_key(secure)
187 .map_err(|err| FileStoreError::RecordKeyUnreadable(err.to_string()))?;
188 let cipher = match &key {
189 StoredRecordKey::Usable(cipher) => Some(cipher),
190 StoredRecordKey::Absent | StoredRecordKey::Unrecoverable(_) => None,
191 };
192 let mut sealed_records = false;
193 for key_type in sealed_state_key_types() {
194 match self.probe_category(key_type, cipher)? {
195 SealedCategory::Empty => {}
196 SealedCategory::Opens => return Ok(SealedState::Opens),
197 SealedCategory::NoneOpens => sealed_records = true,
198 }
199 }
200 Ok(match key {
201 _ if !sealed_records => SealedState::Empty,
202 StoredRecordKey::Unrecoverable(_) => SealedState::KeyDamaged,
203 StoredRecordKey::Usable(_) | StoredRecordKey::Absent => SealedState::Foreign,
204 })
205 }
206
207 fn probe_category(
211 &self,
212 key_type: &str,
213 cipher: Option<&StateRecordCipher>,
214 ) -> Result<SealedCategory, FileStoreError> {
215 let directory = self.type_directory(key_type);
216 let ceiling = if cipher.is_some() {
217 SEALED_STATE_PROBE_LIMIT
218 } else {
219 1
220 };
221 let mut tried = 0usize;
222 let mut opened = false;
223 let mut unreadable = None;
224 records::for_each_entry(
225 &directory,
226 ENTRY_PREFIX,
227 records::MAX_LISTED_ENTRIES,
228 |path, _| {
229 if opened || unreadable.is_some() || tried >= ceiling {
230 return;
231 }
232 let raw = match records::read_bounded(path, MAX_FRAME_BYTES) {
233 Ok(BoundedRead::Bytes(raw)) => raw,
234 Ok(BoundedRead::Absent | BoundedRead::Oversized) => return,
235 Err(source) => {
236 unreadable = Some(FileStoreError::Io {
237 path: path.to_path_buf(),
238 source,
239 });
240 return;
241 }
242 };
243 let Some(header) = records::parse_header(&raw) else {
244 return;
245 };
246 if header.key_type != key_type {
247 return;
248 }
249 tried += 1;
250 opened = cipher.is_some_and(|cipher| {
251 cipher
252 .open(key_type, &header.key_id, &raw[header.value_offset..])
253 .is_some()
254 });
255 },
256 )
257 .map_err(|source| FileStoreError::Io {
258 path: directory.clone(),
259 source,
260 })?;
261 if let Some(err) = unreadable {
262 return Err(err);
263 }
264 Ok(match (opened, tried) {
265 (true, _) => SealedCategory::Opens,
266 (false, 0) => SealedCategory::Empty,
267 (false, _) => SealedCategory::NoneOpens,
268 })
269 }
270
271 pub(super) fn pairing(&self) -> Result<Option<[u8; PAIRING_ID_BYTES]>, FileStoreError> {
276 let _guard = self
277 .lock
278 .open()
279 .ok_or_else(|| FileStoreError::Closed(self.directory.clone()))?;
280 let path = self.directory.join(PAIRING_FILE);
281 match records::read_bounded(&path, PAIRING_ID_BYTES)
282 .map_err(|source| FileStoreError::Io { path, source })?
283 {
284 BoundedRead::Bytes(raw) if raw.len() == PAIRING_ID_BYTES => {
285 let mut id = [0u8; PAIRING_ID_BYTES];
286 id.copy_from_slice(&raw);
287 Ok(Some(id))
288 }
289 BoundedRead::Bytes(_) | BoundedRead::Absent | BoundedRead::Oversized => Ok(None),
290 }
291 }
292
293 pub(super) fn set_pairing(&self, id: &[u8; PAIRING_ID_BYTES]) -> Result<(), FileStoreError> {
295 let _guard = self
296 .lock
297 .open()
298 .ok_or_else(|| FileStoreError::Closed(self.directory.clone()))?;
299 #[cfg(test)]
300 if tests::FAIL_PAIRING_WRITE.with(|fail| fail.replace(false)) {
301 return Err(FileStoreError::Io {
302 path: self.directory.join(PAIRING_FILE),
303 source: std::io::Error::other("injected by a test"),
304 });
305 }
306 self.swept.sweep_once(&self.directory);
309 let path = self.directory.join(PAIRING_FILE);
310 records::write_atomic(&self.directory, &path, id)
311 .map_err(|source| FileStoreError::Io { path, source })
312 }
313
314 fn closed(&self) -> String {
316 format!("the store at {} is closed", self.directory.display())
317 }
318
319 pub fn directory(&self) -> &Path {
321 &self.directory
322 }
323
324 pub fn holds_records(root: impl AsRef<Path>, namespace: &str) -> Result<bool, FileStoreError> {
335 super::account_holds(root.as_ref(), namespace, |path, name| {
336 if !name.starts_with(TYPE_PREFIX) {
337 return Ok(false);
338 }
339 if !std::fs::metadata(path)?.is_dir() {
341 return Ok(false);
342 }
343 records::holds_entry(path, ENTRY_PREFIX)
344 })
345 }
346
347 fn type_directory(&self, key_type: &str) -> PathBuf {
348 self.directory.join(type_directory_name(key_type))
349 }
350
351 fn entry_path(&self, key_type: &str, key_id: &str) -> PathBuf {
352 self.type_directory(key_type)
353 .join(entry_name(key_type, key_id))
354 }
355
356 fn discard(path: &Path, reason: &str) -> ProtocolStateError {
361 tracing::warn!(path = %path.display(), reason, "dropping protocol-state record");
362 let _ = records::remove(path);
363 ProtocolStateError::Corrupted(format!(
364 "dropped unreadable protocol-state record: {reason}"
365 ))
366 }
367}
368
369impl ProtocolStateStorage for FileProtocolStateStorage {
370 fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> ProtocolStateResult<()> {
371 let framed = records::frame(key_type, key_id, data)
372 .map_err(|err| ProtocolStateError::StoreFailed(err.to_string()))?;
373 let _guard = self
374 .lock
375 .open()
376 .ok_or_else(|| ProtocolStateError::StoreFailed(self.closed()))?;
377 let directory = self.type_directory(key_type);
378 records::private_mkdir(&directory)
379 .map_err(|err| ProtocolStateError::StoreFailed(err.to_string()))?;
380 self.swept.sweep_once(&directory);
381 records::write_atomic(&directory, &self.entry_path(key_type, key_id), &framed)
382 .map_err(|err| ProtocolStateError::StoreFailed(err.to_string()))
383 }
384
385 fn load(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<Option<Vec<u8>>> {
386 let _guard = self
387 .lock
388 .open()
389 .ok_or_else(|| ProtocolStateError::LoadFailed(self.closed()))?;
390 let path = self.entry_path(key_type, key_id);
391 let raw = match records::read_bounded(&path, MAX_FRAME_BYTES)
392 .map_err(|err| ProtocolStateError::LoadFailed(err.to_string()))?
393 {
394 BoundedRead::Absent => return Ok(None),
395 BoundedRead::Oversized => {
396 return Err(Self::discard(&path, "record is over the ceiling"))
397 }
398 BoundedRead::Bytes(raw) => raw,
399 };
400 match records::parse_header(&raw) {
401 Some(header) if header.key_type == key_type && header.key_id == key_id => {
402 let mut raw = raw;
403 raw.drain(..header.value_offset);
404 Ok(Some(raw))
405 }
406 _ => Err(Self::discard(
409 &path,
410 "record framing does not name the requested key",
411 )),
412 }
413 }
414
415 fn delete(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<()> {
416 let _guard = self
417 .lock
418 .open()
419 .ok_or_else(|| ProtocolStateError::DeleteFailed(self.closed()))?;
420 records::remove(&self.entry_path(key_type, key_id))
421 .map(|_| ())
422 .map_err(|err| ProtocolStateError::DeleteFailed(err.to_string()))
423 }
424
425 fn list_keys(&self, key_type: &str) -> ProtocolStateResult<Vec<String>> {
426 let _guard = self
427 .lock
428 .open()
429 .ok_or_else(|| ProtocolStateError::LoadFailed(self.closed()))?;
430 let mut keys = BTreeSet::new();
433 records::for_each_entry(
434 &self.type_directory(key_type),
435 ENTRY_PREFIX,
436 records::MAX_LISTED_ENTRIES,
437 |path, _| {
438 if let Some(header) = records::read_prefix(path, MAX_HEADER_BYTES)
439 .as_deref()
440 .and_then(records::parse_header)
441 {
442 if header.key_type == key_type {
443 keys.insert(header.key_id);
444 }
445 }
446 },
447 )
448 .map_err(|err| ProtocolStateError::LoadFailed(err.to_string()))?;
449 Ok(keys.into_iter().collect())
450 }
451}
452
453#[cfg(test)]
454pub(super) mod tests {
455 use super::*;
456 use crate::file_store::account_storage_namespace;
457 use crate::file_store::records::MAX_VALUE_BYTES;
458 use crate::file_store::test_support::TempRoot;
459 use offline_protocol_mls::storage::{StorageError, StorageResult};
460
461 thread_local! {
462 pub(in crate::file_store) static FAIL_PAIRING_WRITE: std::cell::Cell<bool> =
465 const { std::cell::Cell::new(false) };
466 }
467
468 fn namespace() -> String {
469 account_storage_namespace("com.example.state", "alice")
470 }
471
472 fn open(root: &TempRoot) -> FileProtocolStateStorage {
473 FileProtocolStateStorage::open(root.path(), &namespace()).expect("open")
474 }
475
476 #[test]
482 fn holds_records_counts_record_files_and_nothing_else() {
483 let root = TempRoot::new("state-holds");
484 let probe = || FileProtocolStateStorage::holds_records(root.path(), &namespace());
485 assert!(!probe().expect("absent"));
486 assert!(
487 !root.path().join(namespace()).exists(),
488 "the probe creates nothing"
489 );
490 let store = open(&root);
491 assert!(!probe().expect("lock file only"));
492
493 for stray in ["mls-store.lock", "store-key-check", "t_not-a-directory"] {
494 std::fs::write(store.directory().join(stray), b"").expect("stray file");
495 }
496 std::fs::create_dir(store.directory().join("c_an-mls-type-directory")).expect("mls");
497 assert!(!probe().expect("what another store left"));
498
499 store.store("outbox", "one", b"sealed").expect("store");
500 store.delete("outbox", "one").expect("delete");
501 assert!(store.type_directory("outbox").is_dir());
502 assert!(!probe().expect("a type directory with no record left in it"));
503
504 store
505 .store("lamport_clock", "current", b"1")
506 .expect("store");
507 assert!(probe().expect("a record"));
508 }
509
510 #[test]
515 fn a_closed_store_releases_the_directory_and_refuses_every_operation() {
516 let root = TempRoot::new("state-close");
517 let store = open(&root);
518 store.store("outbox", "one", b"kept").expect("store");
519 assert!(matches!(
520 FileProtocolStateStorage::open(root.path(), &namespace()),
521 Err(FileStoreError::InUse(_))
522 ));
523
524 store.close();
525 store.close();
526 let next = open(&root);
527 assert_eq!(
528 next.load("outbox", "one").expect("load"),
529 Some(b"kept".to_vec())
530 );
531
532 assert!(matches!(
533 store.store("outbox", "one", b"other"),
534 Err(ProtocolStateError::StoreFailed(m)) if m.contains("closed")
535 ));
536 assert!(matches!(
537 store.load("outbox", "one"),
538 Err(ProtocolStateError::LoadFailed(m)) if m.contains("closed")
539 ));
540 assert!(matches!(
541 store.delete("outbox", "one"),
542 Err(ProtocolStateError::DeleteFailed(m)) if m.contains("closed")
543 ));
544 assert!(matches!(
545 store.list_keys("outbox"),
546 Err(ProtocolStateError::LoadFailed(m)) if m.contains("closed")
547 ));
548 assert!(matches!(
549 store.sealed_state(&NoRecordKey),
550 Err(FileStoreError::Closed(_))
551 ));
552 assert_eq!(
553 next.load("outbox", "one").expect("load"),
554 Some(b"kept".to_vec()),
555 "a closed store changes nothing in a directory it no longer holds"
556 );
557 }
558
559 struct NoRecordKey;
562 struct UnreadableRecordKey;
563 struct RecordKey([u8; 32]);
564 struct DamagedRecordKey;
565
566 impl MlsStorage for RecordKey {
567 fn store(&self, _: &str, _: &str, _: &[u8]) -> StorageResult<()> {
568 Ok(())
569 }
570 fn load(&self, _: &str, _: &str) -> StorageResult<Option<Vec<u8>>> {
571 Ok(Some(self.0.to_vec()))
572 }
573 fn delete(&self, _: &str, _: &str) -> StorageResult<()> {
574 Ok(())
575 }
576 fn list_keys(&self, _: &str) -> StorageResult<Vec<String>> {
577 Ok(Vec::new())
578 }
579 }
580
581 impl MlsStorage for DamagedRecordKey {
582 fn store(&self, _: &str, _: &str, _: &[u8]) -> StorageResult<()> {
583 Ok(())
584 }
585 fn load(&self, _: &str, _: &str) -> StorageResult<Option<Vec<u8>>> {
586 Err(StorageError::CorruptedData(
587 "the record does not authenticate".to_string(),
588 ))
589 }
590 fn delete(&self, _: &str, _: &str) -> StorageResult<()> {
591 Ok(())
592 }
593 fn list_keys(&self, _: &str) -> StorageResult<Vec<String>> {
594 Ok(Vec::new())
595 }
596 }
597
598 fn seal_records(store: &FileProtocolStateStorage, key: [u8; 32], key_type: &str, count: usize) {
600 let cipher = StateRecordCipher::new(&key);
601 for index in 0..count {
602 let key_id = format!("record-{index}");
603 let sealed = cipher.seal(key_type, &key_id, b"parked").expect("seal");
604 store.store(key_type, &key_id, &sealed).expect("store");
605 }
606 }
607
608 #[test]
613 fn records_that_do_not_open_in_one_category_do_not_speak_for_the_store() {
614 let root = TempRoot::new("state-per-category");
615 let store = open(&root);
616 let mut sealed_types = sealed_state_key_types();
617 let (first, second) = (
618 sealed_types.next().expect("a sealed category"),
619 sealed_types.next().expect("a second sealed category"),
620 );
621 seal_records(&store, [2; 32], first, SEALED_STATE_PROBE_LIMIT + 6);
622 assert_eq!(
623 store.sealed_state(&RecordKey([1; 32])).expect("foreign"),
624 SealedState::Foreign
625 );
626
627 seal_records(&store, [1; 32], second, 1);
628 assert_eq!(
629 store.sealed_state(&RecordKey([1; 32])).expect("opens"),
630 SealedState::Opens
631 );
632 }
633
634 #[test]
639 fn a_damaged_record_key_is_not_a_foreign_store() {
640 let root = TempRoot::new("state-damaged-key");
641 let store = open(&root);
642 assert_eq!(
643 store.sealed_state(&DamagedRecordKey).expect("empty"),
644 SealedState::Empty
645 );
646 let sealed_type = sealed_state_key_types().next().expect("a sealed category");
647 seal_records(&store, [1; 32], sealed_type, 1);
648 assert_eq!(
649 store.sealed_state(&DamagedRecordKey).expect("damaged"),
650 SealedState::KeyDamaged
651 );
652 assert_eq!(
653 store.sealed_state(&NoRecordKey).expect("no key"),
654 SealedState::Foreign
655 );
656 }
657
658 impl MlsStorage for NoRecordKey {
659 fn store(&self, _: &str, _: &str, _: &[u8]) -> StorageResult<()> {
660 Ok(())
661 }
662 fn load(&self, _: &str, _: &str) -> StorageResult<Option<Vec<u8>>> {
663 Ok(None)
664 }
665 fn delete(&self, _: &str, _: &str) -> StorageResult<()> {
666 Ok(())
667 }
668 fn list_keys(&self, _: &str) -> StorageResult<Vec<String>> {
669 Ok(Vec::new())
670 }
671 }
672
673 impl MlsStorage for UnreadableRecordKey {
674 fn store(&self, _: &str, _: &str, _: &[u8]) -> StorageResult<()> {
675 Ok(())
676 }
677 fn load(&self, _: &str, _: &str) -> StorageResult<Option<Vec<u8>>> {
678 Err(StorageError::LoadFailed(
679 "the disk did not answer".to_string(),
680 ))
681 }
682 fn delete(&self, _: &str, _: &str) -> StorageResult<()> {
683 Ok(())
684 }
685 fn list_keys(&self, _: &str) -> StorageResult<Vec<String>> {
686 Ok(Vec::new())
687 }
688 }
689
690 #[test]
694 fn a_record_key_that_cannot_be_read_is_not_an_answer() {
695 let root = TempRoot::new("state-unreadable-key");
696 let store = open(&root);
697 assert!(matches!(
698 store.sealed_state(&UnreadableRecordKey),
699 Err(FileStoreError::RecordKeyUnreadable(m)) if m.contains("did not answer")
700 ));
701 }
702
703 #[test]
706 fn the_sealed_state_probe_reads_sealed_categories_and_changes_nothing() {
707 let root = TempRoot::new("state-probe");
708 let store = open(&root);
709 assert_eq!(
710 store.sealed_state(&NoRecordKey).expect("empty"),
711 SealedState::Empty
712 );
713
714 store
716 .store("lamport_clock", "current", b"1")
717 .expect("store");
718 assert_eq!(
719 store.sealed_state(&NoRecordKey).expect("unsealed only"),
720 SealedState::Empty
721 );
722
723 let torn = store.type_directory("outbox").join("k_torn");
725 std::fs::create_dir_all(torn.parent().expect("parent")).expect("type directory");
726 std::fs::write(&torn, b"not a frame").expect("torn");
727 assert_eq!(
728 store.sealed_state(&NoRecordKey).expect("unparseable"),
729 SealedState::Empty
730 );
731
732 store
733 .store("outbox", "one", b"sealed elsewhere")
734 .expect("store");
735 let before = crate::file_store::test_support::files_under(root.path());
736 assert_eq!(
737 store.sealed_state(&NoRecordKey).expect("foreign"),
738 SealedState::Foreign
739 );
740 assert_eq!(
741 crate::file_store::test_support::files_under(root.path()),
742 before,
743 "the probe deletes nothing, the torn file included"
744 );
745 assert!(torn.exists());
746 }
747
748 #[test]
749 fn the_store_passes_the_conformance_suite() {
750 let root = TempRoot::new("state-conformance");
751 let report = crate::storage_conformance::run(&open(&root));
752 assert!(report.is_green(), "{report:#?}");
753 }
754
755 #[test]
759 fn the_on_disk_format_matches_the_bindings_golden_vector() {
760 let framed = records::frame("outbox", "m-1", b"\xaa\xbb").expect("frame");
761 assert_eq!(framed, b"OPS1\x00\x06\x00\x03outboxm-1\xaa\xbb".to_vec());
762 assert_eq!(
763 type_directory_name("outbox"),
764 "t_d5fac01c82279b8b061df80b3c312942e2ce27a41a48b1b7479ff07ad5a6198d"
765 );
766 assert_eq!(
767 entry_name("outbox", "m-1"),
768 "k_db5fcc2398ef2863d4269a61be6ea2de1f80d2889f34670c9a57c79cbe8058a1"
769 );
770 assert_eq!(
771 records::parse_header(&framed).map(|h| h.value_offset),
772 Some(17)
773 );
774 }
775
776 #[test]
778 fn a_record_written_by_a_binding_opens() {
779 let root = TempRoot::new("state-foreign");
780 let store = open(&root);
781 let directory = store.directory().join(type_directory_name("outbox"));
782 std::fs::create_dir_all(&directory).expect("mkdir");
783 std::fs::write(
784 directory.join(entry_name("outbox", "m-1")),
785 b"OPS1\x00\x06\x00\x03outboxm-1\xaa\xbb",
786 )
787 .expect("write");
788
789 assert_eq!(
790 store.load("outbox", "m-1").expect("load"),
791 Some(vec![0xaa, 0xbb])
792 );
793 assert_eq!(store.list_keys("outbox").expect("list"), vec!["m-1"]);
794 }
795
796 #[test]
797 fn records_survive_a_reopen() {
798 let root = TempRoot::new("state-reopen");
799 {
800 let store = open(&root);
801 store.store("outbox", "m-1", b"one").expect("store");
802 store.store("pending", "bob", b"").expect("store");
803 }
804 let store = open(&root);
805 assert_eq!(
806 store.load("outbox", "m-1").expect("load"),
807 Some(b"one".to_vec())
808 );
809 assert_eq!(
810 store.load("pending", "bob").expect("load"),
811 Some(Vec::new())
812 );
813 assert_eq!(store.list_keys("outbox").expect("list"), vec!["m-1"]);
814 }
815
816 #[test]
817 fn an_oversized_file_is_dropped_without_being_read() {
818 let root = TempRoot::new("state-oversized");
819 let store = open(&root);
820 store.store("outbox", "m-1", b"x").expect("store");
821 let path = store.entry_path("outbox", "m-1");
822 let handle = std::fs::OpenOptions::new()
823 .write(true)
824 .open(&path)
825 .expect("open");
826 handle
827 .set_len((MAX_FRAME_BYTES + 1) as u64)
828 .expect("sparse extend");
829 drop(handle);
830
831 assert!(matches!(
832 store.load("outbox", "m-1"),
833 Err(ProtocolStateError::Corrupted(_))
834 ));
835 assert!(!path.exists());
836 }
837
838 #[test]
839 fn a_record_that_names_another_key_is_dropped_as_corrupt() {
840 let root = TempRoot::new("state-misnamed");
841 let store = open(&root);
842 store.store("outbox", "m-1", b"one").expect("store");
843 store.store("outbox", "m-2", b"two").expect("store");
844 let first = store.entry_path("outbox", "m-1");
845 std::fs::copy(store.entry_path("outbox", "m-2"), &first).expect("copy");
846
847 assert!(matches!(
848 store.load("outbox", "m-1"),
849 Err(ProtocolStateError::Corrupted(_))
850 ));
851 assert!(!first.exists());
852 assert_eq!(store.list_keys("outbox").expect("list"), vec!["m-2"]);
854 assert_eq!(
855 store.load("outbox", "m-2").expect("load"),
856 Some(b"two".to_vec())
857 );
858 }
859
860 #[test]
861 fn garbage_framing_is_dropped_as_corrupt() {
862 let root = TempRoot::new("state-garbage");
863 let store = open(&root);
864 store.store("outbox", "m-1", b"one").expect("store");
865 let path = store.entry_path("outbox", "m-1");
866 std::fs::write(&path, (0u8..9).collect::<Vec<_>>()).expect("write");
867 assert!(matches!(
868 store.load("outbox", "m-1"),
869 Err(ProtocolStateError::Corrupted(_))
870 ));
871 assert!(!path.exists());
872 }
873
874 #[test]
875 fn a_value_over_the_ceiling_is_refused_on_store() {
876 let root = TempRoot::new("state-ceiling");
877 let store = open(&root);
878 let big = vec![0u8; MAX_VALUE_BYTES + 1];
879 assert!(matches!(
880 store.store("outbox", "m-1", &big),
881 Err(ProtocolStateError::StoreFailed(_))
882 ));
883 assert_eq!(store.load("outbox", "m-1").expect("load"), None);
884 }
885
886 #[test]
889 fn ids_differing_only_in_case_are_distinct_records() {
890 let root = TempRoot::new("state-case");
891 let store = open(&root);
892 store.store("groups", "Team", b"upper").expect("store");
893 store.store("groups", "team", b"lower").expect("store");
894 assert_eq!(
895 store.load("groups", "Team").expect("load"),
896 Some(b"upper".to_vec())
897 );
898 assert_eq!(
899 store.load("groups", "team").expect("load"),
900 Some(b"lower".to_vec())
901 );
902 assert_eq!(
903 store.list_keys("groups").expect("list"),
904 vec!["Team", "team"]
905 );
906 }
907
908 #[test]
909 fn a_second_store_over_one_directory_is_refused() {
910 let root = TempRoot::new("state-in-use");
911 let first = open(&root);
912 assert!(matches!(
913 FileProtocolStateStorage::open(root.path(), &namespace()),
914 Err(FileStoreError::InUse(_))
915 ));
916 drop(first);
917 open(&root);
918 }
919
920 #[cfg(unix)]
921 #[test]
922 fn directories_and_records_are_owner_only() {
923 use std::os::unix::fs::PermissionsExt;
924 let root = TempRoot::new("state-mode");
925 let store = open(&root);
926 store.store("outbox", "m-1", b"x").expect("store");
927 let mode = |p: &Path| std::fs::metadata(p).expect("stat").permissions().mode() & 0o777;
928 assert_eq!(mode(store.directory()), 0o700);
929 assert_eq!(mode(&store.type_directory("outbox")), 0o700);
930 assert_eq!(mode(&store.entry_path("outbox", "m-1")), 0o600);
931 }
932}