Files
ElyOffice/crates/ely-files/src/save.rs
T
ZacharyZhang-NY b7fca0146d Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
2026-10-08 21:20:08 -04:00

467 lines
15 KiB
Rust

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<Fingerprint> {
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<PathBuf>)> {
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<Fingerprint> {
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<PathBuf> {
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<bool> {
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<PathBuf> {
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<PathBuf>,
}
/// The whole save flow: conflict check, ticket, write, confirm (FILE-04/05).
pub fn save(session: &mut Session, bytes: &[u8]) -> Result<Saved, SaveError> {
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<bool>,
) -> Result<Saved, SaveError> {
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<Saved, SaveError> {
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<Saved, SaveError> {
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<Saved, SaveError> {
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<Saved, SaveError> {
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<bool> {
capable(parent_dir(path))
}
#[cfg(unix)]
fn statfs(dir: &Path) -> io::Result<libc::statfs> {
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<bool> {
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<bool> {
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<bool> {
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<u16> = 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<bool> {
Ok(true)
}
/// Takes every target or none; a file there already refuses.
pub fn claim_all(paths: &[PathBuf]) -> io::Result<Vec<std::fs::File>> {
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)
}