Skip to main content

aria_primitives/
private_document.rs

1//! A small JSON document only its owner may read, replaced atomically.
2//!
3//! A write lands whole or not at all: the bytes go to a temporary file beside
4//! the target, reach the disk, and only then take the target's name. A reader
5//! that arrives mid-write sees the old document, never a half-written one.
6//! [`CommittedWrite::confirm_durability`] is the separate step that says the
7//! rename itself survives a power cut.
8//!
9//! Errors here name the fault, not the path. The caller knows which of its
10//! documents it was writing, and says so when it wraps the failure.
11
12use std::fs::{File, Permissions};
13use std::io::{self, Read, Write};
14use std::os::unix::fs::PermissionsExt;
15use std::path::{Path, PathBuf};
16
17use rustix::fs::{Mode, OFlags};
18use serde::Serialize;
19use serde::de::DeserializeOwned;
20
21use super::private_fs::{self, PRIVATE_FILE_MODE, PrivateDirectoryError, PrivateFileError};
22
23/// One document at one path. The byte ceiling is the caller's: a document
24/// that outgrows it is a bug or a tampered file, never something to parse.
25pub struct PrivateDocument {
26    path: PathBuf,
27    maximum_bytes: u64,
28}
29
30impl PrivateDocument {
31    pub fn new(path: impl Into<PathBuf>, maximum_bytes: u64) -> Self {
32        Self {
33            path: path.into(),
34            maximum_bytes,
35        }
36    }
37
38    pub fn path(&self) -> &Path {
39        &self.path
40    }
41
42    /// The parsed document, or `None` when nothing is written there yet.
43    /// Creates the containing directory, so a first read leaves a private
44    /// home for the first write.
45    pub fn read<T: DeserializeOwned>(&self) -> Result<Option<T>, DocumentError> {
46        private_fs::prepare_directory(self.parent()?).map_err(DocumentError::Directory)?;
47
48        let Some(file) = self.open_existing()? else {
49            return Ok(None);
50        };
51        let length = self.validate(&file)?;
52        let contents = self.read_all(file, length)?;
53        serde_json::from_slice(&contents)
54            .map(Some)
55            .map_err(DocumentError::Parse)
56    }
57
58    /// Serializes and stages the write. Nothing is visible at the path until
59    /// [`PreparedWrite::commit`].
60    pub fn prepare<T: Serialize>(&self, value: &T) -> Result<PreparedWrite, DocumentError> {
61        let directory = self.parent()?;
62        private_fs::prepare_directory(directory).map_err(DocumentError::Directory)?;
63
64        let mut contents = serde_json::to_vec_pretty(value).map_err(DocumentError::Serialize)?;
65        contents.push(b'\n');
66        if contents.len() as u64 > self.maximum_bytes {
67            return Err(DocumentError::SerializedTooLarge {
68                actual_bytes: contents.len() as u64,
69                maximum_bytes: self.maximum_bytes,
70            });
71        }
72
73        // A target that is a symlink, someone else's, or wrongly permissioned
74        // is refused before anything is written.
75        if let Some(file) = self.open_existing()? {
76            self.validate(&file)?;
77        }
78
79        let file = |action: &'static str| move |source| DocumentError::File { action, source };
80        let mut temporary =
81            tempfile::NamedTempFile::new_in(directory).map_err(file("create a temporary"))?;
82        temporary
83            .as_file()
84            .set_permissions(Permissions::from_mode(PRIVATE_FILE_MODE))
85            .map_err(file("secure a temporary"))?;
86        temporary
87            .write_all(&contents)
88            .map_err(file("write a temporary"))?;
89        temporary
90            .as_file_mut()
91            .sync_all()
92            .map_err(file("synchronize a temporary"))?;
93
94        Ok(PreparedWrite {
95            temporary,
96            path: self.path.clone(),
97            directory: directory.to_path_buf(),
98        })
99    }
100
101    /// Creates the document empty, and only if nothing is there yet.
102    /// `false` when something already is.
103    ///
104    /// The filesystem decides, not the caller: a name that folds onto an
105    /// existing one — a different case, a different unicode normalization —
106    /// is refused here, which no comparison of names could do portably.
107    pub fn claim(&self) -> Result<bool, DocumentError> {
108        private_fs::prepare_directory(self.parent()?).map_err(DocumentError::Directory)?;
109        let flags = OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC;
110        let mode = Mode::from_bits_retain(PRIVATE_FILE_MODE as rustix::fs::RawMode);
111        match rustix::fs::open(&self.path, flags, mode) {
112            Ok(_descriptor) => Ok(true),
113            Err(error) if error == rustix::io::Errno::EXIST => Ok(false),
114            Err(source) => Err(DocumentError::File {
115                action: "claim",
116                source: source.into(),
117            }),
118        }
119    }
120
121    /// Deletes the document. `false` when there was nothing there.
122    pub fn remove(&self) -> Result<bool, DocumentError> {
123        match std::fs::remove_file(&self.path) {
124            Ok(()) => Ok(true),
125            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
126            Err(source) => Err(DocumentError::File {
127                action: "delete",
128                source,
129            }),
130        }
131    }
132
133    fn parent(&self) -> Result<&Path, DocumentError> {
134        self.path.parent().ok_or(DocumentError::NoParentDirectory)
135    }
136
137    fn open_existing(&self) -> Result<Option<File>, DocumentError> {
138        let flags = OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK;
139        match rustix::fs::open(&self.path, flags, Mode::empty()) {
140            Ok(descriptor) => Ok(Some(File::from(descriptor))),
141            Err(error) if error == rustix::io::Errno::NOENT => Ok(None),
142            Err(source) => Err(DocumentError::File {
143                action: "open",
144                source: source.into(),
145            }),
146        }
147    }
148
149    fn validate(&self, file: &File) -> Result<u64, DocumentError> {
150        let metadata = private_fs::verify_file(file).map_err(DocumentError::Unusable)?;
151        if metadata.len() > self.maximum_bytes {
152            return Err(DocumentError::TooLarge {
153                actual_bytes: metadata.len(),
154                maximum_bytes: self.maximum_bytes,
155            });
156        }
157        Ok(metadata.len())
158    }
159
160    /// Reads one byte past the ceiling, so a file that grew between the size
161    /// check and the read is caught rather than truncated into a parse.
162    fn read_all(&self, file: File, length: u64) -> Result<Vec<u8>, DocumentError> {
163        let mut contents = Vec::with_capacity(length as usize);
164        file.take(self.maximum_bytes + 1)
165            .read_to_end(&mut contents)
166            .map_err(|source| DocumentError::File {
167                action: "read",
168                source,
169            })?;
170        if contents.len() as u64 > self.maximum_bytes {
171            return Err(DocumentError::TooLarge {
172                actual_bytes: contents.len() as u64,
173                maximum_bytes: self.maximum_bytes,
174            });
175        }
176        Ok(contents)
177    }
178}
179
180/// Bytes on the disk under a temporary name. Dropping this instead of
181/// committing leaves the document as it was.
182pub struct PreparedWrite {
183    temporary: tempfile::NamedTempFile,
184    path: PathBuf,
185    directory: PathBuf,
186}
187
188impl PreparedWrite {
189    pub fn commit(self) -> Result<CommittedWrite, DocumentError> {
190        let Self {
191            temporary,
192            path,
193            directory,
194        } = self;
195        temporary
196            .persist(&path)
197            .map_err(|error| DocumentError::File {
198                action: "replace the",
199                source: error.error,
200            })?;
201        Ok(CommittedWrite { directory })
202    }
203}
204
205/// The document is in place. Whether the rename outlives a power cut is the
206/// separate question [`Self::confirm_durability`] answers.
207pub struct CommittedWrite {
208    directory: PathBuf,
209}
210
211impl CommittedWrite {
212    pub fn confirm_durability(self) -> Result<(), DocumentError> {
213        File::open(&self.directory)
214            .and_then(|directory| directory.sync_all())
215            .map_err(DocumentError::DurabilityUnconfirmed)
216    }
217}
218
219#[derive(Debug, thiserror::Error)]
220pub enum DocumentError {
221    #[error("the path has no parent directory")]
222    NoParentDirectory,
223
224    #[error("the containing directory is not usable")]
225    Directory(#[source] PrivateDirectoryError),
226
227    #[error("failed to {action} file")]
228    File {
229        action: &'static str,
230        #[source]
231        source: io::Error,
232    },
233
234    #[error("the file is not usable")]
235    Unusable(#[source] PrivateFileError),
236
237    #[error("the file holds {actual_bytes} bytes; maximum is {maximum_bytes} bytes")]
238    TooLarge {
239        actual_bytes: u64,
240        maximum_bytes: u64,
241    },
242
243    #[error("failed to serialize the document")]
244    Serialize(#[source] serde_json::Error),
245
246    #[error("the document serializes to {actual_bytes} bytes; maximum is {maximum_bytes} bytes")]
247    SerializedTooLarge {
248        actual_bytes: u64,
249        maximum_bytes: u64,
250    },
251
252    #[error("failed to parse the file")]
253    Parse(#[source] serde_json::Error),
254
255    #[error("the document was written, but its durability could not be confirmed")]
256    DurabilityUnconfirmed(#[source] io::Error),
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    const CEILING: u64 = 1024;
264
265    /// A document under a directory that lives as long as the test holds the
266    /// pair.
267    fn document() -> (tempfile::TempDir, PrivateDocument) {
268        let home = tempfile::tempdir().expect("a temporary directory");
269        let document = PrivateDocument::new(home.path().join("state").join("thing.json"), CEILING);
270        (home, document)
271    }
272
273    fn write(document: &PrivateDocument, value: &Vec<u32>) {
274        document
275            .prepare(value)
276            .expect("the document serializes")
277            .commit()
278            .expect("the write commits")
279            .confirm_durability()
280            .expect("the rename is durable");
281    }
282
283    #[test]
284    fn an_unwritten_document_reads_as_nothing() {
285        let (_home, document) = document();
286        let read: Option<Vec<u32>> = document.read().expect("the read succeeds");
287        assert!(read.is_none());
288    }
289
290    #[test]
291    fn a_committed_document_reads_back() {
292        let (_home, document) = document();
293        write(&document, &vec![1, 2, 3]);
294        assert_eq!(
295            document.read().expect("the read succeeds"),
296            Some(vec![1, 2, 3])
297        );
298    }
299
300    #[test]
301    fn a_commit_replaces_what_was_there() {
302        let (_home, document) = document();
303        write(&document, &vec![1]);
304        write(&document, &vec![2]);
305        assert_eq!(document.read().expect("the read succeeds"), Some(vec![2]));
306    }
307
308    #[test]
309    fn a_prepared_write_that_is_dropped_changes_nothing() {
310        let (_home, document) = document();
311        write(&document, &vec![1]);
312        drop(document.prepare(&vec![2]).expect("the document serializes"));
313        assert_eq!(document.read().expect("the read succeeds"), Some(vec![1]));
314    }
315
316    #[test]
317    fn a_document_over_the_ceiling_is_refused_before_it_is_written() {
318        let (_home, document) = document();
319        let oversized: Vec<u32> = (0..CEILING as u32).collect();
320        assert!(matches!(
321            document.prepare(&oversized),
322            Err(DocumentError::SerializedTooLarge { .. })
323        ));
324        assert!(!document.path().exists());
325    }
326
327    #[test]
328    fn a_file_left_readable_by_others_is_refused() {
329        let (_home, document) = document();
330        write(&document, &vec![1]);
331        std::fs::set_permissions(document.path(), Permissions::from_mode(0o644))
332            .expect("the mode changes");
333        assert!(matches!(
334            document.read::<Vec<u32>>(),
335            Err(DocumentError::Unusable(..))
336        ));
337    }
338
339    #[test]
340    fn removing_says_whether_anything_was_there() {
341        let (_home, document) = document();
342        write(&document, &vec![1]);
343        assert!(document.remove().expect("the delete succeeds"));
344        assert!(!document.remove().expect("the delete succeeds"));
345    }
346}