Skip to main content

sioul_core/
filelock.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! One writer at a time on a file that two parts of Sioul change: the window
5//! marks a dose in the doses' record while the sharing writes in it what your
6//! other devices marked. One writing an older copy over the other would lose a
7//! dose taken, and the sharing would then take it out on every device. An
8//! advisory lock on a hidden file beside it (`.<name>.lock`, which the
9//! sharing never carries), held while the file is read, changed and written.
10
11use std::path::{Path, PathBuf};
12
13/// Bytes written beside `path` under a hidden name of their own, then renamed
14/// over it: a reader never finds the file empty or half written (the sharing
15/// reads without the lock), and a full disk leaves the file as it was rather
16/// than empty (review of 5 October 2026, F10).
17pub fn replace(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
18    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
19    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
20    let temporary = path.with_file_name(format!(".{name}.{}-{}.new", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
21    let written = std::fs::write(&temporary, bytes).and_then(|()| rename_over(&temporary, path));
22    if written.is_err() {
23        let _ = std::fs::remove_file(&temporary);
24    }
25    written
26}
27
28/// `from` renamed over `to`. On Windows a file another thread or program has
29/// open for a moment (a reader of the same state) refuses to be replaced:
30/// tried again a few times, for a tenth of a second at most (CI's Windows job
31/// of 22 October 2026: a calendar's kinds lost so).
32fn rename_over(from: &Path, to: &Path) -> std::io::Result<()> {
33    let mut tries = 0;
34    loop {
35        match std::fs::rename(from, to) {
36            Err(e) if cfg!(windows) && e.kind() == std::io::ErrorKind::PermissionDenied && tries < 20 => {
37                tries += 1;
38                std::thread::sleep(std::time::Duration::from_millis(5));
39            }
40            done => return done,
41        }
42    }
43}
44
45/// `bytes` in place of `path`'s content, what it held overwritten where the
46/// filesystem lets it be (docs/client.md, "What a disk keeps"): [`Prepared`],
47/// [`Prepared::place`] and [`Placed::finish`] at once, for a file no other
48/// part of Sioul reads meanwhile but through [`read_whole`]. A file that
49/// readers take a lock for is replaced in those three steps, the place alone
50/// under that lock (`draftcopies`: the sharing's exchange lock).
51pub fn replace_wiping(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
52    if let Some(placed) = Prepared::new(path, bytes)?.place(None)? {
53        placed.finish();
54    }
55    Ok(())
56}
57
58/// A file as it was when it was read, to know before replacing it that it
59/// still is (its device and number, its size and time; size and time only
60/// where a file has no number: Windows).
61#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
62pub struct FileStamp {
63    dev: u64,
64    ino: u64,
65    len: u64,
66    modified: u128,
67}
68
69impl FileStamp {
70    /// What `meta` says of a file (a descriptor's, after reading through it).
71    pub fn of(meta: &std::fs::Metadata) -> FileStamp {
72        #[cfg(unix)]
73        let (dev, ino) = (std::os::unix::fs::MetadataExt::dev(meta), std::os::unix::fs::MetadataExt::ino(meta));
74        #[cfg(not(unix))]
75        let (dev, ino) = (0, 0);
76        let modified = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos());
77        FileStamp { dev, ino, len: meta.len(), modified }
78    }
79
80    /// The file `path` names now; none when there is none.
81    pub fn now(path: &Path) -> Option<FileStamp> {
82        std::fs::metadata(path).ok().map(|meta| FileStamp::of(&meta))
83    }
84}
85
86/// New bytes for a file, written beside it under a hidden name (which the
87/// sharing and the watcher pass over), yours alone, and on the disk, waiting
88/// to take its place; taken out if they never do. The slow part of a
89/// replacement, done before any lock is taken.
90#[derive(Debug)]
91pub struct Prepared {
92    path: PathBuf,
93    temporary: PathBuf,
94    placed: bool,
95    /// The new file as written: the same once it takes the path's place (a
96    /// rename keeps its device, number, size and time).
97    stamp: Option<FileStamp>,
98}
99
100impl Prepared {
101    pub fn new(path: &Path, bytes: &[u8]) -> std::io::Result<Prepared> {
102        use std::io::Write;
103        static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
104        let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
105        let temporary = path.with_file_name(format!(".{name}.{}-{}.new", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
106        let mut options = std::fs::OpenOptions::new();
107        options.write(true).create_new(true);
108        #[cfg(unix)]
109        std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
110        // Made first, so that a failure below takes it out (`Drop`).
111        let mut prepared = Prepared { path: path.to_path_buf(), temporary, placed: false, stamp: None };
112        let mut file = options.open(&prepared.temporary)?;
113        file.write_all(bytes)?;
114        file.sync_all()?;
115        prepared.stamp = file.metadata().ok().map(|meta| FileStamp::of(&meta));
116        Ok(prepared)
117    }
118
119    /// The new bytes in the file's place, when it is still as `was` says
120    /// (None: whatever it is now); else nothing is done and None returned (the
121    /// file changed since it was read, or went). Quick, no byte written: meant
122    /// to run under the lock its readers take. On Unix the old file is held
123    /// open across the rename, to overwrite it once it has no name
124    /// ([`Placed::finish`]); elsewhere it is not held, and never overwritten:
125    /// Windows renames a file held open only at times, and nothing there can
126    /// read zeros.
127    pub fn place(mut self, was: Option<FileStamp>) -> std::io::Result<Option<Placed>> {
128        if let Some(was) = was
129            && FileStamp::now(&self.path) != Some(was)
130        {
131            return Ok(None);
132        }
133        // Held for its overwrite; a file yours but read-only (a message copied
134        // with its mode) made writable first, else its old bytes would stay
135        // (review of 19 October 2026, F17).
136        #[cfg(unix)]
137        let old = std::fs::OpenOptions::new().write(true).open(&self.path).ok().or_else(|| {
138            let mut permissions = std::fs::metadata(&self.path).ok()?.permissions();
139            std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o600);
140            std::fs::set_permissions(&self.path, permissions).ok()?;
141            std::fs::OpenOptions::new().write(true).open(&self.path).ok()
142        });
143        #[cfg(not(unix))]
144        let old: Option<std::fs::File> = None;
145        std::fs::rename(&self.temporary, &self.path)?;
146        self.placed = true;
147        Ok(Some(Placed { path: self.path.clone(), old, stamp: self.stamp }))
148    }
149}
150
151impl Drop for Prepared {
152    fn drop(&mut self) {
153        if !self.placed {
154            let _ = std::fs::remove_file(&self.temporary);
155        }
156    }
157}
158
159/// A replacement made: what is left once the lock is let go. No reader that
160/// takes that lock holds the old file then (it reads within its own hold,
161/// by the file's name, which names the new file), and a reader that does not
162/// reads through [`read_whole`], which never returns what follows.
163#[derive(Debug)]
164pub struct Placed {
165    path: PathBuf,
166    old: Option<std::fs::File>,
167    stamp: Option<FileStamp>,
168}
169
170impl Placed {
171    /// The file that took the path's place, as it was written: what the path
172    /// names after another version replaces it is another file.
173    pub fn stamp(&self) -> Option<FileStamp> {
174        self.stamp
175    }
176
177    /// The new name on the disk (after a crash, the folder names the new
178    /// file); then the old file, without a name now, overwritten with zeros
179    /// and on the disk before it goes. Kept whole while it has another name
180    /// (a hard link): that name's own replacement overwrites it. A filesystem
181    /// that writes each change elsewhere (copy on write: Btrfs, ZFS, APFS;
182    /// F2FS on phones; snapshots) and a flash disk's own remapping keep the old
183    /// blocks until they are reused: only the disk's encryption makes those
184    /// unreadable.
185    pub fn finish(mut self) {
186        #[cfg(unix)]
187        if let Some(folder) = self.path.parent().and_then(|p| std::fs::File::open(if p.as_os_str().is_empty() { Path::new(".") } else { p }).ok()) {
188            let _ = folder.sync_all();
189        }
190        if let Some(old) = self.old.as_mut() {
191            overwrite(old);
192        }
193    }
194}
195
196/// A file without a name left, its bytes overwritten with zeros, on the disk.
197fn overwrite(file: &mut std::fs::File) {
198    use std::io::{Seek, Write};
199    let Ok(meta) = file.metadata() else { return };
200    // Another name still holds it: left whole for that name.
201    #[cfg(unix)]
202    if std::os::unix::fs::MetadataExt::nlink(&meta) > 0 {
203        return;
204    }
205    let zeros = [0u8; 64 * 1024];
206    let mut left = meta.len();
207    if file.seek(std::io::SeekFrom::Start(0)).is_err() {
208        return;
209    }
210    while left > 0 {
211        let n = usize::try_from(left.min(zeros.len() as u64)).unwrap_or(zeros.len());
212        if file.write_all(&zeros[..n]).is_err() {
213            return;
214        }
215        left -= n as u64;
216    }
217    let _ = file.sync_all();
218}
219
220/// How many times a file replaced while it was read is read again before
221/// that is said ("Interrupted").
222const READ_AGAIN: usize = 8;
223
224/// A file's bytes as one of its versions held them whole, never the zeros
225/// [`Placed::finish`] writes over the file it replaced: read through its
226/// descriptor, then checked that the file read still has a name and that
227/// `path` names it (`still_named`); else read again, a few times. A file
228/// replaced while it was read has no name, and its bytes may be zeros by
229/// then; one replaced after it was read is an older version: both are read
230/// again. Where a file has no number to tell (Windows), read once: there
231/// nothing is overwritten.
232pub fn read_whole(path: &Path) -> std::io::Result<Vec<u8>> {
233    read_whole_with(path).map(|(bytes, _)| bytes)
234}
235
236/// [`read_whole`], with what the descriptor said of the file read, after the
237/// read: its size and time are those of the bytes returned.
238pub fn read_whole_with(path: &Path) -> std::io::Result<(Vec<u8>, std::fs::Metadata)> {
239    read_through(path, &mut |path| std::fs::File::open(path))
240}
241
242/// [`read_whole_with`], each file opened by `open` (a test replaces the file
243/// between its opening and its reading).
244fn read_through(path: &Path, open: &mut dyn FnMut(&Path) -> std::io::Result<std::fs::File>) -> std::io::Result<(Vec<u8>, std::fs::Metadata)> {
245    use std::io::Read;
246    for _ in 0..READ_AGAIN {
247        let mut file = open(path)?;
248        let mut bytes = Vec::with_capacity(file.metadata().map_or(0, |m| usize::try_from(m.len()).unwrap_or(0)));
249        file.read_to_end(&mut bytes)?;
250        let held = file.metadata()?;
251        if still_named(path, &held) {
252            return Ok((bytes, held));
253        }
254    }
255    Err(std::io::Error::new(std::io::ErrorKind::Interrupted, format!("{}: replaced each time it was read", path.display())))
256}
257
258/// `from` copied to `to` as one of its versions held it whole, with
259/// [`read_whole`]'s check: through its descriptor, then again, a few times,
260/// when the file copied lost its name meanwhile or `from` names another one.
261/// `to` is made yours alone (on Unix). Returns the bytes copied.
262pub fn copy_whole(from: &Path, to: &Path) -> std::io::Result<u64> {
263    for _ in 0..READ_AGAIN {
264        let mut source = std::fs::File::open(from)?;
265        let mut options = std::fs::OpenOptions::new();
266        options.write(true).create(true).truncate(true);
267        #[cfg(unix)]
268        std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
269        let mut target = options.open(to)?;
270        let copied = std::io::copy(&mut source, &mut target)?;
271        let held = source.metadata()?;
272        if still_named(from, &held) {
273            return Ok(copied);
274        }
275    }
276    Err(std::io::Error::new(std::io::ErrorKind::Interrupted, format!("{}: replaced each time it was copied", from.display())))
277}
278
279/// Whether the file read through a descriptor (`held`: what it says after
280/// the read) still has a name, and `path` is it.
281fn still_named(path: &Path, held: &std::fs::Metadata) -> bool {
282    #[cfg(unix)]
283    {
284        use std::os::unix::fs::MetadataExt;
285        held.nlink() > 0 && std::fs::metadata(path).is_ok_and(|named| named.dev() == held.dev() && named.ino() == held.ino())
286    }
287    #[cfg(not(unix))]
288    {
289        let _ = (path, held);
290        true
291    }
292}
293
294/// Whether a writer of `path` takes its lock here (its hidden lock file is
295/// there): for files the sharing writes without it otherwise (a note, which
296/// most writers do not lock), so that a file one of Sioul's own writers
297/// changes under its lock (the letters' list) is written under it too.
298pub fn has_lock(path: &Path) -> bool {
299    lock_path(path).exists()
300}
301
302fn lock_path(path: &Path) -> PathBuf {
303    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
304    path.with_file_name(format!(".{name}.lock"))
305}
306
307thread_local! {
308    /// The locks this thread holds: one taken again inside (a writer calling
309    /// another of the same file) is already held, never waited for.
310    static HELD: std::cell::RefCell<Vec<PathBuf>> = const { std::cell::RefCell::new(Vec::new()) };
311}
312
313/// `f`, with the lock on `path` held: other threads and programs wait for it.
314/// Where no lock can be taken (a folder not writable), `f` runs all the same:
315/// writing it fails there anyway. Taken again by the thread holding it, it is
316/// held already.
317pub fn with_lock<T>(path: &Path, f: impl FnOnce() -> T) -> T {
318    let lock = lock_path(path);
319    if HELD.with(|held| held.borrow().contains(&lock)) {
320        return f();
321    }
322    struct Holding(PathBuf);
323    impl Drop for Holding {
324        fn drop(&mut self) {
325            HELD.with(|held| held.borrow_mut().retain(|p| *p != self.0));
326        }
327    }
328    let mut options = std::fs::OpenOptions::new();
329    options.create(true).truncate(false).write(true);
330    // Empty, it says nothing; yours alone all the same, as what it guards is (0600 on Unix).
331    #[cfg(unix)]
332    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
333    let file = path.parent().and_then(|parent| std::fs::create_dir_all(parent).ok()).and_then(|()| options.open(lock_path(path)).ok());
334    let locked = file.as_ref().is_some_and(|f| f.lock().is_ok());
335    HELD.with(|held| held.borrow_mut().push(lock.clone()));
336    let holding = Holding(lock);
337    let out = f();
338    drop(holding);
339    if locked && let Some(file) = &file {
340        let _ = file.unlock();
341    }
342    out
343}
344
345/// A TOML file as it was read, kept beside the state read from it (a field
346/// that serde skips): saved, only what changed since it was read is written
347/// over what the file holds then (`save_merged`), so that a change your other
348/// devices brought in meanwhile (the sharing writes under the same lock)
349/// stays. Equal to any other, so that it changes no comparison of the state.
350#[derive(Clone, Default)]
351pub struct Read(Option<std::sync::Arc<toml::Table>>);
352
353impl Read {
354    /// The file's text, as read.
355    pub fn of(text: &str) -> Read {
356        Read(text.parse::<toml::Table>().ok().map(std::sync::Arc::new))
357    }
358}
359
360impl PartialEq for Read {
361    fn eq(&self, _: &Read) -> bool {
362        true
363    }
364}
365
366impl Eq for Read {}
367
368impl std::fmt::Debug for Read {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        f.write_str("Read")
371    }
372}
373
374/// `state` written into `path`, under its lock, the file read again first:
375/// what changed from `read` (the file as it was read) to `state` is set over
376/// what the file holds now, field by field (a list as a set: what was added
377/// added, what was taken out taken out; a list or table missing on one side
378/// is an empty one, as a state skips an empty field); the rest stays as the
379/// file holds it. A file missing when it was read is read as empty. Nothing
380/// read before that could be merged (a file that did not read): `state`
381/// whole. Written beside, then moved.
382pub fn save_merged<T: serde::Serialize + serde::de::DeserializeOwned>(path: &Path, read: &Read, state: &T) -> Result<(), String> {
383    save_merged_by(path, read, state, |_, _| {})
384}
385
386/// The same, `settle` then given the merged table and the file as it was
387/// just before (none when missing): for what a field-by-field merge cannot
388/// say (a mark that must never go backwards).
389pub fn save_merged_by<T: serde::Serialize + serde::de::DeserializeOwned>(path: &Path, read: &Read, state: &T, settle: impl FnOnce(&mut toml::Table, Option<&toml::Table>)) -> Result<(), String> {
390    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
391    let new = match toml::Value::try_from(state).map_err(|e| e.to_string())? {
392        toml::Value::Table(table) => table,
393        _ => return Err(format!("{}: not a table", path.display())),
394    };
395    // What of the file as read this Sioul knows: read into `T` and written
396    // again. A field it does not know (a newer Sioul's) is never taken out.
397    let known: Option<toml::Table> = read.0.as_ref().and_then(|base| T::deserialize(toml::Value::Table((**base).clone())).ok()).and_then(|t| toml::Value::try_from(&t).ok()).and_then(|v| match v {
398        toml::Value::Table(table) => Some(table),
399        _ => None,
400    });
401    with_lock(path, || {
402        let current = std::fs::read_to_string(path).ok().and_then(|text| text.parse::<toml::Table>().ok());
403        let mut table = match (&read.0, &current) {
404            (Some(base), Some(current)) => {
405                let mut merged = current.clone();
406                merge(&mut merged, base, &new, known.as_ref());
407                merged
408            }
409            _ => new.clone(),
410        };
411        settle(&mut table, current.as_ref());
412        if let Some(parent) = path.parent() {
413            std::fs::create_dir_all(parent).map_err(fail)?;
414        }
415        let temporary = path.with_file_name(format!(".{}.sioul.tmp", path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()));
416        std::fs::write(&temporary, toml::to_string(&table).map_err(|e| e.to_string())?).map_err(fail)?;
417        std::fs::rename(&temporary, path).map_err(fail)
418    })
419}
420
421static EMPTY: std::sync::LazyLock<toml::Table> = std::sync::LazyLock::new(toml::Table::new);
422
423/// What changed from `base` to `new`, set over `current`. A list or a table
424/// on one side only stands against an empty one on the other: emptied, it
425/// takes out what it held, never what another writer added since; filled
426/// from nothing, it adds to what another writer put there.
427///
428/// `known`: the base as the state's type reads it (none: all of it); a key of
429/// the base it does not hold, nor `new`, is one that type does not know,
430/// left as `current` holds it.
431fn merge(current: &mut toml::Table, base: &toml::Table, new: &toml::Table, known: Option<&toml::Table>) {
432    let keys: std::collections::BTreeSet<String> = base.keys().chain(new.keys()).filter(|key| new.contains_key(*key) || known.is_none_or(|known| known.contains_key(*key)) || !base.contains_key(*key)).cloned().collect();
433    let no_list = toml::Value::Array(Vec::new());
434    let no_table = toml::Value::Table(toml::Table::new());
435    for key in keys {
436        let (before, after) = match (base.get(&key), new.get(&key)) {
437            (Some(before @ toml::Value::Array(_)), None) => (Some(before), Some(&no_list)),
438            (None, Some(after @ toml::Value::Array(_))) => (Some(&no_list), Some(after)),
439            (Some(before @ toml::Value::Table(_)), None) => (Some(before), Some(&no_table)),
440            (None, Some(after @ toml::Value::Table(_))) => (Some(&no_table), Some(after)),
441            pair => pair,
442        };
443        match (before, after) {
444            (before, after) if before == after => {}
445            (Some(toml::Value::Table(before)), Some(toml::Value::Table(after))) => match current.get_mut(&key) {
446                Some(toml::Value::Table(inner)) => {
447                    merge(inner, before, after, known.map(|k| k.get(&key).and_then(toml::Value::as_table).unwrap_or(&*EMPTY)));
448                    if inner.is_empty() && !new.contains_key(&key) {
449                        current.remove(&key);
450                    }
451                }
452                _ if after.is_empty() && !new.contains_key(&key) => {}
453                _ => {
454                    current.insert(key, toml::Value::Table(after.clone()));
455                }
456            },
457            (Some(toml::Value::Array(before)), Some(toml::Value::Array(after))) => match current.get_mut(&key) {
458                Some(toml::Value::Array(inner)) => {
459                    inner.retain(|item| !before.contains(item) || after.contains(item));
460                    for item in after.iter().filter(|item| !before.contains(item)) {
461                        if !inner.contains(item) {
462                            inner.push(item.clone());
463                        }
464                    }
465                    if inner.is_empty() && !new.contains_key(&key) {
466                        current.remove(&key);
467                    }
468                }
469                _ if after.is_empty() && !new.contains_key(&key) => {}
470                _ => {
471                    current.insert(key, toml::Value::Array(after.clone()));
472                }
473            },
474            (_, Some(after)) => {
475                current.insert(key, after.clone());
476            }
477            (_, None) => {
478                current.remove(&key);
479            }
480        }
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    /// The race main's final pass met (19 October 2026), made to happen: a
489    /// reader opens a copy, the copy is replaced and its old bytes overwritten
490    /// before the reader reads. Read through that descriptor alone, the
491    /// reader gets the zeros; `read_whole` sees that the file it read has no
492    /// name any more, and reads again: the new bytes, never the zeros.
493    #[cfg(unix)]
494    #[test]
495    fn a_file_replaced_between_its_opening_and_its_reading_is_read_again() {
496        use std::io::Read;
497        let base = std::env::temp_dir().join(format!("sioul-read-whole-{}", std::process::id()));
498        let _ = std::fs::remove_dir_all(&base);
499        std::fs::create_dir_all(&base).unwrap();
500        let path = base.join("a1.eml");
501        std::fs::write(&path, "The secret clause is on page four.").unwrap();
502        let mut plain = std::fs::File::open(&path).unwrap();
503        let mut opened = 0;
504        let (bytes, held) = read_through(&path, &mut |path| {
505            let file = std::fs::File::open(path)?;
506            opened += 1;
507            if opened == 1 {
508                replace_wiping(path, b"sealed")?;
509            }
510            Ok(file)
511        })
512        .unwrap();
513        assert_eq!(bytes, b"sealed");
514        assert_eq!((opened, held.len()), (2, 6), "read again once, the new file's size recorded");
515        let mut old = Vec::new();
516        plain.read_to_end(&mut old).unwrap();
517        assert!(!old.is_empty() && old.iter().all(|&b| b == 0), "a plain read gets the zeros: {old:?}");
518        // Replaced each time: said, never returned.
519        let again = || {
520            let file = std::fs::File::open(&path)?;
521            replace_wiping(&path, b"sealed")?;
522            Ok(file)
523        };
524        assert_eq!(read_through(&path, &mut |_| again()).unwrap_err().kind(), std::io::ErrorKind::Interrupted);
525        // A copy through the same check.
526        let copied = base.join("copy");
527        assert_eq!(copy_whole(&path, &copied).unwrap(), 6);
528        assert_eq!(std::fs::read(&copied).unwrap(), b"sealed");
529        let _ = std::fs::remove_dir_all(&base);
530    }
531
532    /// A copy yours but read-only (a message kept with the mode it came
533    /// with) is made writable to be overwritten: once replaced, its old bytes
534    /// are zeros (review of 19 October 2026, F17, probe P10a: they stayed,
535    /// silently, since it could not be opened for writing).
536    #[cfg(unix)]
537    #[test]
538    fn a_read_only_copy_is_overwritten_too() {
539        use std::io::Read;
540        use std::os::unix::fs::PermissionsExt;
541        let base = std::env::temp_dir().join(format!("sioul-read-only-{}", std::process::id()));
542        let _ = std::fs::remove_dir_all(&base);
543        std::fs::create_dir_all(&base).unwrap();
544        let path = base.join("a1.eml");
545        std::fs::write(&path, "The secret clause is on page four.").unwrap();
546        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
547        let mut reader = std::fs::File::open(&path).unwrap();
548        let placed = Prepared::new(&path, b"sealed").unwrap().place(FileStamp::now(&path)).unwrap().expect("placed");
549        placed.finish();
550        let mut old = Vec::new();
551        reader.read_to_end(&mut old).unwrap();
552        assert!(!old.is_empty() && old.iter().all(|&b| b == 0), "the old bytes overwritten: {:?}", String::from_utf8_lossy(&old));
553        assert_eq!(std::fs::read(&path).unwrap(), b"sealed");
554        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600, "the new copy yours alone");
555        let _ = std::fs::remove_dir_all(&base);
556    }
557
558    /// A state saved after another writer changed the file since it was read
559    /// keeps that writer's change: only what this one changed is written.
560    #[test]
561    fn a_save_keeps_what_another_writer_changed_since_it_was_read() {
562        let dir = std::env::temp_dir().join(format!("sioul-merge-{}", std::process::id()));
563        let _ = std::fs::remove_dir_all(&dir);
564        std::fs::create_dir_all(&dir).unwrap();
565        let path = dir.join("quiet.toml");
566        std::fs::write(&path, "work_until = 10
567lighter = [2026-10-01]
568").unwrap();
569        let read = Read::of(&std::fs::read_to_string(&path).unwrap());
570        // Another writer (the sharing) adds a day and sets a field.
571        std::fs::write(&path, "work_until = 10
572rest_until = 20
573lighter = [2026-10-01, 2026-10-02]
574").unwrap();
575        // This one, from what it read, changes work_until and adds another day.
576        let mine: toml::Table = "work_until = 11
577lighter = [2026-10-01, 2026-10-03]
578".parse().unwrap();
579        save_merged(&path, &read, &mine).unwrap();
580        let now: toml::Table = std::fs::read_to_string(&path).unwrap().parse().unwrap();
581        assert_eq!(now["work_until"].as_integer(), Some(11));
582        assert_eq!(now["rest_until"].as_integer(), Some(20), "the other writer's field stays");
583        assert_eq!(now["lighter"].as_array().unwrap().len(), 3, "both days added stay: {now:?}");
584        let _ = std::fs::remove_dir_all(&dir);
585    }
586
587    /// A list the window empties (a state skips an empty field) takes out
588    /// what it held, never what another writer added since; a list written
589    /// into a file that was missing when read adds to what another writer put
590    /// there since (third review, T9).
591    #[test]
592    fn an_emptied_or_new_list_keeps_what_another_writer_added() {
593        let dir = std::env::temp_dir().join(format!("sioul-merge-empty-{}", std::process::id()));
594        let _ = std::fs::remove_dir_all(&dir);
595        std::fs::create_dir_all(&dir).unwrap();
596        let path = dir.join("quiet.toml");
597        std::fs::write(&path, "lighter = [2026-10-12]\n").unwrap();
598        let read = Read::of(&std::fs::read_to_string(&path).unwrap());
599        std::fs::write(&path, "lighter = [2026-10-12, 2026-10-14]\n").unwrap();
600        save_merged(&path, &read, &toml::Table::new()).unwrap();
601        assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "lighter = [2026-10-14]");
602        // Emptied with nothing added since: the field goes.
603        let read = Read::of(&std::fs::read_to_string(&path).unwrap());
604        save_merged(&path, &read, &toml::Table::new()).unwrap();
605        assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "");
606        // Missing when read; the other writer makes it; this one adds its own.
607        std::fs::remove_file(&path).unwrap();
608        let read = Read::of(&std::fs::read_to_string(&path).unwrap_or_default());
609        std::fs::write(&path, "ignored = [\"a\"]\n[seen]\nx = 1\n").unwrap();
610        let mine: toml::Table = "ignored = [\"b\"]\n[seen]\ny = 2\n".parse().unwrap();
611        save_merged(&path, &read, &mine).unwrap();
612        let now: toml::Table = std::fs::read_to_string(&path).unwrap().parse().unwrap();
613        assert_eq!(now["ignored"].as_array().unwrap().len(), 2, "{now:?}");
614        assert_eq!((now["seen"].get("x").and_then(toml::Value::as_integer), now["seen"].get("y").and_then(toml::Value::as_integer)), (Some(1), Some(2)), "{now:?}");
615        let _ = std::fs::remove_dir_all(&dir);
616    }
617
618    /// A field the state's type does not know (a newer Sioul's) stays, saved
619    /// over by an older one.
620    #[test]
621    fn a_field_a_newer_sioul_wrote_stays() {
622        #[derive(serde::Serialize, serde::Deserialize)]
623        struct Older {
624            #[serde(default)]
625            work_until: Option<i64>,
626        }
627        let dir = std::env::temp_dir().join(format!("sioul-merge-newer-{}", std::process::id()));
628        let _ = std::fs::remove_dir_all(&dir);
629        std::fs::create_dir_all(&dir).unwrap();
630        let path = dir.join("quiet.toml");
631        std::fs::write(&path, "work_until = 10\nnewer = \"kept\"\n[table]\nnewer = 1\n").unwrap();
632        let read = Read::of(&std::fs::read_to_string(&path).unwrap());
633        save_merged(&path, &read, &Older { work_until: Some(11) }).unwrap();
634        let now: toml::Table = std::fs::read_to_string(&path).unwrap().parse().unwrap();
635        assert_eq!((now["work_until"].as_integer(), now["newer"].as_str(), now.get("table").is_some()), (Some(11), Some("kept"), true), "{now:?}");
636        let _ = std::fs::remove_dir_all(&dir);
637    }
638
639    #[test]
640    fn writers_take_turns() {
641        let dir = std::env::temp_dir().join(format!("sioul-lock-{}", std::process::id()));
642        let _ = std::fs::remove_dir_all(&dir);
643        std::fs::create_dir_all(&dir).unwrap();
644        let path = dir.join("count.txt");
645        std::fs::write(&path, "0").unwrap();
646        // Eight threads add one each, a hundred times: read, wait, write. Locked, none is lost.
647        let threads: Vec<_> = (0..8)
648            .map(|_| {
649                let path = path.clone();
650                std::thread::spawn(move || {
651                    for _ in 0..100 {
652                        with_lock(&path, || {
653                            let n: u32 = std::fs::read_to_string(&path).unwrap().trim().parse().unwrap();
654                            std::thread::yield_now();
655                            std::fs::write(&path, (n + 1).to_string()).unwrap();
656                        });
657                    }
658                })
659            })
660            .collect();
661        for thread in threads {
662            thread.join().unwrap();
663        }
664        assert_eq!(std::fs::read_to_string(&path).unwrap(), "800");
665        assert!(dir.join(".count.txt.lock").exists(), "hidden, so never shared");
666        let _ = std::fs::remove_dir_all(&dir);
667    }
668}