mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-01 13:57:18 -06:00
* fix(format): report a failed directory flush after a save instead of ignoring it atomic_write and the bundle writer discarded the directory fsync result once the rename had published the file, so a save whose rename might not survive a power loss reported success and its recovery data could be discarded. A real flush failure is now returned (the file is in place, the message says the save may not last), so callers keep the document unsaved and its recovery data, and a bundle save stops before removing objects the old manifest needs. A file system that can't flush directories at all (EINVAL, ENOTSUP, Unsupported) still counts as saved. Fixes #4968. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(format): a folder that can't be opened for a flush is not a failed save A write-and-search-only folder (no read permission) can't be opened to fsync it, so no flush can be attempted there. Treating that open failure as a failed flush made saves into such folders fail (the directory_symlinks permission tests on Linux). Only a flush that is attempted and fails is reported now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(format): flush bundle folders like atomic_write, and test a failed manifest flush The bundle writer flushed the folder with sync_all, which on Apple platforms is F_FULLFSYNC and fails on SMB shares; it now goes through the same RealFs::sync_dir as atomic_write (sync_file's plain-fsync fallback, #1336). open_dir_for_sync and dir_synced are crate-private. A test-only hook fails the manifest's folder flush: the save fails, and the objects the previous manifest references stay until the next save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(format): keep stale previews until the manifest commits; errno 45 only where it is ENOTSUP A directory save removed a thumbnail or composite the new document no longer has before writing the new manifest, so a failed manifest flush left the previous manifest naming a preview that was gone. They are now removed after the manifest commits, best-effort like unreferenced objects; the failed-flush test checks the thumbnail survives. errno 45 counted as "can't flush directories" on every Unix, but it is ENOTSUP only on Apple platforms and the BSDs (illumos: EDEADLK). Other Unix targets now accept only EINVAL and ErrorKind::Unsupported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(format): flush the object folders before every manifest commit When an object's folder flush failed, a retried save found the object already written, reused it without flushing its folder, and then committed a manifest naming it: success was reported while the object's directory entry was still unconfirmed. Each directory save now flushes tiles/ and blobs/ once before writing the manifest, so a reused object is confirmed too. The flush error is shared with write_bundle_file (confirm_flushed), and a test retries a one-tile save whose tile folder flush failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(format): keep stale-preview removal before the manifest commit, as main requiresf77ffa3emoved stale-preview removal after the manifest commit. That broke the hardening in2d5a7497(#4957), whose test denied_stale_preview_cleanup_preserves_the_previous_manifest requires a committed manifest never to sit beside a stale preview, so a failed removal must fail the save. Restore main's ordering; the object-folder flush and the failed-flush tests stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(format): flush the composite folder after removing a stale composite The stale composite/preview.png was removed before the manifest commit, but nothing flushed composite/, so a crash could bring it back beside the new manifest. The folder is now flushed when a stale composite was removed (the thumbnail lives in the bundle root, flushed with the manifest). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
440 lines
20 KiB
Rust
440 lines
20 KiB
Rust
//! Crash-safe file replacement: the one way PhotoCraft writes a document to disk.
|
|
//!
|
|
//! [`atomic_write`] never truncates the destination. It writes a temporary file in the **same
|
|
//! directory**, flushes it to stable storage (`sync_all`), renames it over the destination and,
|
|
//! flushes the directory so the rename itself survives a power loss. Failures before replacement
|
|
//! keep the destination's previous bytes. On Windows a failed publication flush reports that the
|
|
//! new file is in place but its durability could not be confirmed (#7858).
|
|
//!
|
|
//! - **Windows:** antivirus scanners and the search indexer briefly hold files open, which makes
|
|
//! the replacing rename fail with `PermissionDenied`. The rename is retried with exponential
|
|
//! backoff before giving up. (`std::fs::rename` uses `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`,
|
|
//! i.e. replace semantics.)
|
|
//! - **Cross-device (`EXDEV`):** cannot happen while the temporary file sits beside the target;
|
|
//! if it does anyway, the save fails with a clear error. It never falls back to writing the
|
|
//! destination in place.
|
|
//! - **Unwritable folder:** the save fails with a clear error instead of writing in place.
|
|
//! - **Read-only file:** the save is refused before anything is written (#6600). A rename only
|
|
//! needs the folder to be writable, so without this check Linux and macOS would silently replace
|
|
//! a `chmod 444` file while Windows refuses; now every platform refuses, as Photoshop does, and
|
|
//! the user saves a copy with Save As.
|
|
//! - **Network shares (macOS):** `sync_all` asks for a full flush to the disk's platters
|
|
//! (`F_FULLFSYNC`), which SMB shares and some virtual file systems refuse while a plain `fsync`
|
|
//! works. [`sync_file`] falls back to the plain one there (#1336).
|
|
//!
|
|
//! On the web there is no file system: writes go through the platform services (downloads), so
|
|
//! this module is never reached there; on `wasm32` every call returns the `std` "unsupported"
|
|
//! error.
|
|
//!
|
|
//! Replacing a file also carries its metadata across — permission bits, extended attributes,
|
|
//! the "downloaded from the internet" mark and the access-control list — so that saving a
|
|
//! document does not quietly undo a security decision someone made about the file. That part is
|
|
//! [`meta`], and it is best effort: it never fails a save and never touches the bytes.
|
|
//!
|
|
//! [`atomic_write_with`] takes an [`AtomicFs`] so tests can inject failures at each step.
|
|
|
|
pub mod meta;
|
|
|
|
use std::io::{self, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
/// The file-system steps [`atomic_write_with`] performs. [`RealFs`] is the real thing; tests
|
|
/// wrap it to inject failures.
|
|
pub trait AtomicFs {
|
|
/// Whether the existing file at `path` is marked read-only (a missing file is not). A
|
|
/// read-only destination is never replaced (#6600). By default nothing is read-only.
|
|
fn is_readonly(&self, path: &Path) -> bool {
|
|
let _ = path;
|
|
false
|
|
}
|
|
/// Create `tmp` (it must not exist), write all of `bytes` and flush it ([`sync_file`]).
|
|
fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()>;
|
|
/// [`write_new`](Self::write_new), reporting the fraction of `bytes` written so far to
|
|
/// `progress` (a save's progress bar, #3389). By default it reports nothing.
|
|
fn write_new_progress(&self, tmp: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> {
|
|
let _ = progress;
|
|
self.write_new(tmp, bytes)
|
|
}
|
|
/// Rename `from` over `to`, replacing it.
|
|
fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
|
|
/// Remove a (temporary) file.
|
|
fn remove(&self, path: &Path) -> io::Result<()>;
|
|
/// Flush a directory entry change on Unix and Windows; a no-op elsewhere.
|
|
fn sync_dir(&self, dir: &Path) -> io::Result<()>;
|
|
/// Copy the metadata a replacement should keep — mode, extended attributes, ACL — from the
|
|
/// file about to be replaced onto the temporary file. Best effort; see [`meta`].
|
|
fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()>;
|
|
/// Replace the **existing** file `to` with `from`. Everywhere but Windows this is
|
|
/// [`rename`](Self::rename); on Windows it keeps the destination's DACL, named streams and
|
|
/// creation time ([`meta::replace_existing`]). The default is `rename`, which is what the
|
|
/// failure-injecting test file systems want.
|
|
fn replace_existing(&self, from: &Path, to: &Path) -> io::Result<()> {
|
|
self.rename(from, to)
|
|
}
|
|
}
|
|
|
|
/// The real file system.
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct RealFs;
|
|
|
|
/// How much [`RealFs::write_new_progress`] writes between progress reports.
|
|
const WRITE_CHUNK: usize = 8 << 20;
|
|
|
|
impl AtomicFs for RealFs {
|
|
fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> {
|
|
self.write_new_progress(tmp, bytes, &|_| {})
|
|
}
|
|
fn write_new_progress(&self, tmp: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> {
|
|
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)?;
|
|
let mut written = 0usize;
|
|
for chunk in bytes.chunks(WRITE_CHUNK) {
|
|
f.write_all(chunk)?;
|
|
written = written.saturating_add(chunk.len());
|
|
progress(written as f32 / bytes.len().max(1) as f32);
|
|
}
|
|
sync_file(&f)
|
|
}
|
|
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
|
|
std::fs::rename(from, to)
|
|
}
|
|
fn remove(&self, path: &Path) -> io::Result<()> {
|
|
std::fs::remove_file(path)
|
|
}
|
|
/// Flush directory publication using a directory handle on Unix and Windows.
|
|
/// Windows requests only `FILE_ADD_FILE`; opening or flushing the handle can fail the save.
|
|
fn sync_dir(&self, dir: &Path) -> io::Result<()> {
|
|
#[cfg(unix)]
|
|
{
|
|
open_dir_for_sync(dir)?.map_or(Ok(()), |d| sync_file(&d))
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::fs::OpenOptionsExt;
|
|
|
|
// FILE_ADD_FILE is the directory equivalent of FILE_WRITE_DATA. Requesting only
|
|
// this right permits FlushFileBuffers without requiring GENERIC_WRITE's additional
|
|
// directory permissions. BACKUP_SEMANTICS opens a directory, not a volume: no
|
|
// administrative privilege or volume-wide flush is needed.
|
|
const FILE_ADD_FILE: u32 = 0x0002;
|
|
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
|
|
let directory = std::fs::OpenOptions::new().access_mode(FILE_ADD_FILE).custom_flags(FILE_FLAG_BACKUP_SEMANTICS).open(dir)?;
|
|
directory.sync_all()
|
|
}
|
|
#[cfg(not(any(unix, windows)))]
|
|
{
|
|
let _ = dir;
|
|
Ok(())
|
|
}
|
|
}
|
|
fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> {
|
|
meta::copy_metadata(from, to)
|
|
}
|
|
fn replace_existing(&self, from: &Path, to: &Path) -> io::Result<()> {
|
|
meta::replace_existing(from, to)
|
|
}
|
|
fn is_readonly(&self, path: &Path) -> bool {
|
|
std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().readonly())
|
|
}
|
|
}
|
|
|
|
/// Flush `file` to stable storage (`sync_all`). On Apple platforms that is a full flush
|
|
/// (`F_FULLFSYNC`); where the file system can't do one (SMB shares refuse it with `ENOTSUP`,
|
|
/// #1336; Tart's shared folders with `ENOTTY`) this falls back to a plain `fsync`, as Apple's own
|
|
/// `removefile` does. Any other error is returned as it is.
|
|
pub fn sync_file(file: &std::fs::File) -> io::Result<()> {
|
|
#[cfg(target_vendor = "apple")]
|
|
{
|
|
or_plain_fsync(file.sync_all(), || rustix::fs::fsync(file).map_err(io::Error::from))
|
|
}
|
|
#[cfg(not(target_vendor = "apple"))]
|
|
{
|
|
file.sync_all()
|
|
}
|
|
}
|
|
|
|
/// `full`, or `fsync()` when `full` failed only because the file system can't do a full flush.
|
|
pub fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
|
|
match full {
|
|
Err(e) if full_flush_unsupported(&e) => fsync(),
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
/// Whether `e` is a file system refusing a full flush (`F_FULLFSYNC`): `ENOTSUP` (45, SMB) or
|
|
/// `ENOTTY` (25) on Apple platforms. Never elsewhere: there `sync_all` is a plain `fsync`.
|
|
fn full_flush_unsupported(e: &io::Error) -> bool {
|
|
const ENOTTY: i32 = 25;
|
|
const ENOTSUP: i32 = 45;
|
|
cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | ENOTTY))
|
|
}
|
|
|
|
/// How hard to retry a rename that fails with `PermissionDenied`.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct RenameRetry {
|
|
/// Retries after the first attempt (0 = no retry).
|
|
pub attempts: u32,
|
|
/// Delay before the first retry; doubles each time.
|
|
pub first_delay_ms: u64,
|
|
}
|
|
|
|
impl RenameRetry {
|
|
/// The platform default: retry on Windows (about 1.3 s in total), never elsewhere, where
|
|
/// `PermissionDenied` is a real permission problem that waiting won't fix.
|
|
pub fn platform() -> Self {
|
|
if cfg!(windows) { Self { attempts: 7, first_delay_ms: 10 } } else { Self { attempts: 0, first_delay_ms: 0 } }
|
|
}
|
|
}
|
|
|
|
/// Atomically replace (or create) `path` with `bytes`. See the module docs.
|
|
///
|
|
/// Errors keep their [`io::ErrorKind`]. Before replacement they say the original is unchanged;
|
|
/// a Windows publication-flush error says the new file may not survive a power loss.
|
|
pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
|
atomic_write_with(&RealFs, RenameRetry::platform(), path, bytes)
|
|
}
|
|
|
|
/// [`atomic_write`] reporting the fraction of `bytes` written to `progress` as it goes (a save's
|
|
/// progress bar, #3389); the flush and rename follow the last report.
|
|
pub fn atomic_write_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> {
|
|
atomic_write_with_progress(&RealFs, RenameRetry::platform(), path, bytes, progress)
|
|
}
|
|
|
|
/// [`atomic_write`] through an explicit file system and retry policy (the failure-injection seam).
|
|
pub fn atomic_write_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8]) -> io::Result<()> {
|
|
atomic_write_with_progress(fs, retry, path, bytes, &|_| {})
|
|
}
|
|
|
|
/// [`atomic_write_with`] reporting write progress (see [`atomic_write_progress`]).
|
|
pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> {
|
|
let target = resolve_symlink(path).map_err(|e| context(e, format!("cannot save {}", path.display())))?;
|
|
let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?;
|
|
let dir = match target.parent() {
|
|
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
|
|
_ => PathBuf::from("."),
|
|
};
|
|
std::fs::create_dir_all(&dir).map_err(|e| context(e, format!("cannot create the folder {}", dir.display())))?;
|
|
if fs.is_readonly(&target) {
|
|
return Err(io::Error::new(io::ErrorKind::PermissionDenied, format!("cannot save {}: the file is read-only; {UNCHANGED}", path.display())));
|
|
}
|
|
|
|
// Create a fresh temporary file next to the target (same directory, so the rename never
|
|
// crosses a device). The name is unique per process and call; retry a few on collisions.
|
|
let mut tmp = None;
|
|
for _ in 0..8 {
|
|
let candidate = dir.join(temp_name(&name.to_string_lossy()));
|
|
match fs.write_new_progress(&candidate, bytes, progress) {
|
|
Ok(()) => {
|
|
tmp = Some(candidate);
|
|
break;
|
|
}
|
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
|
Err(e) => {
|
|
// A partial temp file may exist; the destination was never touched.
|
|
let _ = fs.remove(&candidate);
|
|
return Err(write_error(e, path, &dir));
|
|
}
|
|
}
|
|
}
|
|
let tmp = tmp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))?;
|
|
|
|
// Keep what the file being replaced carried: its mode (e.g. 0600) instead of the process
|
|
// default, its extended attributes, and its ACL. Best effort, and before the swap, so the
|
|
// file the user ends up with never passes through a more permissive state.
|
|
let replacing = target.exists();
|
|
if replacing {
|
|
let _ = fs.copy_metadata(&target, &tmp);
|
|
}
|
|
|
|
// Replacing an existing file goes through `replace_existing`, which on Windows is the one
|
|
// call that can keep the destination's DACL and named streams; creating a new one is a
|
|
// plain rename, with nothing to preserve.
|
|
let swap = || if replacing { fs.replace_existing(&tmp, &target) } else { fs.rename(&tmp, &target) };
|
|
if let Err(e) = retry_rename(retry, swap) {
|
|
let _ = fs.remove(&tmp);
|
|
return Err(rename_error(e, path));
|
|
}
|
|
// The new bytes are in place, but until the directory is flushed the rename may not survive
|
|
// a power loss (#4968). Windows fails the save on any failed flush (#7858). Elsewhere a file
|
|
// system that can't flush directories at all counts as done; any other failure is reported,
|
|
// so the caller keeps the document unsaved and its recovery data instead of discarding them
|
|
// on a save that may not last.
|
|
#[cfg(windows)]
|
|
fs.sync_dir(&dir).map_err(|e| publication_error(e, path))?;
|
|
#[cfg(not(windows))]
|
|
dir_synced(fs.sync_dir(&dir)).map_err(|e| unconfirmed_error(e, path))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// `dir` opened to flush it, or `None` when it can't be opened for that because it has no read
|
|
/// permission (a write-and-search-only folder): no flush can be attempted there, so none failed.
|
|
/// Any other error is returned.
|
|
#[cfg(unix)]
|
|
fn open_dir_for_sync(dir: &Path) -> io::Result<Option<std::fs::File>> {
|
|
match std::fs::File::open(dir) {
|
|
Ok(d) => Ok(Some(d)),
|
|
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => Ok(None),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// `result` of flushing a directory, with "this file system can't flush directories" counted as
|
|
/// success: `EINVAL` (Linux file systems without directory fsync), `ENOTSUP`/`EOPNOTSUPP` and,
|
|
/// on Apple platforms, `ENOTTY`, or [`io::ErrorKind::Unsupported`]. Waiting or retrying won't
|
|
/// change those, and failing every save there would make saving impossible. Any other error is
|
|
/// returned: the flush was attempted and failed.
|
|
#[cfg(not(windows))]
|
|
pub(crate) fn dir_synced(result: io::Result<()>) -> io::Result<()> {
|
|
match result {
|
|
Err(e) if dir_sync_unsupported(&e) => Ok(()),
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn dir_sync_unsupported(e: &io::Error) -> bool {
|
|
if e.kind() == io::ErrorKind::Unsupported {
|
|
return true;
|
|
}
|
|
// errno values: EINVAL is 22 everywhere; ENOTSUP and EOPNOTSUPP are 95 on Linux and
|
|
// Android, 45 and 102 on Apple platforms, 45 on the BSDs; ENOTTY is 25. Elsewhere 45 means
|
|
// something else (illumos: EDEADLK), so only EINVAL and `Unsupported` count there.
|
|
const EINVAL: i32 = 22;
|
|
let unsupported: &[i32] = if cfg!(any(target_os = "linux", target_os = "android")) {
|
|
&[EINVAL, 95]
|
|
} else if cfg!(target_vendor = "apple") {
|
|
&[EINVAL, 45, 102, 25]
|
|
} else if cfg!(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")) {
|
|
&[EINVAL, 45]
|
|
} else if cfg!(unix) {
|
|
&[EINVAL]
|
|
} else {
|
|
&[]
|
|
};
|
|
e.raw_os_error().is_some_and(|code| unsupported.contains(&code))
|
|
}
|
|
|
|
/// The error for a file that was published but whose directory flush failed: same kind, saying
|
|
/// the file is in place but may not survive a power loss. (Windows reports `publication_error`.)
|
|
#[cfg(not(windows))]
|
|
fn unconfirmed_error(e: io::Error, path: &Path) -> io::Error {
|
|
context(
|
|
e,
|
|
format!(
|
|
"{} was written, but the folder could not be flushed to disk, so the save may not survive a power loss or crash; save again or to another folder",
|
|
path.display()
|
|
),
|
|
)
|
|
}
|
|
|
|
/// Run `rename`, retrying with backoff while it fails with `PermissionDenied` (see
|
|
/// [`RenameRetry`]). For callers that rename through their own handles (capability directories).
|
|
pub fn retry_rename(retry: RenameRetry, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> {
|
|
let mut delay = retry.first_delay_ms;
|
|
let mut left = retry.attempts;
|
|
loop {
|
|
match rename() {
|
|
Err(e) if e.kind() == io::ErrorKind::PermissionDenied && left > 0 => {
|
|
left -= 1;
|
|
sleep_ms(delay);
|
|
delay = delay.saturating_mul(2);
|
|
}
|
|
r => return r,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn sleep_ms(ms: u64) {
|
|
std::thread::sleep(std::time::Duration::from_millis(ms));
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn sleep_ms(_ms: u64) {}
|
|
|
|
/// How deep a symlink chain we follow before giving up, like the OS limit.
|
|
const MAX_LINK_DEPTH: u32 = 40;
|
|
|
|
/// Write through a symlink to the file it points at, instead of replacing the link.
|
|
///
|
|
/// A dangling link (its target, or a folder on the way to it, doesn't exist yet) resolves to its
|
|
/// target too, so saving through one creates the target instead of silently turning the link
|
|
/// into a regular file (#6605). A chain that never ends is an error, never a link replacement.
|
|
fn resolve_symlink(path: &Path) -> io::Result<PathBuf> {
|
|
if !std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) {
|
|
return Ok(path.to_path_buf());
|
|
}
|
|
// A resolvable chain: canonicalize follows every link and relative target in one go.
|
|
if let Ok(real) = std::fs::canonicalize(path) {
|
|
return Ok(real);
|
|
}
|
|
// Dangling chain: walk the links lexically; the final target may not exist yet.
|
|
let mut current = path.to_path_buf();
|
|
for _ in 0..MAX_LINK_DEPTH {
|
|
let Ok(link) = std::fs::read_link(¤t) else {
|
|
return Ok(current); // not (or no longer) a link: the chain ends here
|
|
};
|
|
let base = current.parent().unwrap_or(Path::new(".")).to_path_buf();
|
|
// `..` stays in the path for the OS to resolve: folding it by text is wrong when a folder
|
|
// on the way is itself a symlink.
|
|
current = if link.is_absolute() { link } else { base.join(link) };
|
|
if !std::fs::symlink_metadata(¤t).is_ok_and(|m| m.file_type().is_symlink()) {
|
|
return Ok(current);
|
|
}
|
|
}
|
|
Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{}: too many levels of symbolic links", path.display())))
|
|
}
|
|
|
|
/// A unique hidden temporary file name to write beside `file` before renaming over it.
|
|
pub fn temp_name(file: &str) -> String {
|
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
// Keep the name short: a long file name plus the suffix must stay under NAME_MAX.
|
|
let stem: String = file.chars().take(64).collect();
|
|
format!(".{stem}.{}-{n}.pcsave.tmp", std::process::id())
|
|
}
|
|
|
|
fn context(e: io::Error, msg: String) -> io::Error {
|
|
io::Error::new(e.kind(), format!("{msg}: {e}"))
|
|
}
|
|
|
|
const UNCHANGED: &str = "the original file was not changed";
|
|
|
|
/// Replacement already happened: never claim that the original file is unchanged or undo it.
|
|
#[cfg(windows)]
|
|
pub(crate) fn publication_error(error: io::Error, path: &Path) -> io::Error {
|
|
io::Error::new(
|
|
error.kind(),
|
|
format!("wrote {}, but could not confirm the save is durable; it may not survive a power loss or crash: {error}", path.display()),
|
|
)
|
|
}
|
|
|
|
fn write_error(e: io::Error, path: &Path, dir: &Path) -> io::Error {
|
|
let msg = match e.kind() {
|
|
io::ErrorKind::PermissionDenied | io::ErrorKind::ReadOnlyFilesystem => {
|
|
format!("cannot save {}: the folder {} is not writable ({e}); {UNCHANGED}", path.display(), dir.display())
|
|
}
|
|
io::ErrorKind::StorageFull => format!("cannot save {}: the disk is full ({e}); {UNCHANGED}", path.display()),
|
|
_ => format!("cannot save {}: {e}; {UNCHANGED}", path.display()),
|
|
};
|
|
io::Error::new(e.kind(), msg)
|
|
}
|
|
|
|
fn rename_error(e: io::Error, path: &Path) -> io::Error {
|
|
let msg = if is_cross_device(&e) {
|
|
format!("cannot save {}: the temporary file is on a different volume than the destination ({e}); {UNCHANGED}", path.display())
|
|
} else if e.kind() == io::ErrorKind::PermissionDenied {
|
|
format!("cannot save {}: the file is locked or read-only ({e}); {UNCHANGED}", path.display())
|
|
} else {
|
|
format!("cannot save {}: {e}; {UNCHANGED}", path.display())
|
|
};
|
|
io::Error::new(e.kind(), msg)
|
|
}
|
|
|
|
fn is_cross_device(e: &io::Error) -> bool {
|
|
// EXDEV is 18 on Linux, macOS and the BSDs; ERROR_NOT_SAME_DEVICE is 17 on Windows.
|
|
e.kind() == io::ErrorKind::CrossesDevices || (cfg!(unix) && e.raw_os_error() == Some(18)) || (cfg!(windows) && e.raw_os_error() == Some(17))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|