use std::{ io::{self, Write}, path::{Path, PathBuf}, }; use crate::{Fingerprint, SaveState, Session}; #[cfg(test)] mod tests; #[cfg(all(test, target_os = "macos"))] mod full; /// The largest name the filesystem takes. const NAME_MAX: usize = 255; /// `error` with the path in its message. fn with_path(path: &Path, error: io::Error) -> io::Error { io::Error::new(error.kind(), format!("{}: {error}", path.display())) } /// The directory a path lives in (bare names: `.`). fn parent_dir(path: &Path) -> &Path { match path.parent() { Some(dir) if !dir.as_os_str().is_empty() => dir, _ => Path::new("."), } } /// `stem` capped so `suffix` survives the name limit (FILE-09). fn with_suffix(stem: &str, suffix: &str) -> String { take_bytes(stem, NAME_MAX.saturating_sub(suffix.len())) + suffix } /// `text` taken head-first within `max` bytes, on char edges. fn take_bytes(text: &str, max: usize) -> String { let mut out = String::new(); let mut used = 0; for ch in text.chars() { let len = ch.len_utf8(); if used + len > max { break; } out.push(ch); used += len; } out } /// A collision name: the counter always survives. fn numbered_name(stem: &str, counter: u32, ext: &str) -> String { let middle = format!("-{counter}"); let ext = take_bytes(ext, 32); let dot = if ext.is_empty() { "" } else { "." }; let room = NAME_MAX.saturating_sub(middle.len() + dot.len() + ext.len()); format!("{}{middle}{dot}{ext}", take_bytes(stem, room)) } /// Writes `bytes` beside `path`, checks the write, then replaces (FILE-04). pub fn save_atomic(path: &Path, bytes: &[u8], revision: u64) -> io::Result { let target = resolved(path)?; let mut staging = tempfile::NamedTempFile::new_in(parent_dir(&target)).map_err(|e| with_path(path, e))?; staging.write_all(bytes).map_err(|e| with_path(path, e))?; staging .as_file() .sync_all() .map_err(|e| with_path(path, e))?; if let Ok(meta) = std::fs::metadata(&target) { staging .as_file() .set_permissions(meta.permissions()) .map_err(|e| with_path(path, e))?; copy_acl(&target, staging.path()).map_err(|e| with_path(path, e))?; } staging .persist(&target) .map_err(|error| with_path(path, error.error))?; log::info!("files: saved revision {revision} to {}", path.display()); Fingerprint::of(&target) } /// The access-control entries of an existing target (macOS). #[cfg(target_os = "macos")] fn copy_acl(from: &Path, to: &Path) -> io::Result<()> { use std::os::unix::ffi::OsStrExt; const COPYFILE_ACL: u32 = 1 << 0; let cstring = |path: &Path| { std::ffi::CString::new(path.as_os_str().as_bytes()) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "the path holds a NUL")) }; let (from, to) = (cstring(from)?, cstring(to)?); if unsafe { libc::copyfile( from.as_ptr(), to.as_ptr(), std::ptr::null_mut(), COPYFILE_ACL, ) } != 0 { return Err(io::Error::last_os_error()); } Ok(()) } #[cfg(not(target_os = "macos"))] fn copy_acl(_: &Path, _: &Path) -> io::Result<()> { Ok(()) } /// Non-atomic targets keep the previous file aside (§6.1). pub fn save_with_backup( path: &Path, bytes: &[u8], revision: u64, ) -> io::Result<(Fingerprint, Option)> { let backup = if path.try_exists().map_err(|e| with_path(path, e))? { let (name, mut file) = fresh_backup(path)?; let mut source = std::fs::File::open(path).map_err(|e| with_path(path, e))?; io::copy(&mut source, &mut file).map_err(|e| with_path(&name, e))?; file.sync_all().map_err(|e| with_path(&name, e))?; log::info!("files: the previous file stays at {}", name.display()); Some(name) } else { None }; let written = save_atomic(path, bytes, revision)?; Ok((written, backup)) } /// An exclusive fresh backup file with the source's access limits. fn fresh_backup(path: &Path) -> io::Result<(PathBuf, std::fs::File)> { let name = path.file_name().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidInput, format!("{} has no file name", path.display()), ) })?; for n in 0u32.. { let suffix = if n == 0 { ".bak".to_string() } else { format!("-{n}.bak") }; let candidate = parent_dir(path).join(with_suffix(&name.to_string_lossy(), &suffix)); match std::fs::OpenOptions::new() .write(true) .create_new(true) .open(&candidate) { Ok(file) => { let meta = std::fs::metadata(path).map_err(|e| with_path(path, e))?; file.set_permissions(meta.permissions()) .map_err(|e| with_path(&candidate, e))?; copy_acl(path, &candidate).map_err(|e| with_path(&candidate, e))?; return Ok((candidate, file)); } Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, Err(error) => return Err(with_path(&candidate, error)), } } unreachable!("u32 backup names never run out") } /// An export never overwrites the session's file (FILE-06). pub fn export( path: &Path, session: Option<&Path>, bytes: &[u8], revision: u64, ) -> io::Result { if let Some(open) = session && same_file(open, path)? { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!("export would overwrite the open file {}", path.display()), )); } log::info!("files: exported revision {revision} to {}", path.display()); save_atomic(path, bytes, revision) } /// The canonical form of a possibly missing path. fn resolved(path: &Path) -> io::Result { match std::fs::canonicalize(path) { Ok(real) => Ok(real), Err(error) if error.kind() == io::ErrorKind::NotFound => { let name = path.file_name().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidInput, format!("{} has no file name", path.display()), ) })?; Ok(std::fs::canonicalize(parent_dir(path))?.join(name)) } Err(error) => Err(error), } } /// Whether two paths name one file, links resolved. pub fn same_file(a: &Path, b: &Path) -> io::Result { if resolved(a)? == resolved(b)? { return Ok(true); } #[cfg(unix)] { use std::os::unix::fs::MetadataExt; if let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) { return Ok((ma.dev(), ma.ino()) == (mb.dev(), mb.ino())); } } Ok(false) } /// Copies an attachment in; the source is never touched (FILE-07). pub fn import_attachment(source: &Path, dir: &Path) -> io::Result { std::fs::create_dir_all(dir).map_err(|e| with_path(dir, e))?; let name = source.file_name().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidInput, format!("{} has no file name", source.display()), ) })?; let mut target = dir.join(name); let stem = Path::new(name) .file_stem() .unwrap_or_default() .to_string_lossy() .into_owned(); let ext = Path::new(name) .extension() .map(|ext| ext.to_string_lossy().into_owned()) .unwrap_or_default(); let mut from = std::fs::File::open(source).map_err(|e| with_path(source, e))?; let mut counter = 1; // Claims the name; another writer's file is never replaced. let mut to = loop { match std::fs::File::create_new(&target) { Ok(file) => break file, Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { target = dir.join(numbered_name(&stem, counter, &ext)); counter += 1; } Err(e) => return Err(with_path(&target, e)), } }; let copied = io::copy(&mut from, &mut to).and_then(|_| to.flush()); if let Err(e) = copied { // A half copy is not left behind. std::fs::remove_file(&target).ok(); return Err(with_path(&target, e)); } log::info!( "files: imported {} to {}", source.display(), target.display() ); Ok(target) } /// Why a save did not land (FILE-05). #[derive(Debug)] pub enum SaveError { Conflict, Refused(SaveState), Io(io::Error), } /// What a completed save left behind. pub struct Saved { pub path: PathBuf, pub written: Fingerprint, /// Some on non-atomic volumes; the previous file lives there. pub backup: Option, } /// The whole save flow: conflict check, ticket, write, confirm (FILE-04/05). pub fn save(session: &mut Session, bytes: &[u8]) -> Result { save_with_probe(session, bytes, atomic_capable) } /// The save flow with the volume probe handed in (§6.1). pub fn save_with_probe( session: &mut Session, bytes: &[u8], probe: impl FnOnce(&Path) -> io::Result, ) -> Result { let Some(path) = session.path().map(Path::to_path_buf) else { return Err(SaveError::Io(io::Error::new( io::ErrorKind::NotFound, "no path to save", ))); }; let target = resolved(&path).map_err(SaveError::Io)?; let backup = !probe(&target).map_err(SaveError::Io)?; if backup { log::warn!("files: {} sits on a non-atomic volume", target.display()); } save_mode(session, &target, bytes, backup) } /// The save flow with the volume's capability decided. fn save_mode( session: &mut Session, path: &Path, bytes: &[u8], backup: bool, ) -> Result { if session.external_changed().map_err(SaveError::Io)? { session.external_modified(); log::warn!("files: save to {} meets an external change", path.display()); return Err(SaveError::Conflict); } write_ticketed(session, path, bytes, backup, false) } /// Save As to a chosen path (FILE-04, FILE-05). pub fn save_as(session: &mut Session, path: &Path, bytes: &[u8]) -> Result { let target = resolved(path).map_err(SaveError::Io)?; let backup = !atomic_capable(&target).map_err(SaveError::Io)?; let own = match session.path() { Some(current) => same_file(current, &target).map_err(SaveError::Io)?, None => false, }; if own { // The session's own file keeps its conflict check. return save_mode(session, &target, bytes, backup); } log::info!("files: save as {}", target.display()); // A new file keeps the local version in a conflict. let keep_local = session.state() == SaveState::ExternalModified; write_ticketed(session, &target, bytes, backup, keep_local) } /// Keep-local in a conflict: overwrite the changed file (FILE-05). pub fn save_overwrite(session: &mut Session, bytes: &[u8]) -> Result { let Some(path) = session.path().map(Path::to_path_buf) else { return Err(SaveError::Io(io::Error::new( io::ErrorKind::NotFound, "no path to save", ))); }; let target = resolved(&path).map_err(SaveError::Io)?; let backup = !atomic_capable(&target).map_err(SaveError::Io)?; write_ticketed(session, &target, bytes, backup, true) } /// Ticket, write, confirm: the shared tail of every save. fn write_ticketed( session: &mut Session, path: &Path, bytes: &[u8], backup: bool, keep_local: bool, ) -> Result { let ticket = if keep_local { session.begin_save_overwrite() } else { session.begin_save() } .map_err(SaveError::Refused)?; let revision = session.revision(); let outcome = if backup { save_with_backup(path, bytes, revision) } else { save_atomic(path, bytes, revision).map(|written| (written, None)) }; let (written, backup) = match outcome { Ok(done) => done, Err(error) => { if session.fail_save(ticket).is_err() { log::error!("files: the failed save left an unknown ticket"); } return Err(SaveError::Io(error)); } }; session .finish_save(ticket, path.to_path_buf(), written) .map_err(|_| { SaveError::Io(io::Error::new( io::ErrorKind::InvalidData, "stale save ticket", )) })?; Ok(Saved { path: path.to_path_buf(), written, backup, }) } /// Whether the target's volume replaces files atomically (§6.1). pub fn atomic_capable(path: &Path) -> io::Result { capable(parent_dir(path)) } #[cfg(unix)] fn statfs(dir: &Path) -> io::Result { use std::os::unix::ffi::OsStrExt; let path = std::ffi::CString::new(dir.as_os_str().as_bytes()) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "the path holds a NUL"))?; let mut stats: libc::statfs = unsafe { std::mem::zeroed() }; if unsafe { libc::statfs(path.as_ptr(), &mut stats) } != 0 { return Err(io::Error::last_os_error()); } Ok(stats) } #[cfg(target_os = "macos")] fn capable(dir: &Path) -> io::Result { const MNT_LOCAL: i32 = 0x0000_1000; Ok(statfs(dir)?.f_flags as i32 & MNT_LOCAL != 0) } #[cfg(target_os = "linux")] fn capable(dir: &Path) -> io::Result { const NFS: i64 = 0x6969; const SMB: i64 = 0x517B; const CIFS: i64 = 0xFF534D42; Ok(!matches!(statfs(dir)?.f_type, NFS | SMB | CIFS)) } #[cfg(windows)] fn capable(dir: &Path) -> io::Result { use std::path::Component; use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW; const DRIVE_REMOTE: u32 = 4; let root = match dir.components().next() { Some(Component::Prefix(prefix)) => prefix.as_os_str().to_string_lossy().into_owned() + "\\", _ => "\\".to_string(), }; let wide: Vec = root.encode_utf16().chain(Some(0)).collect(); Ok(unsafe { GetDriveTypeW(wide.as_ptr()) } != DRIVE_REMOTE) } #[cfg(not(any(unix, windows)))] fn capable(_: &Path) -> io::Result { Ok(true) } /// Takes every target or none; a file there already refuses. pub fn claim_all(paths: &[PathBuf]) -> io::Result> { let mut taken = Vec::new(); for path in paths { // A new file only: never a link someone left. match std::fs::OpenOptions::new() .write(true) .create_new(true) .open(path) { Ok(file) => taken.push(file), Err(error) => { paths[..taken.len()] .iter() .try_for_each(std::fs::remove_file)?; let name = path.file_name().map_or_else( || path.display().to_string(), |name| name.to_string_lossy().into_owned(), ); return Err(io::Error::new(error.kind(), name)); } } } Ok(taken) }