1use std::path::{Path, PathBuf};
12
13pub 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
28fn 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
45pub 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#[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 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 pub fn now(path: &Path) -> Option<FileStamp> {
82 std::fs::metadata(path).ok().map(|meta| FileStamp::of(&meta))
83 }
84}
85
86#[derive(Debug)]
91pub struct Prepared {
92 path: PathBuf,
93 temporary: PathBuf,
94 placed: bool,
95 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 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 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 #[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#[derive(Debug)]
164pub struct Placed {
165 path: PathBuf,
166 old: Option<std::fs::File>,
167 stamp: Option<FileStamp>,
168}
169
170impl Placed {
171 pub fn stamp(&self) -> Option<FileStamp> {
174 self.stamp
175 }
176
177 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
196fn overwrite(file: &mut std::fs::File) {
198 use std::io::{Seek, Write};
199 let Ok(meta) = file.metadata() else { return };
200 #[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
220const READ_AGAIN: usize = 8;
223
224pub fn read_whole(path: &Path) -> std::io::Result<Vec<u8>> {
233 read_whole_with(path).map(|(bytes, _)| bytes)
234}
235
236pub 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
242fn 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
258pub 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
279fn 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
294pub 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 static HELD: std::cell::RefCell<Vec<PathBuf>> = const { std::cell::RefCell::new(Vec::new()) };
311}
312
313pub 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 #[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#[derive(Clone, Default)]
351pub struct Read(Option<std::sync::Arc<toml::Table>>);
352
353impl Read {
354 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
374pub 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
386pub 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 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, ¤t) {
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
423fn 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 #[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 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 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 #[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 #[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 std::fs::write(&path, "work_until = 10
572rest_until = 20
573lighter = [2026-10-01, 2026-10-02]
574").unwrap();
575 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 #[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 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 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 #[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 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}