1use std::collections::BTreeSet;
61use std::fs;
62use std::path::{Path, PathBuf};
63use std::sync::{Condvar, Mutex, PoisonError};
64
65use rightkit_fs::{AtomicOptions, AtomicWriter, FsError};
66
67use crate::model::PdfEditError;
68use crate::save::{resolve_commit_with, PdfCommitResult, PdfSaveTokenStore};
69
70#[derive(Debug, thiserror::Error)]
71pub enum CommitError {
72 #[error(transparent)]
73 Edit(#[from] PdfEditError),
74 #[error("write failed: {0}")]
75 Io(#[from] std::io::Error),
76 #[error("not saved, the original is unchanged: {0}")]
79 Refused(#[from] Unpreserved),
80 #[error("saved, but a step after the replacement failed: {0}")]
84 Replaced(std::io::Error),
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
91#[non_exhaustive]
92pub enum Unpreserved {
93 #[error("the file is read-only")]
95 ReadOnly,
96 #[error("the file is encrypted and a replacement would not be")]
98 Encrypted,
99 #[error("the replacement would have a different owner")]
101 Owner,
102 #[error("the replacement would have a different group")]
104 Group,
105 #[error("the replacement would have different permission bits")]
107 Mode,
108 #[error("the file has, or may have, an access control list the replacement would not keep")]
111 Acl,
112}
113
114pub fn write_atomic(target: &Path, bytes: &[u8]) -> std::io::Result<()> {
126 let target = resolve_destination(target)?;
127 publish(&target, bytes).map_err(into_io)
128}
129
130fn into_io(error: CommitError) -> std::io::Error {
131 match error {
132 CommitError::Io(source) => source,
133 other => {
134 let kind = match &other {
135 CommitError::Refused(_) => std::io::ErrorKind::PermissionDenied,
136 CommitError::Replaced(source) => source.kind(),
137 _ => std::io::ErrorKind::Other,
138 };
139 std::io::Error::new(kind, other)
140 }
141 }
142}
143
144fn fs_error(error: FsError) -> CommitError {
147 if error.target_replaced() {
148 CommitError::Replaced(error.into())
149 } else {
150 CommitError::Io(error.into())
151 }
152}
153
154fn publish(target: &Path, bytes: &[u8]) -> Result<(), CommitError> {
158 let mut writer = AtomicWriter::create(target, AtomicOptions::default()).map_err(fs_error)?;
159 match fs::metadata(target) {
160 Ok(existing) => {
161 if !existing.is_file() {
162 return Err(std::io::Error::new(
163 std::io::ErrorKind::InvalidInput,
164 "the destination exists and is not a regular file",
165 )
166 .into());
167 }
168 keeps_protection(target, &existing, writer.temp_path())?;
169 }
170 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
172 Err(error) => return Err(error.into()),
173 }
174 writer.write_all(bytes).map_err(fs_error)?;
175 writer.commit().map_err(fs_error)
176}
177
178#[cfg(unix)]
182fn same_protection(existing: &fs::Metadata, made: &fs::Metadata) -> Result<(), Unpreserved> {
183 use std::os::unix::fs::MetadataExt;
184 if existing.uid() != made.uid() {
185 return Err(Unpreserved::Owner);
186 }
187 if existing.gid() != made.gid() {
188 return Err(Unpreserved::Group);
189 }
190 if existing.mode() & 0o7777 != made.mode() & 0o7777 {
191 return Err(Unpreserved::Mode);
192 }
193 Ok(())
194}
195
196#[cfg(unix)]
201fn keeps_protection(
202 target: &Path,
203 existing: &fs::Metadata,
204 temp: &Path,
205) -> Result<(), CommitError> {
206 use std::os::unix::fs::MetadataExt;
207 let made = fs::metadata(temp)?;
208 same_protection(existing, &made)?;
209 if existing.mode() & 0o077 == 0 {
210 let owner_only = |path: &Path| rightkit_fs::private::is_owner_only(path).unwrap_or(false);
211 if !owner_only(target) || !owner_only(temp) {
212 return Err(Unpreserved::Acl.into());
213 }
214 }
215 Ok(())
216}
217
218#[cfg(windows)]
222fn keeps_protection(
223 _target: &Path,
224 existing: &fs::Metadata,
225 _temp: &Path,
226) -> Result<(), CommitError> {
227 use std::os::windows::fs::MetadataExt;
228 const READONLY: u32 = 0x0000_0001;
230 const ENCRYPTED: u32 = 0x0000_4000;
231 let attributes = existing.file_attributes();
232 if attributes & READONLY != 0 {
233 return Err(Unpreserved::ReadOnly.into());
234 }
235 if attributes & ENCRYPTED != 0 {
236 return Err(Unpreserved::Encrypted.into());
237 }
238 Ok(())
239}
240
241#[cfg(not(any(unix, windows)))]
242fn keeps_protection(
243 _target: &Path,
244 _existing: &fs::Metadata,
245 _temp: &Path,
246) -> Result<(), CommitError> {
247 Ok(())
248}
249
250type FileKey = (u64, u128);
253
254#[derive(Clone, Debug)]
256struct FileIdentity {
257 file: Option<FileKey>,
259 path: PathBuf,
262}
263
264#[cfg(unix)]
265fn file_key(path: &Path) -> std::io::Result<Option<FileKey>> {
266 use std::os::unix::fs::MetadataExt;
267 match fs::metadata(path) {
268 Ok(meta) => Ok(Some((meta.dev(), u128::from(meta.ino())))),
269 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
270 Err(error) => Err(error),
271 }
272}
273
274#[cfg(windows)]
279fn file_key(path: &Path) -> std::io::Result<Option<FileKey>> {
280 use std::os::windows::fs::OpenOptionsExt;
281 use std::os::windows::io::AsRawHandle;
282 use windows_sys::Win32::Foundation::HANDLE;
283 use windows_sys::Win32::Storage::FileSystem::{
284 FileIdInfo, GetFileInformationByHandle, GetFileInformationByHandleEx,
285 BY_HANDLE_FILE_INFORMATION, FILE_ID_INFO, FILE_READ_ATTRIBUTES,
286 };
287
288 let file = match fs::OpenOptions::new()
289 .access_mode(FILE_READ_ATTRIBUTES)
290 .open(path)
291 {
292 Ok(file) => file,
293 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
294 Err(error) => return Err(error),
295 };
296 let handle = file.as_raw_handle() as HANDLE;
297
298 unsafe {
301 let mut info: FILE_ID_INFO = std::mem::zeroed();
302 let ok = GetFileInformationByHandleEx(
303 handle,
304 FileIdInfo,
305 (&mut info as *mut FILE_ID_INFO).cast(),
306 std::mem::size_of::<FILE_ID_INFO>() as u32,
307 );
308 if ok != 0 {
309 return Ok(Some((
310 info.VolumeSerialNumber,
311 u128::from_le_bytes(info.FileId.Identifier),
312 )));
313 }
314 let mut basic: BY_HANDLE_FILE_INFORMATION = std::mem::zeroed();
315 if GetFileInformationByHandle(handle, &mut basic) != 0 {
316 let index = (u64::from(basic.nFileIndexHigh) << 32) | u64::from(basic.nFileIndexLow);
317 return Ok(Some((
318 u64::from(basic.dwVolumeSerialNumber),
319 u128::from(index),
320 )));
321 }
322 }
323 Err(std::io::Error::last_os_error())
324}
325
326#[cfg(not(any(unix, windows)))]
329fn file_key(path: &Path) -> std::io::Result<Option<FileKey>> {
330 match fs::metadata(path) {
331 Ok(_) => Err(std::io::Error::new(
332 std::io::ErrorKind::Unsupported,
333 "file identity is unavailable on this platform",
334 )),
335 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
336 Err(error) => Err(error),
337 }
338}
339
340fn resolve_destination(path: &Path) -> std::io::Result<PathBuf> {
344 match fs::canonicalize(path) {
345 Ok(canonical) => Ok(canonical),
346 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
347 if fs::symlink_metadata(path).is_ok() {
348 return Err(error);
349 }
350 let parent = path
351 .parent()
352 .filter(|p| !p.as_os_str().is_empty())
353 .unwrap_or(Path::new("."));
354 let parent = fs::canonicalize(parent)
355 .or_else(|_| std::path::absolute(parent))
356 .unwrap_or_else(|_| parent.to_path_buf());
357 Ok(match path.file_name() {
358 Some(name) => parent.join(name),
359 None => parent,
360 })
361 }
362 Err(error) => Err(error),
363 }
364}
365
366impl FileIdentity {
367 fn try_of(path: &Path) -> std::io::Result<FileIdentity> {
370 let resolved = resolve_destination(path)?;
371 let file = file_key(&resolved)?;
372 Ok(FileIdentity {
373 file,
374 path: resolved,
375 })
376 }
377
378 fn same_file(&self, other: &FileIdentity) -> bool {
381 match (self.file, other.file) {
382 (Some(a), Some(b)) => a == b,
383 _ => self.path == other.path,
384 }
385 }
386}
387
388fn same_file(a: &str, b: &str) -> bool {
391 match (
392 FileIdentity::try_of(Path::new(a)),
393 FileIdentity::try_of(Path::new(b)),
394 ) {
395 (Ok(a), Ok(b)) => a.same_file(&b),
396 _ => true,
397 }
398}
399
400#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
401enum DestinationKey {
402 File(u64, u128),
404 Path(PathBuf),
406}
407
408struct DestinationLocks {
412 held: Mutex<BTreeSet<DestinationKey>>,
413 released: Condvar,
414}
415
416static DESTINATION_LOCKS: DestinationLocks = DestinationLocks {
417 held: Mutex::new(BTreeSet::new()),
418 released: Condvar::new(),
419};
420
421struct DestinationGuard {
423 keys: Vec<DestinationKey>,
424}
425
426impl DestinationGuard {
427 fn acquire(identity: &FileIdentity) -> DestinationGuard {
428 let mut keys = vec![DestinationKey::Path(identity.path.clone())];
429 if let Some((device, file)) = identity.file {
430 keys.push(DestinationKey::File(device, file));
431 }
432 let mut held = DESTINATION_LOCKS
433 .held
434 .lock()
435 .unwrap_or_else(PoisonError::into_inner);
436 while keys.iter().any(|key| held.contains(key)) {
437 held = DESTINATION_LOCKS
438 .released
439 .wait(held)
440 .unwrap_or_else(PoisonError::into_inner);
441 }
442 held.extend(keys.iter().cloned());
443 DestinationGuard { keys }
444 }
445}
446
447impl Drop for DestinationGuard {
448 fn drop(&mut self) {
449 let mut held = DESTINATION_LOCKS
450 .held
451 .lock()
452 .unwrap_or_else(PoisonError::into_inner);
453 for key in &self.keys {
454 held.remove(key);
455 }
456 drop(held);
457 DESTINATION_LOCKS.released.notify_all();
458 }
459}
460
461pub fn commit_pdf_save(
469 store: &PdfSaveTokenStore,
470 token: &str,
471 target: Option<&Path>,
472) -> Result<PdfCommitResult, CommitError> {
473 commit_with(store, token, target, |source| {
474 fs::read(source).map_err(|_| PdfEditError::WriteConflict)
475 })
476}
477
478fn commit_with<R>(
481 store: &PdfSaveTokenStore,
482 token: &str,
483 target: Option<&Path>,
484 read_source: R,
485) -> Result<PdfCommitResult, CommitError>
486where
487 R: FnOnce(&str) -> Result<Vec<u8>, PdfEditError>,
488{
489 let destination: PathBuf = match target {
492 Some(path) => path.to_path_buf(),
493 None => PathBuf::from(
494 store
495 .source_path_of(token)
496 .ok_or(PdfEditError::WriteConflict)?,
497 ),
498 };
499 let before = FileIdentity::try_of(&destination)?;
502 let _destination = DestinationGuard::acquire(&before);
503 let identity = FileIdentity::try_of(&destination)?;
504 if identity.path != before.path {
505 return Err(PdfEditError::WriteConflict.into());
506 }
507
508 let (_source, bytes, result) = resolve_commit_with(
509 store,
510 token,
511 target.map(|p| p.to_string_lossy().into_owned()),
512 same_file,
513 read_source,
514 )?;
515 let published = publish(&identity.path, &bytes);
517
518 if matches!(published, Ok(()) | Err(CommitError::Replaced(_))) {
528 let written = FileIdentity::try_of(&identity.path).ok();
529 invalidate_aliases(store, &identity, written.as_ref());
530 }
531 published.map(|()| result)
532}
533
534fn invalidate_aliases(
538 store: &PdfSaveTokenStore,
539 replaced: &FileIdentity,
540 written: Option<&FileIdentity>,
541) {
542 for staged in store.source_paths() {
543 let same = match FileIdentity::try_of(Path::new(&staged)) {
544 Ok(staged_identity) => {
545 staged_identity.same_file(replaced)
546 || written.is_none_or(|written| staged_identity.same_file(written))
547 }
548 Err(_) => true,
549 };
550 if same {
551 store.invalidate_source(&staged);
552 }
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use std::sync::Arc;
560 use std::time::Duration;
561
562 use crate::model::document_sha256;
563 use crate::save::stage_prepared_save;
564 use crate::test_fixtures::build_one_page_pdf_bytes as build_pdf;
565
566 fn identity_of(path: &Path) -> FileIdentity {
567 FileIdentity::try_of(path).expect("identity")
568 }
569
570 fn scratch(tag: &str) -> PathBuf {
571 let dir =
572 std::env::temp_dir().join(format!("rightkit-pdf-commit-{tag}-{}", std::process::id()));
573 let _ = fs::remove_dir_all(&dir);
574 fs::create_dir_all(&dir).expect("scratch dir");
575 dir
576 }
577
578 fn temps_in(dir: &Path) -> Vec<String> {
580 fs::read_dir(dir)
581 .expect("dir")
582 .filter_map(Result::ok)
583 .map(|e| e.file_name().to_string_lossy().into_owned())
584 .filter(|name| name.contains(".rktmp-"))
585 .collect()
586 }
587
588 struct Rendezvous {
591 arrived: Mutex<usize>,
592 all: Condvar,
593 }
594
595 impl Rendezvous {
596 fn meet(&self, parties: usize, patience: Duration) {
597 let mut arrived = self.arrived.lock().expect("rendezvous");
598 *arrived += 1;
599 self.all.notify_all();
600 let _ = self
601 .all
602 .wait_timeout_while(arrived, patience, |arrived| *arrived < parties)
603 .expect("rendezvous wait");
604 }
605 }
606
607 #[test]
608 fn finding5_two_commits_staged_against_one_source_cannot_both_succeed() {
609 let dir = scratch("race");
610 let target = dir.join("memo.pdf");
611 let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
612 fs::write(&target, &source).expect("write source");
613 let sha = document_sha256(&source);
614 let candidates = [
615 build_pdf(b"BT /F1 12 Tf 72 700 Td (First) Tj ET"),
616 build_pdf(b"BT /F1 12 Tf 72 700 Td (Second) Tj ET"),
617 ];
618
619 let store = PdfSaveTokenStore::default();
620 let tokens: Vec<String> = candidates
621 .iter()
622 .map(|candidate| {
623 stage_prepared_save(
624 &store,
625 target.to_str().expect("utf-8 path"),
626 &sha,
627 &sha,
628 candidate.clone(),
629 vec![1],
630 vec![],
631 )
632 .expect("stages")
633 .token
634 })
635 .collect();
636
637 let meeting = Arc::new(Rendezvous {
640 arrived: Mutex::new(0),
641 all: Condvar::new(),
642 });
643 let start = Arc::new(std::sync::Barrier::new(2));
644 let results: Vec<Result<PdfCommitResult, CommitError>> = std::thread::scope(|scope| {
645 let mut handles = Vec::new();
646 for token in &tokens {
647 let (store, meeting, start) = (&store, Arc::clone(&meeting), Arc::clone(&start));
648 handles.push(scope.spawn(move || {
649 start.wait();
650 commit_with(store, token, None, |path| {
651 let snapshot = fs::read(path).map_err(|_| PdfEditError::WriteConflict);
655 meeting.meet(2, Duration::from_millis(1500));
656 snapshot
657 })
658 }));
659 }
660 let mut finished = Vec::new();
661 for handle in handles {
662 finished.push(handle.join().expect("commit thread"));
663 }
664 finished
665 });
666 let winners: Vec<usize> = results
667 .iter()
668 .enumerate()
669 .filter(|(_, result)| result.is_ok())
670 .map(|(index, _)| index)
671 .collect();
672 assert_eq!(winners.len(), 1, "exactly one commit may win: {results:?}");
673 let loser = &results[1 - winners[0]];
674 assert!(
675 matches!(loser, Err(CommitError::Edit(PdfEditError::WriteConflict))),
676 "{loser:?}"
677 );
678 assert_eq!(
679 fs::read(&target).expect("read back"),
680 candidates[winners[0]],
681 "the file holds exactly the winner's candidate"
682 );
683 let _ = fs::remove_dir_all(&dir);
684 }
685
686 #[test]
687 fn a_destination_guard_excludes_every_alias_of_the_same_file() {
688 let dir = scratch("guard");
689 let file = dir.join("a.pdf");
690 fs::write(&file, b"x").expect("file");
691 let alias = dir.join(".").join("a.pdf");
692
693 let first = DestinationGuard::acquire(&identity_of(&file));
694 let second_got_it = Arc::new(std::sync::atomic::AtomicBool::new(false));
695 let flag = Arc::clone(&second_got_it);
696 let waiting = std::thread::spawn(move || {
697 let _held = DestinationGuard::acquire(&identity_of(&alias));
698 flag.store(true, std::sync::atomic::Ordering::SeqCst);
699 });
700 std::thread::sleep(Duration::from_millis(200));
701 assert!(
702 !second_got_it.load(std::sync::atomic::Ordering::SeqCst),
703 "an alias must wait for the first holder"
704 );
705 drop(first);
706 waiting.join().expect("second holder");
707 assert!(second_got_it.load(std::sync::atomic::Ordering::SeqCst));
708
709 let other = dir.join("b.pdf");
711 fs::write(&other, b"y").expect("other");
712 let _a = DestinationGuard::acquire(&identity_of(&file));
713 let _b = DestinationGuard::acquire(&identity_of(&other));
714 let _ = fs::remove_dir_all(&dir);
715 }
716
717 #[test]
718 fn finding6_path_aliases_of_one_file_have_the_same_identity() {
719 let dir = scratch("identity");
720 fs::create_dir_all(dir.join("sub")).expect("sub");
721 let file = dir.join("memo.pdf");
722 fs::write(&file, b"x").expect("file");
723 let other = dir.join("other.pdf");
724 fs::write(&other, b"y").expect("other");
725
726 let canonical = identity_of(&file);
727 for alias in [
728 dir.join(".").join("memo.pdf"),
729 dir.join("sub").join("..").join("memo.pdf"),
730 ] {
731 assert!(
732 canonical.same_file(&identity_of(&alias)),
733 "{}",
734 alias.display()
735 );
736 }
737 assert!(same_file(
738 file.to_str().expect("utf-8"),
739 dir.join(".").join("memo.pdf").to_str().expect("utf-8")
740 ));
741 assert!(!canonical.same_file(&identity_of(&other)));
742
743 let fresh = dir.join("sub").join("..").join("new.pdf");
745 assert!(identity_of(&fresh).same_file(&identity_of(&dir.join("new.pdf"))));
746 assert!(!identity_of(&fresh).same_file(&identity_of(&dir.join("old.pdf"))));
747
748 #[cfg(unix)]
749 {
750 let linked = dir.join("linked.pdf");
751 fs::hard_link(&file, &linked).expect("hard link");
752 assert!(canonical.same_file(&identity_of(&linked)));
753 }
754 let _ = fs::remove_dir_all(&dir);
755 }
756
757 fn stage_all(
759 store: &PdfSaveTokenStore,
760 source_path: &Path,
761 source: &[u8],
762 candidates: &[Vec<u8>],
763 ) -> Vec<String> {
764 let sha = document_sha256(source);
765 candidates
766 .iter()
767 .map(|candidate| {
768 stage_prepared_save(
769 store,
770 source_path.to_str().expect("utf-8 path"),
771 &sha,
772 &sha,
773 candidate.clone(),
774 vec![1],
775 vec![],
776 )
777 .expect("stages")
778 .token
779 })
780 .collect()
781 }
782
783 #[test]
784 #[cfg(unix)]
785 fn finding2_a_symlink_destination_is_resolved_once_and_the_sibling_token_dies() {
786 let dir = scratch("symlink");
787 let real = dir.join("A.pdf");
788 let link = dir.join("B.pdf");
789 let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
790 fs::write(&real, &source).expect("write source");
791 std::os::unix::fs::symlink(&real, &link).expect("symlink");
792 let candidates = [
793 build_pdf(b"BT /F1 12 Tf 72 700 Td (First) Tj ET"),
794 build_pdf(b"BT /F1 12 Tf 72 700 Td (Second) Tj ET"),
795 ];
796 let store = PdfSaveTokenStore::default();
797 let tokens = stage_all(&store, &real, &source, &candidates);
798
799 commit_pdf_save(&store, &tokens[0], Some(&link)).expect("first commit");
800 assert!(
801 fs::symlink_metadata(&link)
802 .expect("link metadata")
803 .file_type()
804 .is_symlink(),
805 "the commit must replace the file the link points at, not the link"
806 );
807 assert_eq!(fs::read(&real).expect("read"), candidates[0]);
808
809 let second = commit_pdf_save(&store, &tokens[1], Some(&link));
810 assert!(
811 matches!(second, Err(CommitError::Edit(PdfEditError::WriteConflict))),
812 "{second:?}"
813 );
814 assert_eq!(fs::read(&real).expect("read"), candidates[0]);
815 let _ = fs::remove_dir_all(&dir);
816 }
817
818 #[test]
819 #[cfg(unix)]
820 fn finding2_symlink_and_target_share_one_resolved_identity_and_a_dangling_link_is_refused() {
821 let dir = scratch("symlink-identity");
822 let real = dir.join("A.pdf");
823 fs::write(&real, b"x").expect("file");
824 let link = dir.join("B.pdf");
825 std::os::unix::fs::symlink(&real, &link).expect("symlink");
826 assert_eq!(identity_of(&link).path, identity_of(&real).path);
827 assert!(identity_of(&link).same_file(&identity_of(&real)));
828
829 let dangling = dir.join("gone.pdf");
830 std::os::unix::fs::symlink(dir.join("missing.pdf"), &dangling).expect("dangling");
831 assert!(FileIdentity::try_of(&dangling).is_err());
832 let _ = fs::remove_dir_all(&dir);
833 }
834
835 #[test]
836 fn finding3_a_hard_link_destination_still_rechecks_the_source() {
837 let dir = scratch("hardlink");
838 let real = dir.join("A.pdf");
839 let linked = dir.join("B.pdf");
840 let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
841 fs::write(&real, &source).expect("write source");
842 fs::hard_link(&real, &linked).expect("hard link");
843 let candidates = [build_pdf(b"BT /F1 12 Tf 72 700 Td (Mine) Tj ET")];
844 let store = PdfSaveTokenStore::default();
845 let tokens = stage_all(&store, &real, &source, &candidates);
846
847 let newer = build_pdf(b"BT /F1 12 Tf 72 700 Td (Newer) Tj ET");
849 fs::write(&real, &newer).expect("external rewrite");
850
851 let outcome = commit_pdf_save(&store, &tokens[0], Some(&linked));
852 assert!(
853 matches!(outcome, Err(CommitError::Edit(PdfEditError::WriteConflict))),
854 "{outcome:?}"
855 );
856 assert_eq!(
857 fs::read(&linked).expect("read"),
858 newer,
859 "newer bytes survive"
860 );
861 let _ = fs::remove_dir_all(&dir);
862 }
863
864 #[test]
865 fn finding3_a_file_has_a_volume_and_file_id_that_hard_links_share() {
866 let dir = scratch("file-key");
867 let real = dir.join("A.pdf");
868 let linked = dir.join("B.pdf");
869 let other = dir.join("C.pdf");
870 fs::write(&real, b"x").expect("file");
871 fs::write(&other, b"x").expect("other");
872 fs::hard_link(&real, &linked).expect("hard link");
873 let a = file_key(&real).expect("key").expect("exists");
874 assert_eq!(a, file_key(&linked).expect("key").expect("exists"));
875 assert_ne!(a, file_key(&other).expect("key").expect("exists"));
876 assert_eq!(file_key(&dir.join("absent.pdf")).expect("key"), None);
877 let _ = fs::remove_dir_all(&dir);
878 }
879
880 #[test]
881 fn finding8_hard_link_sibling_tokens_die_when_the_first_commit_replaces_the_link() {
882 let dir = scratch("sibling");
883 let a = dir.join("A.pdf");
884 let b = dir.join("B.pdf");
885 let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
886 fs::write(&a, &source).expect("write source");
887 fs::hard_link(&a, &b).expect("hard link");
888 let candidates = [
889 build_pdf(b"BT /F1 12 Tf 72 700 Td (First) Tj ET"),
890 build_pdf(b"BT /F1 12 Tf 72 700 Td (Second) Tj ET"),
891 ];
892 let store = PdfSaveTokenStore::default();
893 let tokens = stage_all(&store, &a, &source, &candidates);
895
896 commit_pdf_save(&store, &tokens[0], Some(&b)).expect("first commit to B");
897 assert_eq!(fs::read(&b).expect("read B"), candidates[0]);
898 assert_eq!(
899 fs::read(&a).expect("read A"),
900 source,
901 "A keeps the old bytes"
902 );
903
904 let second = commit_pdf_save(&store, &tokens[1], Some(&b));
907 assert!(
908 matches!(second, Err(CommitError::Edit(PdfEditError::WriteConflict))),
909 "{second:?}"
910 );
911 assert_eq!(fs::read(&b).expect("read B"), candidates[0]);
912 let _ = fs::remove_dir_all(&dir);
913 }
914
915 #[test]
916 fn finding8_a_failed_read_back_still_invalidates_every_staged_token() {
917 let dir = scratch("readback");
918 let a = dir.join("A.pdf");
919 let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
920 fs::write(&a, &source).expect("write source");
921 let store = PdfSaveTokenStore::default();
922 let tokens = stage_all(
923 &store,
924 &a,
925 &source,
926 &[build_pdf(b"BT /F1 12 Tf 72 700 Td (One) Tj ET")],
927 );
928 let replaced = identity_of(&a);
929 invalidate_aliases(&store, &replaced, None);
930 assert_eq!(store.source_path_of(&tokens[0]), None);
931 let _ = fs::remove_dir_all(&dir);
932 }
933
934 #[test]
935 #[cfg(unix)]
936 fn finding7_saving_over_an_existing_file_keeps_its_mode() {
937 use std::os::unix::fs::PermissionsExt;
938 let dir = scratch("mode");
939 for mode in [0o600_u32, 0o640, 0o644, 0o444] {
940 let target = dir.join(format!("m{mode:o}.pdf"));
941 fs::write(&target, b"old").expect("old");
942 fs::set_permissions(&target, fs::Permissions::from_mode(mode)).expect("chmod");
943 write_atomic(&target, b"new").expect("write");
944 assert_eq!(fs::read(&target).expect("read"), b"new");
945 let now = fs::metadata(&target).expect("meta").permissions().mode() & 0o7777;
946 assert_eq!(now, mode, "mode {mode:o} survives the replacement");
947 }
948 assert_eq!(
949 temps_in(&dir),
950 Vec::<String>::new(),
951 "no temp sibling is left"
952 );
953 let _ = fs::remove_dir_all(&dir);
954 }
955
956 #[test]
957 #[cfg(unix)]
958 fn finding7_a_private_pdf_stays_private_through_a_staged_commit_and_through_a_symlink() {
959 use std::os::unix::fs::PermissionsExt;
960 let dir = scratch("mode-commit");
961 let real = dir.join("A.pdf");
962 let link = dir.join("L.pdf");
963 let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
964 fs::write(&real, &source).expect("write source");
965 fs::set_permissions(&real, fs::Permissions::from_mode(0o600)).expect("chmod");
966 std::os::unix::fs::symlink(&real, &link).expect("symlink");
967 let candidate = build_pdf(b"BT /F1 12 Tf 72 700 Td (Edited) Tj ET");
968 let store = PdfSaveTokenStore::default();
969 let tokens = stage_all(&store, &real, &source, std::slice::from_ref(&candidate));
970 commit_pdf_save(&store, &tokens[0], Some(&link)).expect("commit through the link");
971 assert_eq!(fs::read(&real).expect("read"), candidate);
972 assert_eq!(
973 fs::metadata(&real).expect("meta").permissions().mode() & 0o7777,
974 0o600
975 );
976 assert!(fs::symlink_metadata(&link)
977 .expect("link")
978 .file_type()
979 .is_symlink());
980
981 write_atomic(&link, b"direct").expect("direct");
983 assert_eq!(fs::read(&real).expect("read"), b"direct");
984 assert_eq!(
985 fs::metadata(&real).expect("meta").permissions().mode() & 0o7777,
986 0o600
987 );
988 let _ = fs::remove_dir_all(&dir);
989 }
990
991 #[test]
992 fn finding7_a_new_destination_is_created_and_leaves_no_temp() {
993 let dir = scratch("fresh");
994 let target = dir.join("new.pdf");
995 write_atomic(&target, b"fresh").expect("create");
996 assert_eq!(fs::read(&target).expect("read"), b"fresh");
997 assert_eq!(temps_in(&dir), Vec::<String>::new());
998 let _ = fs::remove_dir_all(&dir);
999 }
1000
1001 #[test]
1002 fn a_destination_that_is_a_directory_is_refused_and_left_alone() {
1003 let dir = scratch("dir-destination");
1004 let target = dir.join("folder.pdf");
1005 fs::create_dir(&target).expect("directory");
1006 let error = write_atomic(&target, b"x").expect_err("a directory is not replaced");
1007 assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput, "{error}");
1008 assert!(target.is_dir());
1009 assert_eq!(temps_in(&dir), Vec::<String>::new());
1010 let _ = fs::remove_dir_all(&dir);
1011 }
1012
1013 #[test]
1014 fn finding13_a_publish_that_fails_leaves_the_original_and_no_temp() {
1015 let dir = scratch("failed-publish");
1016 let target = dir.join("memo.pdf");
1017 let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
1018 fs::write(&target, &source).expect("write source");
1019 let candidates = [build_pdf(b"BT /F1 12 Tf 72 700 Td (Edited) Tj ET")];
1020 let store = PdfSaveTokenStore::default();
1021 let tokens = stage_all(&store, &target, &source, &candidates);
1022
1023 let outcome = {
1026 let _fail = rightkit_fs::atomic::testing::on_replace(|_| {
1027 Some(std::io::Error::other("injected replace failure"))
1028 });
1029 commit_pdf_save(&store, &tokens[0], None)
1030 };
1031 assert!(matches!(outcome, Err(CommitError::Io(_))), "{outcome:?}");
1032 assert_eq!(
1033 fs::read(&target).expect("read"),
1034 source,
1035 "the original survives a failed publish"
1036 );
1037 assert_eq!(temps_in(&dir), Vec::<String>::new(), "and no temp is left");
1038
1039 let again = stage_all(&store, &target, &source, &candidates);
1041 commit_pdf_save(&store, &again[0], None).expect("the retry publishes");
1042 assert_eq!(fs::read(&target).expect("read"), candidates[0]);
1043 let _ = fs::remove_dir_all(&dir);
1044 }
1045
1046 #[test]
1047 #[cfg(unix)]
1048 fn finding13_a_failure_after_the_replacement_is_reported_as_replaced_and_still_invalidates() {
1049 let dir = scratch("replaced-then-failed");
1050 let target = dir.join("memo.pdf");
1051 let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
1052 fs::write(&target, &source).expect("write source");
1053 let candidates = [
1054 build_pdf(b"BT /F1 12 Tf 72 700 Td (First) Tj ET"),
1055 build_pdf(b"BT /F1 12 Tf 72 700 Td (Second) Tj ET"),
1056 ];
1057 let store = PdfSaveTokenStore::default();
1058 let tokens = stage_all(&store, &target, &source, &candidates);
1059
1060 let outcome = {
1061 let _fail = rightkit_fs::atomic::testing::fail_dir_sync(std::io::ErrorKind::Other);
1062 commit_pdf_save(&store, &tokens[0], None)
1063 };
1064 assert!(
1065 matches!(outcome, Err(CommitError::Replaced(_))),
1066 "{outcome:?}"
1067 );
1068 assert_eq!(
1069 fs::read(&target).expect("read"),
1070 candidates[0],
1071 "the new bytes are in place"
1072 );
1073 let second = commit_pdf_save(&store, &tokens[1], None);
1074 assert!(
1075 matches!(second, Err(CommitError::Edit(PdfEditError::WriteConflict))),
1076 "{second:?}"
1077 );
1078 assert_eq!(fs::read(&target).expect("read"), candidates[0]);
1079 let _ = fs::remove_dir_all(&dir);
1080 }
1081
1082 #[test]
1083 #[cfg(unix)]
1084 fn finding7_a_replacement_with_other_permission_bits_is_not_the_same_protection() {
1085 use std::os::unix::fs::PermissionsExt;
1086 let dir = scratch("protection");
1087 let (old, new) = (dir.join("old.pdf"), dir.join("new.pdf"));
1088 fs::write(&old, b"x").expect("old");
1089 fs::write(&new, b"y").expect("new");
1090 fs::set_permissions(&old, fs::Permissions::from_mode(0o640)).expect("chmod");
1091 fs::set_permissions(&new, fs::Permissions::from_mode(0o600)).expect("chmod");
1092 let (old_meta, new_meta) = (
1093 fs::metadata(&old).expect("meta"),
1094 fs::metadata(&new).expect("meta"),
1095 );
1096 assert_eq!(
1097 same_protection(&old_meta, &new_meta),
1098 Err(Unpreserved::Mode)
1099 );
1100 fs::set_permissions(&new, fs::Permissions::from_mode(0o640)).expect("chmod");
1101 let new_meta = fs::metadata(&new).expect("meta");
1102 assert_eq!(same_protection(&old_meta, &new_meta), Ok(()));
1103 let _ = fs::remove_dir_all(&dir);
1104 }
1105
1106 #[test]
1107 #[cfg(target_os = "macos")]
1108 fn finding7_an_owner_only_file_with_an_acl_is_refused_and_left_alone() {
1109 use std::os::unix::fs::PermissionsExt;
1110 let dir = scratch("acl");
1111 let target = dir.join("private.pdf");
1112 fs::write(&target, b"old").expect("old");
1113 fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("chmod");
1114 let status = std::process::Command::new("/bin/chmod")
1115 .arg("+a")
1116 .arg("everyone allow read")
1117 .arg(&target)
1118 .status()
1119 .expect("chmod +a runs");
1120 assert!(status.success(), "chmod +a failed");
1121 assert!(
1122 !rightkit_fs::private::is_owner_only(&target).expect("reads the ACL"),
1123 "precondition: the volume holds ACLs and rightkit-fs sees this one"
1124 );
1125
1126 let error = write_atomic(&target, b"new").expect_err("refused");
1127 let refusal = error
1128 .get_ref()
1129 .and_then(|inner| inner.downcast_ref::<CommitError>());
1130 assert!(
1131 matches!(refusal, Some(CommitError::Refused(Unpreserved::Acl))),
1132 "{error:?}"
1133 );
1134 assert_eq!(fs::read(&target).expect("read"), b"old");
1135 assert_eq!(temps_in(&dir), Vec::<String>::new());
1136 let _ = fs::remove_dir_all(&dir);
1137 }
1138
1139 #[test]
1140 #[cfg(windows)]
1141 #[allow(clippy::permissions_set_readonly_false)]
1142 fn finding7_a_read_only_file_is_refused_and_left_alone() {
1143 let dir = scratch("readonly");
1144 let target = dir.join("locked.pdf");
1145 fs::write(&target, b"old").expect("old");
1146 let mut permissions = fs::metadata(&target).expect("meta").permissions();
1147 permissions.set_readonly(true);
1148 fs::set_permissions(&target, permissions).expect("read-only");
1149
1150 let error = write_atomic(&target, b"new").expect_err("refused");
1151 let refusal = error
1152 .get_ref()
1153 .and_then(|inner| inner.downcast_ref::<CommitError>());
1154 assert!(
1155 matches!(refusal, Some(CommitError::Refused(Unpreserved::ReadOnly))),
1156 "{error:?}"
1157 );
1158 assert_eq!(fs::read(&target).expect("read"), b"old");
1159 assert_eq!(temps_in(&dir), Vec::<String>::new());
1160
1161 let mut permissions = fs::metadata(&target).expect("meta").permissions();
1162 permissions.set_readonly(false);
1163 fs::set_permissions(&target, permissions).expect("writable again");
1164 let _ = fs::remove_dir_all(&dir);
1165 }
1166}