logmv/lib.rs
1//! Logged atomic file move and trash with an append-only JSON-Lines audit trail.
2//!
3//! `logmv` renames a file, or moves it to a trash directory, and records the
4//! operation as one compact JSON line per action so every change is auditable.
5//! Moves are atomic renames only (never a copy fallback), never overwrite an
6//! existing destination, and trash never unlinks. See [`run`] for the
7//! orchestration and [`Op`] for the operations it performs.
8//!
9//! # Platform support
10//!
11//! logmv supports only macOS and Linux. The crate bakes in macOS/Linux-specific
12//! assumptions: `EXDEV` == raw OS error 18, `'/'` path separators (see
13//! `ends_with_sep`), and the `~/.Trash` move model (resolved in `main.rs`). Other
14//! platforms differ on all three, so a non-macOS/Linux build is rejected at
15//! compile time via `compile_error!` rather than silently misbehaving. The guard
16//! lives here; the binary depends on the library, so it is guarded transitively.
17//!
18//! One further Linux-only divergence affects log fidelity, not the move itself.
19//! The rename is byte-faithful: it uses the real `OsStr` bytes (see
20//! `path_to_cstring`), so a non-UTF-8 filename is moved correctly. The log,
21//! however, records `src` and `dst` via `to_string_lossy`, so on Linux each
22//! invalid UTF-8 byte is written as U+FFFD (the Unicode replacement character);
23//! JSON strings must be valid Unicode, so the raw bytes cannot be stored
24//! verbatim. A log line whose `src` or `dst` contains U+FFFD is therefore not a
25//! byte-exact record and is not reliably reversible. macOS (APFS/HFS+)
26//! enforces UTF-8, so this case is unreachable there.
27
28#![warn(missing_docs)]
29
30#[cfg(not(any(target_os = "macos", target_os = "linux")))]
31compile_error!("logmv supports only macOS and Linux");
32
33use std::ffi::CString;
34use std::ffi::OsStr;
35use std::ffi::c_char;
36use std::ffi::c_int;
37use std::ffi::c_uint;
38use std::fs;
39use std::fs::OpenOptions;
40use std::io;
41use std::io::Write;
42use std::path::Path;
43use std::path::PathBuf;
44
45use chrono::DateTime;
46use chrono::FixedOffset;
47use chrono::SecondsFormat;
48use serde_json::Map;
49use serde_json::Value;
50
51/// EXDEV (cross-device link) is raw os error 18 on macOS and Linux.
52const EXDEV: i32 = 18;
53
54/// EEXIST (destination already exists) is raw os error 17 on macOS and Linux.
55const EEXIST: i32 = 17;
56
57/// The four keys `logmv` stamps itself; metadata pairs may never use them (AC13).
58const CANONICAL_KEYS: [&str; 4] = ["ts", "act", "src", "dst"];
59
60/// A file-system operation for logmv to perform.
61pub enum Op {
62 /// Move `src` to `dst` via an atomic rename, then log a `move` line.
63 Move {
64 /// Source path to move (canonicalized to absolute when logged).
65 src: PathBuf,
66 /// Destination path; an existing dir (or trailing `/`) means move *into* it.
67 dst: PathBuf,
68 },
69 /// Move `path` into `trash_dir`, disambiguating on name collision, then log a `trash` line.
70 Trash {
71 /// Path to send to the trash.
72 path: PathBuf,
73 /// Trash directory to move `path` into (e.g. `~/.Trash`).
74 trash_dir: PathBuf,
75 },
76}
77
78/// Typed errors for logmv operations.
79#[derive(Debug, thiserror::Error)]
80pub enum Error {
81 /// The resolved destination already exists; the move was refused (never overwrite).
82 #[error("destination already exists: {0}")]
83 DestinationExists(PathBuf),
84 /// The rename crossed filesystems (`EXDEV`); logmv never falls back to a copy.
85 #[error("cross-volume rename not supported (EXDEV)")]
86 CrossVolume,
87 /// The `rename` syscall failed for a reason other than a cross-volume move.
88 #[error("rename failed: {0}")]
89 Rename(#[source] io::Error),
90 /// The move succeeded but the log append failed: filesystem and log may have drifted.
91 #[error("move succeeded but log append failed: filesystem and log may have drifted: {0}")]
92 DriftAfterMove(#[source] io::Error),
93 /// A `--mkdir` directory creation failed; any partially created directories are unlogged.
94 #[error("directory creation failed; any partially created directories are unlogged: {0}")]
95 MkdirCreate(#[source] io::Error),
96 /// A `--mkdir` directory was created but its log append failed: filesystem and log may have drifted.
97 #[error("directories created but log append failed: filesystem and log may have drifted: {0}")]
98 DriftAfterMkdir(#[source] io::Error),
99 /// A `--rmdir` directory removal's log append failed: filesystem and log may have drifted.
100 #[error(
101 "directory removed but its log append failed: filesystem and log may have drifted: {0}"
102 )]
103 DriftAfterRmdir(#[source] io::Error),
104 /// Resolving a path to its canonical absolute form failed.
105 #[error("canonicalize failed: {0}")]
106 Canonicalize(#[source] io::Error),
107 /// A metadata key collided with a canonical key (`ts`/`act`/`src`/`dst`).
108 #[error("metadata key collides with canonical key: {0}")]
109 MetadataKeyCollision(String),
110}
111
112/// Assemble one compact JSON-Lines entry from the given fields.
113///
114/// `ts` is injected (not read from a clock) so this function is pure and
115/// deterministically testable. `src`/`dst` must already be absolute strings.
116/// For trash ops, `dst` is the canonical landing path under the trash dir
117/// (the disambiguated target), just like move.
118/// Refuses any pair whose key is a canonical key (ts/act/src/dst) via
119/// `Error::MetadataKeyCollision` (AC13). Canonical four are written first,
120/// then pairs in the given order, all values as JSON strings.
121fn build_entry(
122 ts: DateTime<FixedOffset>,
123 act: &str,
124 src: &str,
125 dst: &str,
126 pairs: &[(&str, &str)],
127) -> Result<String, Error> {
128 // Refuse before assembling anything: a colliding key must yield no line
129 // (and, via `run`, no move and no log).
130 if let Some(key) = first_colliding_key(pairs) {
131 return Err(Error::MetadataKeyCollision(key.to_string()));
132 }
133
134 let mut map = Map::new();
135 map.insert(
136 "ts".to_string(),
137 Value::String(ts.to_rfc3339_opts(SecondsFormat::Secs, false)),
138 );
139 map.insert("act".to_string(), Value::String(act.to_string()));
140 map.insert("src".to_string(), Value::String(src.to_string()));
141 map.insert("dst".to_string(), Value::String(dst.to_string()));
142 // Pairs follow the canonical four in given order (serde_json `preserve_order`),
143 // every value a JSON string; serde owns all key/value escaping (AC6).
144 for (k, v) in pairs {
145 map.insert((*k).to_string(), Value::String((*v).to_string()));
146 }
147
148 Ok(Value::Object(map).to_string())
149}
150
151/// Return the first metadata key that collides with a canonical key, if any.
152/// Single source of truth for the collision guard: `build_entry` runs it before
153/// assembling a line, and `run` runs it early so a bad pair aborts before any
154/// `--mkdir` mutation (AC11).
155fn first_colliding_key<'a>(pairs: &'a [(&str, &str)]) -> Option<&'a str> {
156 pairs
157 .iter()
158 .find(|(k, _)| CANONICAL_KEYS.contains(k))
159 .map(|(k, _)| *k)
160}
161
162/// Map an `io::Error` from `fs::rename` to a typed `Error`.
163///
164/// EXDEV (raw os error 18) → `Error::CrossVolume`.
165/// Anything else → `Error::Rename`.
166fn classify_rename_err(err: io::Error) -> Error {
167 if err.raw_os_error() == Some(EXDEV) {
168 Error::CrossVolume
169 } else {
170 Error::Rename(err)
171 }
172}
173
174/// `AT_FDCWD`: resolve a relative `renameat2` path against the current working
175/// directory, matching the relative-path semantics `fs::rename` has today.
176#[cfg(target_os = "linux")]
177const AT_FDCWD: c_int = -100;
178
179/// `RENAME_NOREPLACE`: fail with `EEXIST` rather than clobbering an existing
180/// destination entry (name-level; a symlink is not followed).
181#[cfg(target_os = "linux")]
182const RENAME_NOREPLACE: c_uint = 1;
183
184/// `RENAME_EXCL`: macOS exclusive-create flag from `<sys/stdio.h>`
185/// (`RENAME_SECLUDE 0x1`, `RENAME_SWAP 0x2`, `RENAME_EXCL 0x4`); fail with
186/// `EEXIST` rather than clobbering an existing destination entry.
187#[cfg(target_os = "macos")]
188const RENAME_EXCL: c_uint = 0x0000_0004;
189
190/// Convert a path to a NUL-terminated C string for the `renameat2`/`renamex_np`
191/// FFI, via raw bytes (never a lossy `String`, so non-UTF-8 paths pass through
192/// unchanged). An interior NUL yields a clean `InvalidInput` error rather than
193/// an `unwrap`/`expect` on the write path.
194fn path_to_cstring(p: &Path) -> io::Result<CString> {
195 use std::os::unix::ffi::OsStrExt;
196 CString::new(p.as_os_str().as_bytes())
197 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
198}
199
200/// Atomically rename `from` to `to`, refusing to clobber an existing destination
201/// entry (name-level; a symlink is not followed).
202///
203/// Returns `Ok(())` on success, or `Err(io::Error::last_os_error())` carrying the
204/// raw `errno` so callers can test `raw_os_error() == Some(EEXIST)` and still route
205/// `EXDEV` and others through [`classify_rename_err`]. Classifies nothing itself,
206/// mirroring today's `fs::rename(...).map_err(classify_rename_err)` split of
207/// mechanism from policy.
208#[cfg(target_os = "linux")]
209fn rename_noclobber(from: &Path, to: &Path) -> io::Result<()> {
210 // NOTE: libc renameat2 wrapper (glibc >=2.28); drop to a raw syscall() only
211 // if a target without the wrapper (e.g. an old musl) is ever added to CI.
212 unsafe extern "C" {
213 fn renameat2(
214 olddirfd: c_int,
215 oldpath: *const c_char,
216 newdirfd: c_int,
217 newpath: *const c_char,
218 flags: c_uint,
219 ) -> c_int;
220 }
221
222 let from_c = path_to_cstring(from)?;
223 let to_c = path_to_cstring(to)?;
224
225 // SAFETY:
226 // - `from_c`/`to_c` are `CString` locals that outlive the call; `.as_ptr()`
227 // yields non-null pointers to valid, readable, NUL-terminated bytes.
228 // - `renameat2` only reads (never retains) the two `*const c_char` pointers;
229 // no Rust aliasing/mutability invariant is exposed (only `*const` passed).
230 // - `AT_FDCWD` and `RENAME_NOREPLACE` are the documented kernel sentinel/flag.
231 // - the return value is checked immediately and `errno` is read via
232 // `io::Error::last_os_error()` with no intervening libc call between.
233 let rc = unsafe {
234 renameat2(
235 AT_FDCWD,
236 from_c.as_ptr(),
237 AT_FDCWD,
238 to_c.as_ptr(),
239 RENAME_NOREPLACE,
240 )
241 };
242 if rc < 0 {
243 return Err(io::Error::last_os_error());
244 }
245 Ok(())
246}
247
248/// Atomically rename `from` to `to`, refusing to clobber an existing destination
249/// entry (name-level; a symlink is not followed).
250///
251/// Returns `Ok(())` on success, or `Err(io::Error::last_os_error())` carrying the
252/// raw `errno` so callers can test `raw_os_error() == Some(EEXIST)` and still route
253/// `EXDEV` and others through [`classify_rename_err`]. Classifies nothing itself,
254/// mirroring today's `fs::rename(...).map_err(classify_rename_err)` split of
255/// mechanism from policy.
256#[cfg(target_os = "macos")]
257fn rename_noclobber(from: &Path, to: &Path) -> io::Result<()> {
258 unsafe extern "C" {
259 fn renamex_np(from: *const c_char, to: *const c_char, flags: c_uint) -> c_int;
260 }
261
262 let from_c = path_to_cstring(from)?;
263 let to_c = path_to_cstring(to)?;
264
265 // SAFETY:
266 // - `from_c`/`to_c` are `CString` locals that outlive the call; `.as_ptr()`
267 // yields non-null pointers to valid, readable, NUL-terminated bytes.
268 // - `renamex_np` only reads (never retains) the two `*const c_char` pointers;
269 // no Rust aliasing/mutability invariant is exposed (only `*const` passed).
270 // - `RENAME_EXCL` is the documented exclusive-create flag from `<sys/stdio.h>`.
271 // - the return value is checked immediately and `errno` is read via
272 // `io::Error::last_os_error()` with no intervening libc call between.
273 let rc = unsafe { renamex_np(from_c.as_ptr(), to_c.as_ptr(), RENAME_EXCL) };
274 if rc < 0 {
275 return Err(io::Error::last_os_error());
276 }
277 Ok(())
278}
279
280/// Canonicalize a destination path that must NOT yet exist: canonicalize the
281/// parent directory (which does exist) and rejoin the file name.
282fn canonicalize_new(dst: &Path) -> Result<PathBuf, Error> {
283 let file_name = dst.file_name().ok_or_else(|| {
284 Error::Canonicalize(io::Error::new(
285 io::ErrorKind::InvalidInput,
286 "destination has no file name",
287 ))
288 })?;
289 let parent = match dst.parent() {
290 Some(p) if !p.as_os_str().is_empty() => p,
291 _ => Path::new("."),
292 };
293 let parent_abs = fs::canonicalize(parent).map_err(Error::Canonicalize)?;
294 Ok(parent_abs.join(file_name))
295}
296
297/// Atomically move `path` into `trash_dir`, never clobbering an existing entry:
298/// try `name` as-is, then `<stem>-<n>[.<ext>]` for n = 1,2,3,…, bumping `n` only
299/// on `EEXIST` and returning the winning path on the first successful rename.
300/// Any other errno is classified via [`classify_rename_err`]. The disambiguation
301/// is driven by the atomic rename result (not a pre-scan), so the returned path is
302/// exactly the candidate that won the rename.
303fn rename_into_trash(path: &Path, trash_dir: &Path, name: &OsStr) -> Result<PathBuf, Error> {
304 let first = trash_dir.join(name);
305 match rename_noclobber(path, &first) {
306 Ok(()) => return Ok(first),
307 Err(e) if e.raw_os_error() == Some(EEXIST) => {}
308 Err(e) => return Err(classify_rename_err(e)),
309 }
310
311 let as_path = Path::new(name);
312 let stem = as_path
313 .file_stem()
314 .unwrap_or(name)
315 .to_string_lossy()
316 .into_owned();
317 let ext = as_path
318 .extension()
319 .map(|e| e.to_string_lossy().into_owned());
320
321 let mut n = 1u32;
322 loop {
323 let candidate_name = match &ext {
324 Some(ext) => format!("{stem}-{n}.{ext}"),
325 None => format!("{stem}-{n}"),
326 };
327 let candidate = trash_dir.join(candidate_name);
328 match rename_noclobber(path, &candidate) {
329 Ok(()) => return Ok(candidate),
330 Err(e) if e.raw_os_error() == Some(EEXIST) => {}
331 Err(e) => return Err(classify_rename_err(e)),
332 }
333 n += 1;
334 }
335}
336
337/// Write `line` plus one trailing newline to `w` in a single `write` call, so a
338/// log entry can never be split across two syscalls (which two concurrent runs
339/// could interleave). Generic over `Write` so it is unit-testable.
340fn write_log_line<W: Write>(w: &mut W, line: &str) -> io::Result<()> {
341 w.write_all(format!("{line}\n").as_bytes())
342}
343
344/// Append exactly one line (with trailing newline) to `log`, creating it if absent,
345/// then flush it to disk so the record is at least as durable as the event it logs.
346fn append_log(log: &Path, line: &str) -> io::Result<()> {
347 let mut file = OpenOptions::new().create(true).append(true).open(log)?;
348 write_log_line(&mut file, line)?;
349 // NOTE: unconditional sync_data; add --no-sync only if a batch caller measurably needs it.
350 file.sync_data()
351}
352
353/// Does the path end with a directory separator (explicit directory intent)?
354// NOTE: macOS/Linux `'/'` only, consistent with the hardcoded EXDEV const.
355fn ends_with_sep(p: &Path) -> bool {
356 p.to_string_lossy().ends_with('/')
357}
358
359/// Resolve the final move target. When `dst` is an existing directory (or ends
360/// with a separator, signalling directory intent), move `src` *into* it keeping
361/// its basename: `dst/basename(src)`. Otherwise `dst` is the full target path
362/// (today's behavior).
363fn resolve_move_target(src: &Path, dst: &Path) -> PathBuf {
364 match (ends_with_sep(dst) || dst.is_dir(), src.file_name()) {
365 (true, Some(name)) => dst.join(name),
366 _ => dst.to_path_buf(),
367 }
368}
369
370/// `--mkdir`: create the move destination's missing parent chain (`mkdir -p`) and
371/// append one `mkdir` line per directory actually created, parent → child, before
372/// the move line. A fully-present chain creates nothing and logs nothing (AC6).
373/// `mkdir` line carries `src:"-"`, `dst:<created dir>` (Q1 directional sentinel).
374fn mkdir_chain(parent: &Path, log: &Path, ts: DateTime<FixedOffset>) -> Result<(), Error> {
375 // Missing ancestors, collected child → parent then reversed to parent → child.
376 let mut missing: Vec<PathBuf> = Vec::new();
377 let mut cur = Some(parent);
378 while let Some(dir) = cur {
379 if dir.as_os_str().is_empty() || dir.exists() {
380 break;
381 }
382 missing.push(dir.to_path_buf());
383 cur = dir.parent();
384 }
385 if missing.is_empty() {
386 return Ok(());
387 }
388 missing.reverse();
389
390 // create_dir_all is idempotent and never clobbers an existing dir (AC4). A
391 // failure may leave partially-created, unlogged dirs, but the requested chain
392 // is not confirmed present, so this is not post-create drift.
393 fs::create_dir_all(parent).map_err(Error::MkdirCreate)?;
394
395 for dir in &missing {
396 // The dirs now exist but are not yet logged; a canonicalize failure here is
397 // created-but-not-logged drift, not a generic path-resolution failure.
398 let abs = fs::canonicalize(dir).map_err(Error::DriftAfterMkdir)?;
399 let line = build_entry(ts, "mkdir", "-", &abs.to_string_lossy(), &[])?;
400 // Logged only after the dir exists; an append failure here is real drift.
401 append_log(log, &line).map_err(Error::DriftAfterMkdir)?;
402 }
403 Ok(())
404}
405
406/// `--rmdir`: after a successful, logged move/trash, remove the source's now-empty
407/// parent and cascade upward, removing each now-empty ancestor and stopping at the
408/// first non-empty one (`rmdir -p`). Truly-empty-only: a dir holding `.DS_Store`
409/// (or anything) is left in place (AC9). `start` paths come from the canonicalized
410/// `abs_src`, so the cascade walks real (non-symlink) ancestors only.
411/// `rmdir` line carries `src:<removed dir>`, `dst:"-"` (Q1 directional sentinel).
412fn rmdir_cascade(start: Option<&Path>, log: &Path, ts: DateTime<FixedOffset>) -> Result<(), Error> {
413 let mut cur = start.map(Path::to_path_buf);
414 while let Some(dir) = cur {
415 if dir.as_os_str().is_empty() {
416 break;
417 }
418 // Cascade boundary gate: stop at a non-empty (or unreadable) directory.
419 match fs::read_dir(&dir) {
420 Ok(mut entries) => {
421 if entries.next().is_some() {
422 break;
423 }
424 }
425 Err(_) => break,
426 }
427 // remove_dir is the atomic safety guard: it removes only empty dirs.
428 match fs::remove_dir(&dir) {
429 Ok(()) => {
430 let line = build_entry(ts, "rmdir", &dir.to_string_lossy(), "-", &[])?;
431 // Logged only after removal; an append failure here is real drift.
432 append_log(log, &line).map_err(Error::DriftAfterRmdir)?;
433 cur = dir.parent().map(Path::to_path_buf);
434 }
435 // NOTE: a remove_dir failure (race repopulation, EACCES) stops the
436 // cascade silently; rmdir is best-effort cleanup after a logged move and
437 // the dir simply remaining is safe. Upgrade to a stderr warning if
438 // cleanup visibility ever matters.
439 Err(_) => break,
440 }
441 }
442 Ok(())
443}
444
445/// Orchestrate: resolve final dst → never-overwrite + pair-collision check →
446/// `--mkdir` (create + log each) → atomic rename → log move/trash → `--rmdir`
447/// (remove + log each, cascading).
448///
449/// `log` is the path to the JSON-Lines file to append to (created if absent).
450/// `pairs` are free K/V metadata pairs inserted into the line after the canonical four.
451/// `mkdir`/`rmdir` gate the directory-creation / cascading-removal behaviors.
452///
453/// # Errors
454///
455/// - [`Error::MetadataKeyCollision`] if any pair key is a canonical key
456/// (`ts`/`act`/`src`/`dst`), checked before any mutation, so nothing moves.
457/// - [`Error::DestinationExists`] if the resolved target already exists (never overwrite).
458/// - [`Error::Canonicalize`] if a source or destination path cannot be resolved.
459/// - [`Error::MkdirCreate`] if `--mkdir` fails to create the destination's parent
460/// chain; any partially created directories are unlogged.
461/// - [`Error::CrossVolume`] or [`Error::Rename`] if the atomic rename fails.
462/// - [`Error::DriftAfterMove`], [`Error::DriftAfterMkdir`], or [`Error::DriftAfterRmdir`]
463/// if the rename succeeded but a subsequent log append or directory operation
464/// failed: the filesystem and the log have drifted, and the error is loud.
465///
466/// # Examples
467///
468/// ```no_run
469/// use std::path::{Path, PathBuf};
470/// use logmv::{run, Op};
471///
472/// let op = Op::Move {
473/// src: PathBuf::from("report.txt"),
474/// dst: PathBuf::from("archive.txt"),
475/// };
476/// run(op, Path::new("ops.log"), &[("by", "cc")], false, false)?;
477/// # Ok::<(), logmv::Error>(())
478/// ```
479pub fn run(
480 op: Op,
481 log: &Path,
482 pairs: &[(&str, &str)],
483 mkdir: bool,
484 rmdir: bool,
485) -> Result<(), Error> {
486 let ts = chrono::Local::now().fixed_offset();
487
488 // Early pair-collision check: a colliding key must abort before any --mkdir
489 // mutation and before the move (AC11). build_entry re-checks (idempotent).
490 if let Some(key) = first_colliding_key(pairs) {
491 return Err(Error::MetadataKeyCollision(key.to_string()));
492 }
493
494 // Each arm performs its own atomic no-clobber rename (move: once; trash: in a
495 // retry loop), then yields the line to append and the canonical source path.
496 let (line, abs_src) = match op {
497 Op::Move { src, dst } => {
498 let abs_src = fs::canonicalize(&src).map_err(Error::Canonicalize)?;
499 // Resolve the final target: into an existing / trailing-slash directory.
500 let target = resolve_move_target(&src, &dst);
501 // --mkdir: create the destination's missing parent chain, logging each.
502 // A colliding destination implies its parent exists, so mkdir_chain is a
503 // no-op on collision and a refused move creates no orphan dirs (AC11).
504 if mkdir {
505 if let Some(parent) = target.parent() {
506 mkdir_chain(parent, log, ts)?;
507 }
508 }
509 let abs_dst = canonicalize_new(&target)?;
510 let line = build_entry(
511 ts,
512 "move",
513 &abs_src.to_string_lossy(),
514 &abs_dst.to_string_lossy(),
515 pairs,
516 )?;
517 // Atomic no-clobber rename: EEXIST names the colliding target (never
518 // overwrite); EXDEV and others keep CrossVolume/Rename. No log on refusal.
519 rename_noclobber(&src, &target).map_err(|e| {
520 if e.raw_os_error() == Some(EEXIST) {
521 Error::DestinationExists(target.clone())
522 } else {
523 classify_rename_err(e)
524 }
525 })?;
526 (line, abs_src)
527 }
528 Op::Trash { path, trash_dir } => {
529 let abs_src = fs::canonicalize(&path).map_err(Error::Canonicalize)?;
530 let name = path.file_name().ok_or_else(|| {
531 Error::Canonicalize(io::Error::new(
532 io::ErrorKind::InvalidInput,
533 "trash source has no file name",
534 ))
535 })?;
536 // never-overwrite for trash: disambiguate rather than clobber. The
537 // winning candidate is chosen by the atomic rename, then logged.
538 let winner = rename_into_trash(&path, &trash_dir, name)?;
539 let abs_dst = canonicalize_new(&winner)?;
540 let line = build_entry(
541 ts,
542 "trash",
543 &abs_src.to_string_lossy(),
544 &abs_dst.to_string_lossy(),
545 pairs,
546 )?;
547 (line, abs_src)
548 }
549 };
550
551 // Rename happened: an append failure now is real drift, reported loudly (AC9).
552 append_log(log, &line).map_err(Error::DriftAfterMove)?;
553
554 // --rmdir runs only after a successful move AND its log append (AC11), for both
555 // move and trash; it walks the now-empty source parent upward, cascading.
556 if rmdir {
557 rmdir_cascade(abs_src.parent(), log, ts)?;
558 }
559 Ok(())
560}
561
562// ---------------------------------------------------------------------------
563// Unit tests: T1, T2, T3, T_pairs, T_collide_u, T4
564// ---------------------------------------------------------------------------
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use chrono::TimeZone;
570 use std::io;
571
572 // Shared fixed timestamp for U tests: 2024-01-15T10:30:00+00:00
573 fn fixed_ts() -> DateTime<FixedOffset> {
574 FixedOffset::east_opt(0)
575 .unwrap()
576 .with_ymd_and_hms(2024, 1, 15, 10, 30, 0)
577 .unwrap()
578 }
579
580 // T1: AC1, AC5
581 // build_entry(move, empty pairs) → exact compact JSON line with ONLY the four
582 // canonical keys ts/act/src/dst in that order, act="move", absolute src/dst,
583 // ts ISO-8601 second-precision with offset. Parses as valid JSON.
584 #[test]
585 fn t1_build_entry_move_exact_line() {
586 let ts = fixed_ts();
587 let line = build_entry(ts, "move", "/abs/src/file.txt", "/abs/dst/file.txt", &[])
588 .expect("build_entry must succeed with no pairs");
589
590 // Must parse as valid JSON.
591 let v: serde_json::Value =
592 serde_json::from_str(&line).expect("build_entry output must be valid JSON");
593
594 // Field values.
595 assert_eq!(v["ts"], "2024-01-15T10:30:00+00:00");
596 assert_eq!(v["act"], "move");
597 assert_eq!(v["src"], "/abs/src/file.txt");
598 assert_eq!(v["dst"], "/abs/dst/file.txt");
599
600 // Exact compact line: only 4 keys, in order, no placeholders.
601 let expected = r#"{"ts":"2024-01-15T10:30:00+00:00","act":"move","src":"/abs/src/file.txt","dst":"/abs/dst/file.txt"}"#;
602 assert_eq!(line, expected);
603 }
604
605 // AC2: non-UTF-8 path converted the way run() converts it
606 // (Path::to_string_lossy) is logged with each invalid byte as U+FFFD, and
607 // the line stays valid JSON. Pins that a faithful undo of such a line is
608 // impossible: the raw 0xFF byte is not recoverable from the log.
609 #[cfg(unix)]
610 #[test]
611 fn t_non_utf8_path_logs_replacement_char() {
612 use std::os::unix::ffi::OsStrExt;
613
614 let ts = fixed_ts();
615 let lossy = Path::new(OsStr::from_bytes(b"file\xFF.txt")).to_string_lossy();
616
617 let line = build_entry(ts, "move", &lossy, "/abs/dst", &[])
618 .expect("build_entry must succeed with a lossily-converted src");
619
620 // Must parse as valid JSON.
621 let v: serde_json::Value =
622 serde_json::from_str(&line).expect("build_entry output must be valid JSON");
623
624 // The invalid byte is recorded as U+FFFD, not the original byte.
625 assert_eq!(v["src"], "file\u{FFFD}.txt");
626 }
627
628 // T3: AC6 [no-JSON-corruption invariant]
629 // build_entry with a pair whose KEY AND VALUE each contain quote + backslash +
630 // unicode → line is still valid JSON and both key and value round-trip
631 // byte-exact (serde_json escapes object keys too).
632 #[test]
633 fn t3_build_entry_special_chars_in_key_and_value_round_trips() {
634 let ts = fixed_ts();
635 // Key and value each contain a double-quote, backslash, and unicode snowman.
636 let tricky_key = r#"k"ey\ ☃"#;
637 let tricky_val = r#"say "hello" \ ☃"#;
638
639 let line = build_entry(
640 ts,
641 "move",
642 "/abs/src",
643 "/abs/dst",
644 &[(tricky_key, tricky_val)],
645 )
646 .expect("build_entry must succeed with special-char pair");
647
648 // Must parse as valid JSON (not corrupted).
649 let v: serde_json::Value = serde_json::from_str(&line)
650 .expect("line with special chars in key and value must still be valid JSON");
651
652 // Both key and value must round-trip byte-exact.
653 let obj = v.as_object().unwrap();
654 assert_eq!(
655 obj.get(tricky_key)
656 .and_then(|v| v.as_str())
657 .expect("tricky key must be present in JSON object"),
658 tricky_val
659 );
660 }
661
662 // T_pairs: AC5
663 // build_entry with multiple pairs → pairs appear AFTER the canonical four,
664 // in the given order; every value is a JSON string (even a numeric-looking one).
665 #[test]
666 fn t_pairs_appear_after_canonical_in_order_as_strings() {
667 let ts = fixed_ts();
668 let pairs: &[(&str, &str)] = &[("by", "cc"), ("ac", "p"), ("num", "42")];
669
670 let line = build_entry(ts, "move", "/abs/src", "/abs/dst", pairs)
671 .expect("build_entry must succeed with pairs");
672
673 let v: serde_json::Value =
674 serde_json::from_str(&line).expect("line with pairs must be valid JSON");
675
676 let obj = v.as_object().unwrap();
677 let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).collect();
678
679 // First four must be the canonical keys in order.
680 assert_eq!(
681 &keys[..4],
682 &["ts", "act", "src", "dst"],
683 "first four keys must be canonical in order, got: {keys:?}"
684 );
685 // Pairs follow in the given order.
686 assert_eq!(
687 &keys[4..],
688 &["by", "ac", "num"],
689 "pairs must follow canonical keys in given order, got: {keys:?}"
690 );
691
692 // Every value is a JSON string (not number, bool, etc.)
693 assert_eq!(v["by"], serde_json::Value::String("cc".into()));
694 assert_eq!(v["ac"], serde_json::Value::String("p".into()));
695 // "42" must stay a string, not be coerced to a number.
696 assert_eq!(v["num"], serde_json::Value::String("42".into()));
697 }
698
699 // T_collide_u: AC13 [canonical-unspoofable invariant]
700 // build_entry with a pair key in {ts,act,src,dst} → Err(MetadataKeyCollision),
701 // no line produced.
702 #[test]
703 fn t_collide_u_build_entry_refuses_canonical_key() {
704 let ts = fixed_ts();
705
706 for &canonical in &["ts", "act", "src", "dst"] {
707 let result = build_entry(
708 ts,
709 "move",
710 "/abs/src",
711 "/abs/dst",
712 &[(canonical, "spoofed")],
713 );
714 assert!(
715 matches!(&result, Err(Error::MetadataKeyCollision(k)) if k == canonical),
716 "expected MetadataKeyCollision({canonical}), got: {result:?}"
717 );
718 }
719 }
720
721 // T4: AC8 [atomic-only / no-copy-fallback]
722 // classify_rename_err maps raw os error 18 (EXDEV) to Error::CrossVolume;
723 // any other error maps to Error::Rename.
724 // REUSE: behavior identical to old contract; stays green, no edit.
725 #[test]
726 fn t4_classify_rename_err_exdev_and_other() {
727 // EXDEV is raw os error 18 on macOS and Linux.
728 let exdev = io::Error::from_raw_os_error(18);
729 let result = classify_rename_err(exdev);
730 assert!(
731 matches!(result, Error::CrossVolume),
732 "os error 18 must map to CrossVolume, got: {result:?}"
733 );
734
735 // A different os error (e.g. EACCES = 13) maps to Rename.
736 let other = io::Error::from_raw_os_error(13);
737 let result2 = classify_rename_err(other);
738 assert!(
739 matches!(result2, Error::Rename(_)),
740 "non-EXDEV error must map to Rename, got: {result2:?}"
741 );
742 }
743
744 // T_write_log_line_single_write_call: AC1 + AC3
745 // write_log_line must emit line + newline in a SINGLE Write::write call
746 // (locks out any reintroduction of a two-piece writeln!-style write).
747 #[derive(Default)]
748 struct CountingWriter {
749 writes: usize,
750 buf: Vec<u8>,
751 }
752
753 impl io::Write for CountingWriter {
754 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
755 self.writes += 1;
756 self.buf.extend_from_slice(data);
757 Ok(data.len())
758 }
759
760 fn flush(&mut self) -> io::Result<()> {
761 Ok(())
762 }
763 }
764
765 #[test]
766 fn t_write_log_line_single_write_call() {
767 let mut w = CountingWriter::default();
768 let line = "{\"ts\":\"x\",\"act\":\"move\"}";
769
770 let r = write_log_line(&mut w, line);
771
772 assert!(r.is_ok());
773 assert_eq!(
774 w.writes, 1,
775 "line + newline must be a single write, not two"
776 );
777 assert_eq!(w.buf, format!("{line}\n").into_bytes());
778 }
779}