Skip to main content

io_vdir/item/
store.rs

1//! I/O-free coroutine writing a Vdir item under a collection.
2//!
3//! When `id` is `None`, the coroutine requests 16 random bytes via
4//! [`VdirYield::WantsRandom`] and formats them as a UUIDv4 string. The
5//! item is written to `<collection>/<id>.<ext>.tmp` first, then
6//! atomically renamed onto `<collection>/<id>.<ext>`; any existing
7//! file at the final path is replaced. Use the same coroutine for
8//! create and update.
9//!
10//! # Example
11//!
12//! ```rust,no_run
13//! use std::fs;
14//!
15//! use io_vdir::{coroutine::*, item::{store::*, VdirItemKind}};
16//!
17//! let bytes = b"BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Alice\r\nEND:VCARD\r\n".to_vec();
18//! let opts = VdirItemStoreOptions::default();
19//! let mut coroutine = VdirItemStore::new("/tmp/vdir/contacts", None, VdirItemKind::Vcard, bytes, opts);
20//! let mut arg = None;
21//!
22//! let out = loop {
23//!     match coroutine.resume(arg.take()) {
24//!         VdirCoroutineState::Yielded(VdirYield::WantsRandom { len }) => {
25//!             let bytes = vec![0u8; len]; // fill via the OS RNG in real code
26//!             arg = Some(VdirReply::Random(bytes));
27//!         }
28//!         VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files)) => {
29//!             for (path, bytes) in files {
30//!                 fs::write(path.as_str(), bytes).unwrap();
31//!             }
32//!             arg = Some(VdirReply::FileCreate);
33//!         }
34//!         VdirCoroutineState::Yielded(VdirYield::WantsRename(pairs)) => {
35//!             for (from, to) in pairs {
36//!                 fs::rename(from.as_str(), to.as_str()).unwrap();
37//!             }
38//!             arg = Some(VdirReply::Rename);
39//!         }
40//!         VdirCoroutineState::Complete(Ok(out)) => break out,
41//!         VdirCoroutineState::Complete(Err(err)) => panic!("{err}"),
42//!         state => panic!("unexpected state {state:?}"),
43//!     }
44//! };
45//!
46//! println!("stored {} at {}", out.id, out.path);
47//! ```
48
49use core::{fmt, mem};
50
51use alloc::{collections::BTreeMap, string::String, vec::Vec};
52
53use thiserror::Error;
54
55use crate::{
56    coroutine::*,
57    item::{TMP, VdirItemKind},
58    path::VdirPath,
59};
60
61/// Number of random bytes a fresh UUIDv4 id is built from.
62const UUID_LEN: usize = 16;
63
64/// Failure causes during a [`VdirItemStore`] step.
65#[derive(Clone, Debug, Error)]
66pub enum VdirItemStoreError {
67    /// The driver fed back a reply that does not match the pending
68    /// request.
69    #[error("Vdir item store failed: unexpected arg {0:?}")]
70    UnexpectedArg(Option<VdirReply>),
71}
72
73/// Successful output of [`VdirItemStore`].
74#[derive(Clone, Debug)]
75pub struct VdirItemStoreOutput {
76    /// Id of the stored item, reused from the caller or freshly
77    /// minted as a UUIDv4.
78    pub id: String,
79    /// Final on-disk path of the stored item file.
80    pub path: VdirPath,
81}
82
83/// Options for [`VdirItemStore::new`].
84#[derive(Clone, Debug, Default, Eq, PartialEq)]
85pub struct VdirItemStoreOptions {}
86
87/// Writes a Vdir item under a collection, minting a UUIDv4 id when
88/// none is supplied.
89#[derive(Debug)]
90pub struct VdirItemStore {
91    state: State,
92    #[allow(dead_code)]
93    opts: VdirItemStoreOptions,
94}
95
96impl VdirItemStore {
97    /// Creates a new coroutine that will write `contents` as an item
98    /// of `kind` under `collection`.
99    ///
100    /// When `id` is `Some`, the coroutine reuses it and skips the
101    /// [`VdirYield::WantsRandom`] step.
102    pub fn new(
103        collection: impl Into<VdirPath>,
104        id: Option<String>,
105        kind: VdirItemKind,
106        contents: Vec<u8>,
107        opts: VdirItemStoreOptions,
108    ) -> Self {
109        Self {
110            opts,
111            state: State::Start {
112                collection: collection.into(),
113                id,
114                kind,
115                contents,
116            },
117        }
118    }
119}
120
121impl VdirCoroutine for VdirItemStore {
122    type Yield = VdirYield;
123    type Return = Result<VdirItemStoreOutput, VdirItemStoreError>;
124
125    fn resume(&mut self, arg: Option<VdirReply>) -> VdirCoroutineState<Self::Yield, Self::Return> {
126        match (&mut self.state, arg) {
127            (
128                State::Start {
129                    collection,
130                    id,
131                    kind,
132                    contents,
133                },
134                None,
135            ) => {
136                let collection = mem::take(collection);
137                let kind = *kind;
138                let contents = mem::take(contents);
139
140                match id.take() {
141                    Some(id) => {
142                        let (tmp_path, final_path) = build_paths(&collection, &id, kind);
143                        let files = BTreeMap::from_iter([(tmp_path.clone(), contents)]);
144                        self.state = State::AwaitFileCreate {
145                            id,
146                            tmp_path,
147                            final_path,
148                        };
149                        VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files))
150                    }
151                    None => {
152                        self.state = State::AwaitRandom {
153                            collection,
154                            kind,
155                            contents,
156                        };
157                        VdirCoroutineState::Yielded(VdirYield::WantsRandom { len: UUID_LEN })
158                    }
159                }
160            }
161            (
162                State::AwaitRandom {
163                    collection,
164                    kind,
165                    contents,
166                },
167                Some(VdirReply::Random(bytes)),
168            ) => {
169                let collection = mem::take(collection);
170                let kind = *kind;
171                let contents = mem::take(contents);
172
173                let id = uuid_v4(&bytes);
174                let (tmp_path, final_path) = build_paths(&collection, &id, kind);
175                let files = BTreeMap::from_iter([(tmp_path.clone(), contents)]);
176                self.state = State::AwaitFileCreate {
177                    id,
178                    tmp_path,
179                    final_path,
180                };
181                VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files))
182            }
183            (
184                State::AwaitFileCreate {
185                    id,
186                    tmp_path,
187                    final_path,
188                },
189                Some(VdirReply::FileCreate),
190            ) => {
191                let id = mem::take(id);
192                let tmp_path = mem::take(tmp_path);
193                let final_path = mem::take(final_path);
194
195                let pairs = vec![(tmp_path, final_path.clone())];
196                self.state = State::AwaitRename { id, final_path };
197                VdirCoroutineState::Yielded(VdirYield::WantsRename(pairs))
198            }
199            (State::AwaitRename { id, final_path }, Some(VdirReply::Rename)) => {
200                let out = VdirItemStoreOutput {
201                    id: mem::take(id),
202                    path: mem::take(final_path),
203                };
204                VdirCoroutineState::Complete(Ok(out))
205            }
206            (_, arg) => {
207                let err = VdirItemStoreError::UnexpectedArg(arg);
208                VdirCoroutineState::Complete(Err(err))
209            }
210        }
211    }
212}
213
214#[derive(Debug)]
215enum State {
216    Start {
217        collection: VdirPath,
218        id: Option<String>,
219        kind: VdirItemKind,
220        contents: Vec<u8>,
221    },
222    AwaitRandom {
223        collection: VdirPath,
224        kind: VdirItemKind,
225        contents: Vec<u8>,
226    },
227    AwaitFileCreate {
228        id: String,
229        tmp_path: VdirPath,
230        final_path: VdirPath,
231    },
232    AwaitRename {
233        id: String,
234        final_path: VdirPath,
235    },
236}
237
238impl fmt::Display for State {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        match self {
241            Self::Start { .. } => f.write_str("start"),
242            Self::AwaitRandom { .. } => f.write_str("await random reply"),
243            Self::AwaitFileCreate { .. } => f.write_str("await file create reply"),
244            Self::AwaitRename { .. } => f.write_str("await rename reply"),
245        }
246    }
247}
248
249/// Builds the `(tmp, final)` paths for item `id` of `kind` under
250/// `collection`.
251fn build_paths(collection: &VdirPath, id: &str, kind: VdirItemKind) -> (VdirPath, VdirPath) {
252    let ext = kind.extension();
253    let final_path = collection.join(&format!("{id}.{ext}"));
254    let tmp_path = collection.join(&format!("{id}.{ext}.{TMP}"));
255    (tmp_path, final_path)
256}
257
258/// Formats 16 raw bytes as a canonical UUIDv4 string, stamping the
259/// RFC 4122 version (4) and variant (10x) bits.
260fn uuid_v4(bytes: &[u8]) -> String {
261    let mut bytes: [u8; UUID_LEN] = bytes[..UUID_LEN].try_into().unwrap_or([0u8; UUID_LEN]);
262    bytes[6] = (bytes[6] & 0x0f) | 0x40;
263    bytes[8] = (bytes[8] & 0x3f) | 0x80;
264
265    let mut id = String::with_capacity(36);
266    let groups: [&[u8]; 5] = [
267        &bytes[0..4],
268        &bytes[4..6],
269        &bytes[6..8],
270        &bytes[8..10],
271        &bytes[10..16],
272    ];
273
274    for (i, group) in groups.iter().enumerate() {
275        if i > 0 {
276            id.push('-');
277        }
278        for byte in *group {
279            id.push(hex_nibble(byte >> 4));
280            id.push(hex_nibble(byte & 0x0f));
281        }
282    }
283
284    id
285}
286
287/// Maps a 4-bit nibble to its lowercase hexadecimal digit.
288fn hex_nibble(n: u8) -> char {
289    match n {
290        0..=9 => (b'0' + n) as char,
291        10..=15 => (b'a' + (n - 10)) as char,
292        _ => '0',
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn reuses_supplied_id() {
302        let mut cor = VdirItemStore::new(
303            "root/contacts",
304            Some("alice".into()),
305            VdirItemKind::Vcard,
306            b"BEGIN:VCARD".to_vec(),
307            VdirItemStoreOptions::default(),
308        );
309
310        let files = match cor.resume(None) {
311            VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files)) => files,
312            state => panic!("expected WantsFileCreate, got {state:?}"),
313        };
314        let tmp = VdirPath::from("root/contacts/alice.vcf.tmp");
315        assert!(files.contains_key(&tmp));
316
317        let pairs = match cor.resume(Some(VdirReply::FileCreate)) {
318            VdirCoroutineState::Yielded(VdirYield::WantsRename(pairs)) => pairs,
319            state => panic!("expected WantsRename, got {state:?}"),
320        };
321        assert_eq!(
322            pairs,
323            vec![(tmp, VdirPath::from("root/contacts/alice.vcf"))]
324        );
325
326        let out = match cor.resume(Some(VdirReply::Rename)) {
327            VdirCoroutineState::Complete(Ok(out)) => out,
328            state => panic!("expected Complete(Ok), got {state:?}"),
329        };
330        assert_eq!(out.id, "alice");
331        assert_eq!(out.path, VdirPath::from("root/contacts/alice.vcf"));
332    }
333
334    #[test]
335    fn generates_uuid_when_id_missing() {
336        let mut cor = VdirItemStore::new(
337            "root/contacts",
338            None,
339            VdirItemKind::Ical,
340            b"BEGIN:VCAL".to_vec(),
341            VdirItemStoreOptions::default(),
342        );
343
344        match cor.resume(None) {
345            VdirCoroutineState::Yielded(VdirYield::WantsRandom { len }) => {
346                assert_eq!(len, UUID_LEN)
347            }
348            state => panic!("expected WantsRandom, got {state:?}"),
349        }
350
351        let bytes = vec![0xabu8; UUID_LEN];
352        let files = match cor.resume(Some(VdirReply::Random(bytes))) {
353            VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files)) => files,
354            state => panic!("expected WantsFileCreate, got {state:?}"),
355        };
356
357        let tmp = files.keys().next().unwrap();
358        assert!(tmp.as_str().ends_with(".ics.tmp"));
359        let id = tmp.file_name().unwrap();
360        // NOTE: version nibble (4) and variant nibble (8..=b) embedded in
361        // the canonical 8-4-4-4-12 layout.
362        assert_eq!(id.chars().nth(14), Some('4'));
363        assert!(matches!(id.chars().nth(19), Some('8'..='b')));
364    }
365
366    #[test]
367    fn unexpected_reply_returns_error() {
368        let mut cor = VdirItemStore::new(
369            "root/contacts",
370            Some("alice".into()),
371            VdirItemKind::Vcard,
372            b"x".to_vec(),
373            VdirItemStoreOptions::default(),
374        );
375        let _ = cor.resume(None);
376
377        let err = match cor.resume(Some(VdirReply::DirCreate)) {
378            VdirCoroutineState::Complete(Err(err)) => err,
379            state => panic!("expected Complete(Err), got {state:?}"),
380        };
381        assert!(matches!(err, VdirItemStoreError::UnexpectedArg(_)));
382    }
383}