#![warn(missing_docs)]
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
compile_error!("logmv supports only macOS and Linux");
use std::ffi::CString;
use std::ffi::OsStr;
use std::ffi::c_char;
use std::ffi::c_int;
use std::ffi::c_uint;
use std::fs;
use std::fs::OpenOptions;
use std::io;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use chrono::DateTime;
use chrono::FixedOffset;
use chrono::SecondsFormat;
use serde_json::Map;
use serde_json::Value;
const EXDEV: i32 = 18;
const EEXIST: i32 = 17;
const CANONICAL_KEYS: [&str; 4] = ["ts", "act", "src", "dst"];
pub enum Op {
Move {
src: PathBuf,
dst: PathBuf,
},
Trash {
path: PathBuf,
trash_dir: PathBuf,
},
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("destination already exists: {0}")]
DestinationExists(PathBuf),
#[error("cross-volume rename not supported (EXDEV)")]
CrossVolume,
#[error("rename failed: {0}")]
Rename(#[source] io::Error),
#[error("move succeeded but log append failed: filesystem and log may have drifted: {0}")]
DriftAfterMove(#[source] io::Error),
#[error("directory creation failed; any partially created directories are unlogged: {0}")]
MkdirCreate(#[source] io::Error),
#[error("directories created but log append failed: filesystem and log may have drifted: {0}")]
DriftAfterMkdir(#[source] io::Error),
#[error(
"directory removed but its log append failed: filesystem and log may have drifted: {0}"
)]
DriftAfterRmdir(#[source] io::Error),
#[error("canonicalize failed: {0}")]
Canonicalize(#[source] io::Error),
#[error("metadata key collides with canonical key: {0}")]
MetadataKeyCollision(String),
}
fn build_entry(
ts: DateTime<FixedOffset>,
act: &str,
src: &str,
dst: &str,
pairs: &[(&str, &str)],
) -> Result<String, Error> {
if let Some(key) = first_colliding_key(pairs) {
return Err(Error::MetadataKeyCollision(key.to_string()));
}
let mut map = Map::new();
map.insert(
"ts".to_string(),
Value::String(ts.to_rfc3339_opts(SecondsFormat::Secs, false)),
);
map.insert("act".to_string(), Value::String(act.to_string()));
map.insert("src".to_string(), Value::String(src.to_string()));
map.insert("dst".to_string(), Value::String(dst.to_string()));
for (k, v) in pairs {
map.insert((*k).to_string(), Value::String((*v).to_string()));
}
Ok(Value::Object(map).to_string())
}
fn first_colliding_key<'a>(pairs: &'a [(&str, &str)]) -> Option<&'a str> {
pairs
.iter()
.find(|(k, _)| CANONICAL_KEYS.contains(k))
.map(|(k, _)| *k)
}
fn classify_rename_err(err: io::Error) -> Error {
if err.raw_os_error() == Some(EXDEV) {
Error::CrossVolume
} else {
Error::Rename(err)
}
}
#[cfg(target_os = "linux")]
const AT_FDCWD: c_int = -100;
#[cfg(target_os = "linux")]
const RENAME_NOREPLACE: c_uint = 1;
#[cfg(target_os = "macos")]
const RENAME_EXCL: c_uint = 0x0000_0004;
fn path_to_cstring(p: &Path) -> io::Result<CString> {
use std::os::unix::ffi::OsStrExt;
CString::new(p.as_os_str().as_bytes())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
}
#[cfg(target_os = "linux")]
fn rename_noclobber(from: &Path, to: &Path) -> io::Result<()> {
unsafe extern "C" {
fn renameat2(
olddirfd: c_int,
oldpath: *const c_char,
newdirfd: c_int,
newpath: *const c_char,
flags: c_uint,
) -> c_int;
}
let from_c = path_to_cstring(from)?;
let to_c = path_to_cstring(to)?;
let rc = unsafe {
renameat2(
AT_FDCWD,
from_c.as_ptr(),
AT_FDCWD,
to_c.as_ptr(),
RENAME_NOREPLACE,
)
};
if rc < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
#[cfg(target_os = "macos")]
fn rename_noclobber(from: &Path, to: &Path) -> io::Result<()> {
unsafe extern "C" {
fn renamex_np(from: *const c_char, to: *const c_char, flags: c_uint) -> c_int;
}
let from_c = path_to_cstring(from)?;
let to_c = path_to_cstring(to)?;
let rc = unsafe { renamex_np(from_c.as_ptr(), to_c.as_ptr(), RENAME_EXCL) };
if rc < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn canonicalize_new(dst: &Path) -> Result<PathBuf, Error> {
let file_name = dst.file_name().ok_or_else(|| {
Error::Canonicalize(io::Error::new(
io::ErrorKind::InvalidInput,
"destination has no file name",
))
})?;
let parent = match dst.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => Path::new("."),
};
let parent_abs = fs::canonicalize(parent).map_err(Error::Canonicalize)?;
Ok(parent_abs.join(file_name))
}
fn rename_into_trash(path: &Path, trash_dir: &Path, name: &OsStr) -> Result<PathBuf, Error> {
let first = trash_dir.join(name);
match rename_noclobber(path, &first) {
Ok(()) => return Ok(first),
Err(e) if e.raw_os_error() == Some(EEXIST) => {}
Err(e) => return Err(classify_rename_err(e)),
}
let as_path = Path::new(name);
let stem = as_path
.file_stem()
.unwrap_or(name)
.to_string_lossy()
.into_owned();
let ext = as_path
.extension()
.map(|e| e.to_string_lossy().into_owned());
let mut n = 1u32;
loop {
let candidate_name = match &ext {
Some(ext) => format!("{stem}-{n}.{ext}"),
None => format!("{stem}-{n}"),
};
let candidate = trash_dir.join(candidate_name);
match rename_noclobber(path, &candidate) {
Ok(()) => return Ok(candidate),
Err(e) if e.raw_os_error() == Some(EEXIST) => {}
Err(e) => return Err(classify_rename_err(e)),
}
n += 1;
}
}
fn write_log_line<W: Write>(w: &mut W, line: &str) -> io::Result<()> {
w.write_all(format!("{line}\n").as_bytes())
}
fn append_log(log: &Path, line: &str) -> io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open(log)?;
write_log_line(&mut file, line)?;
file.sync_data()
}
fn ends_with_sep(p: &Path) -> bool {
p.to_string_lossy().ends_with('/')
}
fn resolve_move_target(src: &Path, dst: &Path) -> PathBuf {
match (ends_with_sep(dst) || dst.is_dir(), src.file_name()) {
(true, Some(name)) => dst.join(name),
_ => dst.to_path_buf(),
}
}
fn mkdir_chain(parent: &Path, log: &Path, ts: DateTime<FixedOffset>) -> Result<(), Error> {
let mut missing: Vec<PathBuf> = Vec::new();
let mut cur = Some(parent);
while let Some(dir) = cur {
if dir.as_os_str().is_empty() || dir.exists() {
break;
}
missing.push(dir.to_path_buf());
cur = dir.parent();
}
if missing.is_empty() {
return Ok(());
}
missing.reverse();
fs::create_dir_all(parent).map_err(Error::MkdirCreate)?;
for dir in &missing {
let abs = fs::canonicalize(dir).map_err(Error::DriftAfterMkdir)?;
let line = build_entry(ts, "mkdir", "-", &abs.to_string_lossy(), &[])?;
append_log(log, &line).map_err(Error::DriftAfterMkdir)?;
}
Ok(())
}
fn rmdir_cascade(start: Option<&Path>, log: &Path, ts: DateTime<FixedOffset>) -> Result<(), Error> {
let mut cur = start.map(Path::to_path_buf);
while let Some(dir) = cur {
if dir.as_os_str().is_empty() {
break;
}
match fs::read_dir(&dir) {
Ok(mut entries) => {
if entries.next().is_some() {
break;
}
}
Err(_) => break,
}
match fs::remove_dir(&dir) {
Ok(()) => {
let line = build_entry(ts, "rmdir", &dir.to_string_lossy(), "-", &[])?;
append_log(log, &line).map_err(Error::DriftAfterRmdir)?;
cur = dir.parent().map(Path::to_path_buf);
}
Err(_) => break,
}
}
Ok(())
}
pub fn run(
op: Op,
log: &Path,
pairs: &[(&str, &str)],
mkdir: bool,
rmdir: bool,
) -> Result<(), Error> {
let ts = chrono::Local::now().fixed_offset();
if let Some(key) = first_colliding_key(pairs) {
return Err(Error::MetadataKeyCollision(key.to_string()));
}
let (line, abs_src) = match op {
Op::Move { src, dst } => {
let abs_src = fs::canonicalize(&src).map_err(Error::Canonicalize)?;
let target = resolve_move_target(&src, &dst);
if mkdir {
if let Some(parent) = target.parent() {
mkdir_chain(parent, log, ts)?;
}
}
let abs_dst = canonicalize_new(&target)?;
let line = build_entry(
ts,
"move",
&abs_src.to_string_lossy(),
&abs_dst.to_string_lossy(),
pairs,
)?;
rename_noclobber(&src, &target).map_err(|e| {
if e.raw_os_error() == Some(EEXIST) {
Error::DestinationExists(target.clone())
} else {
classify_rename_err(e)
}
})?;
(line, abs_src)
}
Op::Trash { path, trash_dir } => {
let abs_src = fs::canonicalize(&path).map_err(Error::Canonicalize)?;
let name = path.file_name().ok_or_else(|| {
Error::Canonicalize(io::Error::new(
io::ErrorKind::InvalidInput,
"trash source has no file name",
))
})?;
let winner = rename_into_trash(&path, &trash_dir, name)?;
let abs_dst = canonicalize_new(&winner)?;
let line = build_entry(
ts,
"trash",
&abs_src.to_string_lossy(),
&abs_dst.to_string_lossy(),
pairs,
)?;
(line, abs_src)
}
};
append_log(log, &line).map_err(Error::DriftAfterMove)?;
if rmdir {
rmdir_cascade(abs_src.parent(), log, ts)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use std::io;
fn fixed_ts() -> DateTime<FixedOffset> {
FixedOffset::east_opt(0)
.unwrap()
.with_ymd_and_hms(2024, 1, 15, 10, 30, 0)
.unwrap()
}
#[test]
fn t1_build_entry_move_exact_line() {
let ts = fixed_ts();
let line = build_entry(ts, "move", "/abs/src/file.txt", "/abs/dst/file.txt", &[])
.expect("build_entry must succeed with no pairs");
let v: serde_json::Value =
serde_json::from_str(&line).expect("build_entry output must be valid JSON");
assert_eq!(v["ts"], "2024-01-15T10:30:00+00:00");
assert_eq!(v["act"], "move");
assert_eq!(v["src"], "/abs/src/file.txt");
assert_eq!(v["dst"], "/abs/dst/file.txt");
let expected = r#"{"ts":"2024-01-15T10:30:00+00:00","act":"move","src":"/abs/src/file.txt","dst":"/abs/dst/file.txt"}"#;
assert_eq!(line, expected);
}
#[cfg(unix)]
#[test]
fn t_non_utf8_path_logs_replacement_char() {
use std::os::unix::ffi::OsStrExt;
let ts = fixed_ts();
let lossy = Path::new(OsStr::from_bytes(b"file\xFF.txt")).to_string_lossy();
let line = build_entry(ts, "move", &lossy, "/abs/dst", &[])
.expect("build_entry must succeed with a lossily-converted src");
let v: serde_json::Value =
serde_json::from_str(&line).expect("build_entry output must be valid JSON");
assert_eq!(v["src"], "file\u{FFFD}.txt");
}
#[test]
fn t3_build_entry_special_chars_in_key_and_value_round_trips() {
let ts = fixed_ts();
let tricky_key = r#"k"ey\ ☃"#;
let tricky_val = r#"say "hello" \ ☃"#;
let line = build_entry(
ts,
"move",
"/abs/src",
"/abs/dst",
&[(tricky_key, tricky_val)],
)
.expect("build_entry must succeed with special-char pair");
let v: serde_json::Value = serde_json::from_str(&line)
.expect("line with special chars in key and value must still be valid JSON");
let obj = v.as_object().unwrap();
assert_eq!(
obj.get(tricky_key)
.and_then(|v| v.as_str())
.expect("tricky key must be present in JSON object"),
tricky_val
);
}
#[test]
fn t_pairs_appear_after_canonical_in_order_as_strings() {
let ts = fixed_ts();
let pairs: &[(&str, &str)] = &[("by", "cc"), ("ac", "p"), ("num", "42")];
let line = build_entry(ts, "move", "/abs/src", "/abs/dst", pairs)
.expect("build_entry must succeed with pairs");
let v: serde_json::Value =
serde_json::from_str(&line).expect("line with pairs must be valid JSON");
let obj = v.as_object().unwrap();
let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).collect();
assert_eq!(
&keys[..4],
&["ts", "act", "src", "dst"],
"first four keys must be canonical in order, got: {keys:?}"
);
assert_eq!(
&keys[4..],
&["by", "ac", "num"],
"pairs must follow canonical keys in given order, got: {keys:?}"
);
assert_eq!(v["by"], serde_json::Value::String("cc".into()));
assert_eq!(v["ac"], serde_json::Value::String("p".into()));
assert_eq!(v["num"], serde_json::Value::String("42".into()));
}
#[test]
fn t_collide_u_build_entry_refuses_canonical_key() {
let ts = fixed_ts();
for &canonical in &["ts", "act", "src", "dst"] {
let result = build_entry(
ts,
"move",
"/abs/src",
"/abs/dst",
&[(canonical, "spoofed")],
);
assert!(
matches!(&result, Err(Error::MetadataKeyCollision(k)) if k == canonical),
"expected MetadataKeyCollision({canonical}), got: {result:?}"
);
}
}
#[test]
fn t4_classify_rename_err_exdev_and_other() {
let exdev = io::Error::from_raw_os_error(18);
let result = classify_rename_err(exdev);
assert!(
matches!(result, Error::CrossVolume),
"os error 18 must map to CrossVolume, got: {result:?}"
);
let other = io::Error::from_raw_os_error(13);
let result2 = classify_rename_err(other);
assert!(
matches!(result2, Error::Rename(_)),
"non-EXDEV error must map to Rename, got: {result2:?}"
);
}
#[derive(Default)]
struct CountingWriter {
writes: usize,
buf: Vec<u8>,
}
impl io::Write for CountingWriter {
fn write(&mut self, data: &[u8]) -> io::Result<usize> {
self.writes += 1;
self.buf.extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn t_write_log_line_single_write_call() {
let mut w = CountingWriter::default();
let line = "{\"ts\":\"x\",\"act\":\"move\"}";
let r = write_log_line(&mut w, line);
assert!(r.is_ok());
assert_eq!(
w.writes, 1,
"line + newline must be a single write, not two"
);
assert_eq!(w.buf, format!("{line}\n").into_bytes());
}
}