1use 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#[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}