Skip to main content

io_m2dir/entry/
get.rs

1//! I/O-free coroutine to read an m2dir entry by its id.
2//!
3//! The id is the `<checksum>.<nonce>` portion of the entry
4//! filename. The fetched bytes are checksum-validated before being
5//! returned.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use std::{collections::BTreeMap, fs};
11//!
12//! use io_m2dir::{
13//!     coroutine::{M2dirArg, M2dirCoroutine, M2dirCoroutineState, M2dirYield},
14//!     m2dir::types::M2dir,
15//!     entry::get::{M2dirEntryGet, M2dirEntryGetOptions},
16//! };
17//!
18//! let m2dir = M2dir::from_path("/tmp/inbox");
19//! let opts = M2dirEntryGetOptions::default();
20//! let id = "1747997123,abcd.efgh";
21//! let mut coroutine = M2dirEntryGet::new(m2dir, id, opts);
22//! let mut arg = None;
23//!
24//! let output = loop {
25//!     match coroutine.resume(arg.take()) {
26//!         M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => {
27//!             let mut out = BTreeMap::new();
28//!             for path in paths {
29//!                 let names = fs::read_dir(path.as_str())
30//!                     .map(|rd| rd.flatten().map(|e| e.path().into()).collect())
31//!                     .unwrap_or_default();
32//!                 out.insert(path, names);
33//!             }
34//!             arg = Some(M2dirArg::DirRead(out));
35//!         }
36//!         M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(probes)) => {
37//!             let map = probes
38//!                 .into_iter()
39//!                 .map(|p| {
40//!                     let exists = fs::metadata(p.as_str())
41//!                         .map_or(false, |m| m.is_file());
42//!                     (p, exists)
43//!                 })
44//!                 .collect();
45//!             arg = Some(M2dirArg::FileExists(map));
46//!         }
47//!         M2dirCoroutineState::Yielded(M2dirYield::WantsFileRead(paths)) => {
48//!             let map = paths
49//!                 .into_iter()
50//!                 .map(|p| {
51//!                     let bytes = fs::read(p.as_str()).unwrap_or_default();
52//!                     (p, bytes)
53//!                 })
54//!                 .collect();
55//!             arg = Some(M2dirArg::FileRead(map));
56//!         }
57//!         M2dirCoroutineState::Complete(Ok(out)) => break out,
58//!         M2dirCoroutineState::Complete(Err(err)) => panic!("{err}"),
59//!         state => panic!("unexpected state {state:?}"),
60//!     }
61//! };
62//!
63//! println!("{} bytes", output.contents.len());
64//! ```
65
66use core::{fmt, mem};
67
68use alloc::{
69    collections::BTreeSet,
70    string::{String, ToString},
71    vec::Vec,
72};
73
74use log::trace;
75use thiserror::Error;
76
77use crate::{
78    coroutine::*,
79    entry::{
80        list::*,
81        types::{M2dirEntry, ParseFilenameError},
82        utils::validate_checksum,
83    },
84    m2dir::types::M2dir,
85    path::M2dirPath,
86};
87
88/// Failure causes during the m2dir GET flow.
89#[derive(Clone, Debug, Error)]
90pub enum M2dirEntryGetError {
91    #[error("M2DIR GET failed: unexpected coroutine arg")]
92    UnexpectedArg,
93    #[error("M2DIR GET failed: missing coroutine arg")]
94    MissingArg,
95    #[error("M2DIR GET failed: entry {0} not found")]
96    NotFound(String),
97    #[error("M2DIR GET failed: {0}")]
98    List(#[from] M2dirEntryListError),
99    #[error("M2DIR GET failed: {0}")]
100    Parse(#[from] ParseFilenameError),
101}
102
103/// Options for [`M2dirEntryGet::new`].
104#[derive(Clone, Debug, Default, Eq, PartialEq)]
105pub struct M2dirEntryGetOptions {}
106
107/// Terminal output of [`M2dirEntryGet`].
108#[derive(Clone, Debug)]
109pub struct M2dirEntryGetOutput {
110    /// The resolved entry (id + on-disk path).
111    pub entry: M2dirEntry,
112    /// Raw bytes read from the entry file.
113    pub contents: Vec<u8>,
114}
115
116/// I/O-free m2dir entry GET coroutine.
117pub struct M2dirEntryGet {
118    id: String,
119    state: State,
120    #[allow(dead_code)]
121    opts: M2dirEntryGetOptions,
122}
123
124impl M2dirEntryGet {
125    /// Creates a new coroutine that will retrieve entry `id` from
126    /// `m2dir`.
127    pub fn new(m2dir: M2dir, id: impl ToString, opts: M2dirEntryGetOptions) -> Self {
128        Self {
129            id: id.to_string(),
130            state: State::List(M2dirEntryList::new(m2dir, M2dirEntryListOptions::default())),
131            opts,
132        }
133    }
134}
135
136impl M2dirCoroutine for M2dirEntryGet {
137    type Yield = M2dirYield;
138    type Return = Result<M2dirEntryGetOutput, M2dirEntryGetError>;
139
140    fn resume(&mut self, arg: Option<M2dirArg>) -> M2dirCoroutineState<Self::Yield, Self::Return> {
141        trace!("get entry: {}", self.state);
142
143        match (&mut self.state, arg) {
144            (State::List(list), arg) => match list.resume(arg) {
145                M2dirCoroutineState::Yielded(yld) => M2dirCoroutineState::Yielded(yld),
146                M2dirCoroutineState::Complete(Ok(entries)) => {
147                    let Some(entry) = entries.into_iter().find(|e| e.id() == self.id) else {
148                        let err = M2dirEntryGetError::NotFound(self.id.clone());
149                        return M2dirCoroutineState::Complete(Err(err));
150                    };
151
152                    trace!("located entry at {}", entry.path());
153
154                    let paths = BTreeSet::from_iter([entry.path().clone()]);
155                    self.state = State::Read(entry);
156                    M2dirCoroutineState::Yielded(M2dirYield::WantsFileRead(paths))
157                }
158                M2dirCoroutineState::Complete(Err(err)) => {
159                    M2dirCoroutineState::Complete(Err(err.into()))
160                }
161            },
162            (State::Read(entry), Some(M2dirArg::FileRead(contents))) => {
163                let bytes = contents.into_values().next().unwrap_or_default();
164                let entry = mem::replace(
165                    entry,
166                    M2dirEntry::from_parts(String::new(), M2dirPath::default()),
167                );
168                let checksum = entry.checksum();
169
170                if !validate_checksum(checksum, &bytes) {
171                    let err = ParseFilenameError::InvalidChecksum {
172                        path: entry.path().clone(),
173                        expected: checksum.to_string(),
174                        got: entry.id().to_string(),
175                    };
176                    return M2dirCoroutineState::Complete(Err(err.into()));
177                }
178
179                M2dirCoroutineState::Complete(Ok(M2dirEntryGetOutput {
180                    entry,
181                    contents: bytes,
182                }))
183            }
184            (_, Some(_)) => {
185                let err = M2dirEntryGetError::UnexpectedArg;
186                M2dirCoroutineState::Complete(Err(err))
187            }
188            (_, None) => {
189                let err = M2dirEntryGetError::MissingArg;
190                M2dirCoroutineState::Complete(Err(err))
191            }
192        }
193    }
194}
195
196enum State {
197    List(M2dirEntryList),
198    Read(M2dirEntry),
199}
200
201impl fmt::Display for State {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        match self {
204            Self::List(_) => f.write_str("locate entry"),
205            Self::Read(_) => f.write_str("read entry"),
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use alloc::collections::BTreeMap;
213
214    use super::*;
215
216    #[test]
217    fn missing_entry_returns_not_found_error() {
218        let m2dir = M2dir::from_path("/tmp/inbox");
219        let mut get = M2dirEntryGet::new(m2dir, "missing", M2dirEntryGetOptions::default());
220
221        let probes = match get.resume(None) {
222            M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => paths,
223            state => panic!("expected WantsDirRead, got {state:?}"),
224        };
225        let dir = probes.into_iter().next().unwrap();
226        let mut reply = BTreeMap::new();
227        reply.insert(dir, BTreeSet::new());
228
229        let err = match get.resume(Some(M2dirArg::DirRead(reply))) {
230            M2dirCoroutineState::Complete(Err(err)) => err,
231            state => panic!("expected Complete(Err), got {state:?}"),
232        };
233        assert!(matches!(err, M2dirEntryGetError::NotFound(id) if id == "missing"));
234    }
235
236    #[test]
237    fn list_error_propagates_via_from() {
238        let m2dir = M2dir::from_path("/tmp/inbox");
239        let mut get = M2dirEntryGet::new(m2dir, "missing", M2dirEntryGetOptions::default());
240
241        let _ = get.resume(None);
242        let err = match get.resume(Some(M2dirArg::FileCreate)) {
243            M2dirCoroutineState::Complete(Err(err)) => err,
244            state => panic!("expected Complete(Err), got {state:?}"),
245        };
246        assert!(matches!(err, M2dirEntryGetError::List(_)));
247    }
248
249    #[test]
250    fn unexpected_arg_at_read_returns_unexpected_arg_error() {
251        let m2dir = M2dir::from_path("/tmp/inbox");
252        let mut get = M2dirEntryGet::new(m2dir, "foo", M2dirEntryGetOptions::default());
253
254        let err = match get.resume(Some(M2dirArg::FileRead(BTreeMap::new()))) {
255            M2dirCoroutineState::Complete(Err(err)) => err,
256            state => panic!("expected Complete(Err), got {state:?}"),
257        };
258        assert!(matches!(err, M2dirEntryGetError::List(_)));
259    }
260
261    #[test]
262    fn invalid_checksum_returns_parse_error() {
263        let m2dir = M2dir::from_path("/tmp/inbox");
264        let mut get = M2dirEntryGet::new(m2dir, "checksum.nonce", M2dirEntryGetOptions::default());
265
266        let probes = match get.resume(None) {
267            M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => paths,
268            state => panic!("expected WantsDirRead, got {state:?}"),
269        };
270        let dir = probes.into_iter().next().unwrap();
271
272        let mut names = BTreeSet::new();
273        let entry_path = M2dirPath::from("/tmp/inbox/123,checksum.nonce");
274        names.insert(entry_path.clone());
275
276        let mut reply = BTreeMap::new();
277        reply.insert(dir, names);
278
279        let probes = match get.resume(Some(M2dirArg::DirRead(reply))) {
280            M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(probes)) => probes,
281            state => panic!("expected WantsFileExists, got {state:?}"),
282        };
283        let exists: BTreeMap<M2dirPath, bool> = probes.into_iter().map(|p| (p, true)).collect();
284
285        let read_paths = match get.resume(Some(M2dirArg::FileExists(exists))) {
286            M2dirCoroutineState::Yielded(M2dirYield::WantsFileRead(paths)) => paths,
287            state => panic!("expected WantsFileRead, got {state:?}"),
288        };
289        let read_reply: BTreeMap<M2dirPath, Vec<u8>> = read_paths
290            .into_iter()
291            .map(|p| (p, b"wrong content".to_vec()))
292            .collect();
293
294        let err = match get.resume(Some(M2dirArg::FileRead(read_reply))) {
295            M2dirCoroutineState::Complete(Err(err)) => err,
296            state => panic!("expected Complete(Err), got {state:?}"),
297        };
298        assert!(matches!(err, M2dirEntryGetError::Parse(_)));
299    }
300
301    #[test]
302    fn missing_arg_at_list_propagates_via_list_error() {
303        let m2dir = M2dir::from_path("/tmp/inbox");
304        let mut get = M2dirEntryGet::new(m2dir, "foo", M2dirEntryGetOptions::default());
305        let _ = get.resume(None);
306
307        let err = match get.resume(None) {
308            M2dirCoroutineState::Complete(Err(err)) => err,
309            state => panic!("expected Complete(Err), got {state:?}"),
310        };
311        assert!(matches!(err, M2dirEntryGetError::List(_)));
312    }
313}