umsh_mobile_core/
counter_store.rs

1//! Crash-safe, identity-scoped frame-counter boundary persistence.
2
3use std::{
4    fmt,
5    fs::{self, File, OpenOptions},
6    io::{Read, Write},
7    path::PathBuf,
8    sync::{Arc, Mutex},
9};
10
11const FILE_MAGIC: &[u8; 4] = b"UMCT";
12const FILE_VERSION: u8 = 1;
13const RECORD_LEN: usize = 16;
14const MAX_CONTEXT_LEN: usize = 64;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Error)]
17pub enum CounterStoreError {
18    InvalidRootDirectory,
19    InvalidContext,
20    CorruptRecord,
21    IoFailure,
22}
23
24impl CounterStoreError {
25    pub const fn diagnostic_code(self) -> &'static str {
26        match self {
27            Self::InvalidRootDirectory => "COUNTER_ROOT_INVALID",
28            Self::InvalidContext => "COUNTER_CONTEXT_INVALID",
29            Self::CorruptRecord => "COUNTER_RECORD_CORRUPT",
30            Self::IoFailure => "COUNTER_IO_FAILURE",
31        }
32    }
33}
34
35impl fmt::Display for CounterStoreError {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        formatter.write_str(self.diagnostic_code())
38    }
39}
40
41impl std::error::Error for CounterStoreError {}
42
43/// Durable adapter used by the iOS `umsh_hal::CounterStore` boundary.
44///
45/// Each commit writes and synchronizes a temporary record, atomically renames
46/// it over the previous boundary, then synchronizes the containing directory.
47/// An error at any stage is treated as ambiguous by the caller and therefore
48/// cannot release a prepared authenticated frame for transmission.
49#[derive(uniffi::Object)]
50pub struct MobileCounterStore {
51    root: PathBuf,
52    operation_lock: Mutex<()>,
53}
54
55#[uniffi::export]
56impl MobileCounterStore {
57    #[uniffi::constructor]
58    pub fn new(root_directory: String) -> Result<Arc<Self>, CounterStoreError> {
59        let root = PathBuf::from(root_directory);
60        if !root.is_absolute() {
61            return Err(CounterStoreError::InvalidRootDirectory);
62        }
63        Ok(Arc::new(Self {
64            root,
65            operation_lock: Mutex::new(()),
66        }))
67    }
68
69    pub fn load_boundary(&self, context: Vec<u8>) -> Result<u32, CounterStoreError> {
70        let _guard = self
71            .operation_lock
72            .lock()
73            .map_err(|_| CounterStoreError::IoFailure)?;
74        self.load_boundary_unlocked(&context)
75    }
76
77    pub fn commit_boundary(
78        &self,
79        context: Vec<u8>,
80        boundary: u32,
81    ) -> Result<(), CounterStoreError> {
82        let _guard = self
83            .operation_lock
84            .lock()
85            .map_err(|_| CounterStoreError::IoFailure)?;
86        self.commit_boundary_unlocked(&context, boundary, None)
87    }
88}
89
90impl MobileCounterStore {
91    fn load_boundary_unlocked(&self, context: &[u8]) -> Result<u32, CounterStoreError> {
92        let path = self.record_path(context)?;
93        let mut file = match File::open(path) {
94            Ok(file) => file,
95            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
96            Err(_) => return Err(CounterStoreError::IoFailure),
97        };
98        let mut record = Vec::new();
99        file.read_to_end(&mut record)
100            .map_err(|_| CounterStoreError::IoFailure)?;
101        decode_record(&record)
102    }
103
104    fn commit_boundary_unlocked(
105        &self,
106        context: &[u8],
107        boundary: u32,
108        failpoint: Option<CommitStage>,
109    ) -> Result<(), CounterStoreError> {
110        let destination = self.record_path(context)?;
111        fs::create_dir_all(&self.root).map_err(|_| CounterStoreError::IoFailure)?;
112        let temporary = destination.with_extension("pending");
113        let record = encode_record(boundary);
114
115        let result = (|| {
116            let mut file = OpenOptions::new()
117                .create(true)
118                .truncate(true)
119                .write(true)
120                .open(&temporary)
121                .map_err(|_| CounterStoreError::IoFailure)?;
122            file.write_all(&record)
123                .map_err(|_| CounterStoreError::IoFailure)?;
124            fail_at(failpoint, CommitStage::AfterWrite)?;
125            file.sync_all().map_err(|_| CounterStoreError::IoFailure)?;
126            fail_at(failpoint, CommitStage::AfterFileSync)?;
127            drop(file);
128
129            fs::rename(&temporary, &destination).map_err(|_| CounterStoreError::IoFailure)?;
130            fail_at(failpoint, CommitStage::AfterRename)?;
131            File::open(&self.root)
132                .and_then(|directory| directory.sync_all())
133                .map_err(|_| CounterStoreError::IoFailure)?;
134            Ok(())
135        })();
136
137        if result.is_err() && temporary.exists() {
138            let _ = fs::remove_file(temporary);
139        }
140        result
141    }
142
143    fn record_path(&self, context: &[u8]) -> Result<PathBuf, CounterStoreError> {
144        validate_context(context)?;
145        let mut filename = String::with_capacity(context.len() * 2 + 4);
146        for byte in context {
147            use fmt::Write as _;
148            write!(&mut filename, "{byte:02x}").expect("writing to String cannot fail");
149        }
150        filename.push_str(".ctr");
151        Ok(self.root.join(filename))
152    }
153}
154
155impl umsh_hal::CounterStore for MobileCounterStore {
156    type Error = CounterStoreError;
157
158    async fn load(&self, context: &[u8]) -> Result<u32, Self::Error> {
159        self.load_boundary(context.to_vec())
160    }
161
162    async fn store(&self, context: &[u8], value: u32) -> Result<(), Self::Error> {
163        self.commit_boundary(context.to_vec(), value)
164    }
165
166    async fn flush(&self) -> Result<(), Self::Error> {
167        let _guard = self
168            .operation_lock
169            .lock()
170            .map_err(|_| CounterStoreError::IoFailure)?;
171        if !self.root.exists() {
172            return Ok(());
173        }
174        File::open(&self.root)
175            .and_then(|directory| directory.sync_all())
176            .map_err(|_| CounterStoreError::IoFailure)
177    }
178}
179
180fn validate_context(context: &[u8]) -> Result<(), CounterStoreError> {
181    if context.is_empty() || context.len() > MAX_CONTEXT_LEN {
182        Err(CounterStoreError::InvalidContext)
183    } else {
184        Ok(())
185    }
186}
187
188fn encode_record(boundary: u32) -> [u8; RECORD_LEN] {
189    let mut record = [0u8; RECORD_LEN];
190    record[..4].copy_from_slice(FILE_MAGIC);
191    record[4] = FILE_VERSION;
192    record[8..12].copy_from_slice(&boundary.to_be_bytes());
193    let checksum = checksum(&record[..12]);
194    record[12..].copy_from_slice(&checksum.to_be_bytes());
195    record
196}
197
198fn decode_record(record: &[u8]) -> Result<u32, CounterStoreError> {
199    if record.len() != RECORD_LEN
200        || &record[..4] != FILE_MAGIC
201        || record[4] != FILE_VERSION
202        || record[5..8] != [0, 0, 0]
203    {
204        return Err(CounterStoreError::CorruptRecord);
205    }
206    let expected = u32::from_be_bytes(record[12..16].try_into().unwrap());
207    if checksum(&record[..12]) != expected {
208        return Err(CounterStoreError::CorruptRecord);
209    }
210    Ok(u32::from_be_bytes(record[8..12].try_into().unwrap()))
211}
212
213fn checksum(bytes: &[u8]) -> u32 {
214    bytes.iter().fold(0x811C_9DC5, |hash, byte| {
215        (hash ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
216    })
217}
218
219#[derive(Clone, Copy, PartialEq, Eq)]
220enum CommitStage {
221    AfterWrite,
222    AfterFileSync,
223    AfterRename,
224}
225
226fn fail_at(requested: Option<CommitStage>, current: CommitStage) -> Result<(), CounterStoreError> {
227    if requested == Some(current) {
228        Err(CounterStoreError::IoFailure)
229    } else {
230        Ok(())
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn store() -> (tempfile::TempDir, Arc<MobileCounterStore>) {
239        let directory = tempfile::tempdir().unwrap();
240        let store =
241            MobileCounterStore::new(directory.path().join("counters").display().to_string())
242                .unwrap();
243        (directory, store)
244    }
245
246    #[test]
247    fn missing_context_starts_at_zero_and_commits_boundary() {
248        let (_directory, store) = store();
249        assert_eq!(store.load_boundary(b"primary".to_vec()).unwrap(), 0);
250        store.commit_boundary(b"primary".to_vec(), 128).unwrap();
251        assert_eq!(store.load_boundary(b"primary".to_vec()).unwrap(), 128);
252    }
253
254    #[test]
255    fn failures_before_rename_preserve_previous_boundary() {
256        for stage in [CommitStage::AfterWrite, CommitStage::AfterFileSync] {
257            let (_directory, store) = store();
258            store.commit_boundary(b"primary".to_vec(), 128).unwrap();
259            assert_eq!(
260                store.commit_boundary_unlocked(b"primary", 256, Some(stage)),
261                Err(CounterStoreError::IoFailure)
262            );
263            assert_eq!(store.load_boundary(b"primary".to_vec()).unwrap(), 128);
264        }
265    }
266
267    #[test]
268    fn ambiguous_failure_after_rename_recovers_new_boundary() {
269        let (directory, store) = store();
270        store.commit_boundary(b"primary".to_vec(), 128).unwrap();
271        assert_eq!(
272            store.commit_boundary_unlocked(b"primary", 256, Some(CommitStage::AfterRename)),
273            Err(CounterStoreError::IoFailure)
274        );
275
276        let reopened =
277            MobileCounterStore::new(directory.path().join("counters").display().to_string())
278                .unwrap();
279        assert_eq!(reopened.load_boundary(b"primary".to_vec()).unwrap(), 256);
280    }
281
282    #[test]
283    fn corrupt_or_invalid_records_fail_closed() {
284        let (_directory, store) = store();
285        assert_eq!(
286            store.load_boundary(Vec::new()),
287            Err(CounterStoreError::InvalidContext)
288        );
289        fs::create_dir_all(&store.root).unwrap();
290        fs::write(store.record_path(b"primary").unwrap(), b"partial").unwrap();
291        assert_eq!(
292            store.load_boundary(b"primary".to_vec()),
293            Err(CounterStoreError::CorruptRecord)
294        );
295    }
296
297    #[test]
298    fn implements_hal_counter_store_contract() {
299        fn assert_counter_store<T: umsh_hal::CounterStore>() {}
300        assert_counter_store::<MobileCounterStore>();
301    }
302}