1000 lines
31 KiB
Rust
1000 lines
31 KiB
Rust
// IO error
|
|
// A wrapper around the tokio IO functions that adds the path to the error message, instead of the uninformative std::io::Error.
|
|
|
|
use eyre::{Context, ContextCompat, Result, eyre};
|
|
use std::{
|
|
future::Future,
|
|
io::{ErrorKind, Write},
|
|
path::{Path, PathBuf},
|
|
};
|
|
use tempfile::NamedTempFile;
|
|
use tokio::task::spawn_blocking;
|
|
use tracing::warn;
|
|
|
|
use crate::util::file_lock::get_locking_processes;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum IOError {
|
|
#[error("{source}, path: {path}")]
|
|
IOPathError {
|
|
#[source]
|
|
source: std::io::Error,
|
|
path: String,
|
|
},
|
|
#[error(transparent)]
|
|
IOError(#[from] std::io::Error),
|
|
}
|
|
|
|
impl IOError {
|
|
pub fn from(source: std::io::Error) -> Self {
|
|
Self::IOError(source)
|
|
}
|
|
pub fn with_path(
|
|
source: std::io::Error,
|
|
path: impl AsRef<std::path::Path>,
|
|
) -> Self {
|
|
let path = path.as_ref();
|
|
|
|
Self::IOPathError {
|
|
source,
|
|
path: path.to_string_lossy().to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn kind(&self) -> ErrorKind {
|
|
match self {
|
|
IOError::IOPathError { source, .. } => source.kind(),
|
|
IOError::IOError(source) => source.kind(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if an `std::io::Error` is a permission / sharing-violation that may
|
|
/// indicate another process holds a file lock. If so, attempt to detect the
|
|
/// locking processes and return a richer `IOError` with their details appended.
|
|
pub(crate) fn io_error_with_lock_info(
|
|
source: std::io::Error,
|
|
path: impl AsRef<std::path::Path>,
|
|
) -> IOError {
|
|
let path = path.as_ref();
|
|
io_error_with_lock_info_for_paths(source, path, &[path])
|
|
}
|
|
|
|
/// Join a user-provided relative path onto `root`, rejecting traversal.
|
|
pub(crate) fn join_within_root(
|
|
root: &Path,
|
|
relative: &str,
|
|
) -> crate::Result<PathBuf> {
|
|
let normalized = relative.replace('\\', "/");
|
|
let path = Path::new(&normalized);
|
|
if path.is_absolute() {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Inner path must be relative".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
|
|
let mut safe = PathBuf::new();
|
|
for component in path.components() {
|
|
match component {
|
|
std::path::Component::Normal(part) => safe.push(part),
|
|
std::path::Component::CurDir => {}
|
|
std::path::Component::ParentDir
|
|
| std::path::Component::RootDir
|
|
| std::path::Component::Prefix(_) => {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Inner path escapes the source root".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
}
|
|
}
|
|
if safe.as_os_str().is_empty() {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Inner path must not be empty".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
Ok(root.join(safe))
|
|
}
|
|
|
|
pub(crate) fn io_error_with_lock_info_for_paths(
|
|
source: std::io::Error,
|
|
error_path: &Path,
|
|
lock_paths: &[&Path],
|
|
) -> IOError {
|
|
let raw_os_error = source.raw_os_error();
|
|
let is_lock_error = source.kind() == ErrorKind::PermissionDenied
|
|
|| (cfg!(windows) && raw_os_error == Some(32))
|
|
|| (cfg!(target_os = "linux") && raw_os_error == Some(26));
|
|
|
|
if is_lock_error
|
|
&& let Some((locked_path, processes)) =
|
|
lock_paths.iter().find_map(|path| {
|
|
let processes = get_locking_processes(path);
|
|
(!processes.is_empty()).then_some((*path, processes))
|
|
})
|
|
{
|
|
warn!(
|
|
"File lock detected on {} — {} holding process(es)",
|
|
locked_path.display(),
|
|
processes.len()
|
|
);
|
|
let detail = processes
|
|
.iter()
|
|
.map(|p| {
|
|
format!(
|
|
" PID {} - {} (locked path: {})",
|
|
p.pid, p.name, p.path
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
let enhanced = std::io::Error::new(
|
|
source.kind(),
|
|
format!("{source}\n\nFile locked by:\n{detail}"),
|
|
);
|
|
return IOError::IOPathError {
|
|
source: enhanced,
|
|
path: error_path.to_string_lossy().to_string(),
|
|
};
|
|
}
|
|
|
|
IOError::IOPathError {
|
|
source,
|
|
path: error_path.to_string_lossy().to_string(),
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
const WINDOWS_SHARING_VIOLATION_RETRY_DELAYS: [std::time::Duration; 5] = [
|
|
std::time::Duration::from_millis(100),
|
|
std::time::Duration::from_millis(250),
|
|
std::time::Duration::from_millis(500),
|
|
std::time::Duration::from_secs(1),
|
|
std::time::Duration::from_secs(2),
|
|
];
|
|
|
|
#[cfg(windows)]
|
|
pub(crate) async fn retry_windows_sharing_violation<T, F, Fut>(
|
|
path: &Path,
|
|
operation: &str,
|
|
action: F,
|
|
) -> std::io::Result<T>
|
|
where
|
|
F: FnMut() -> Fut,
|
|
Fut: Future<Output = std::io::Result<T>>,
|
|
{
|
|
retry_windows_sharing_violation_with_delays(
|
|
path,
|
|
operation,
|
|
&WINDOWS_SHARING_VIOLATION_RETRY_DELAYS,
|
|
action,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
async fn retry_windows_sharing_violation_with_delays<T, F, Fut>(
|
|
path: &Path,
|
|
operation: &str,
|
|
delays: &[std::time::Duration],
|
|
mut action: F,
|
|
) -> std::io::Result<T>
|
|
where
|
|
F: FnMut() -> Fut,
|
|
Fut: Future<Output = std::io::Result<T>>,
|
|
{
|
|
for (retry_index, delay) in delays.iter().enumerate() {
|
|
match action().await {
|
|
Err(error) if error.raw_os_error() == Some(32) => {
|
|
warn!(
|
|
"Windows sharing violation while {operation} {}. Retry {}/{} in {} ms",
|
|
path.display(),
|
|
retry_index + 1,
|
|
delays.len(),
|
|
delay.as_millis()
|
|
);
|
|
tokio::time::sleep(*delay).await;
|
|
}
|
|
result => return result,
|
|
}
|
|
}
|
|
|
|
action().await
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
pub(crate) async fn retry_windows_sharing_violation<T, F, Fut>(
|
|
_path: &Path,
|
|
_operation: &str,
|
|
mut action: F,
|
|
) -> std::io::Result<T>
|
|
where
|
|
F: FnMut() -> Fut,
|
|
Fut: Future<Output = std::io::Result<T>>,
|
|
{
|
|
action().await
|
|
}
|
|
|
|
pub fn canonicalize(
|
|
path: impl AsRef<std::path::Path>,
|
|
) -> Result<std::path::PathBuf, IOError> {
|
|
let path = path.as_ref();
|
|
dunce::canonicalize(path).map_err(|e| IOError::IOPathError {
|
|
source: e,
|
|
path: path.to_string_lossy().to_string(),
|
|
})
|
|
}
|
|
|
|
pub async fn read_dir(
|
|
path: impl AsRef<std::path::Path>,
|
|
) -> Result<tokio::fs::ReadDir, IOError> {
|
|
let path = path.as_ref();
|
|
tokio::fs::read_dir(path)
|
|
.await
|
|
.map_err(|e| IOError::IOPathError {
|
|
source: e,
|
|
path: path.to_string_lossy().to_string(),
|
|
})
|
|
}
|
|
|
|
pub async fn create_dir(
|
|
path: impl AsRef<std::path::Path>,
|
|
) -> Result<(), IOError> {
|
|
let path = path.as_ref();
|
|
tokio::fs::create_dir(path)
|
|
.await
|
|
.map_err(|e| IOError::IOPathError {
|
|
source: e,
|
|
path: path.to_string_lossy().to_string(),
|
|
})
|
|
}
|
|
|
|
pub async fn create_dir_all(
|
|
path: impl AsRef<std::path::Path>,
|
|
) -> Result<(), IOError> {
|
|
let path = path.as_ref();
|
|
tokio::fs::create_dir_all(path)
|
|
.await
|
|
.map_err(|e| IOError::IOPathError {
|
|
source: e,
|
|
path: path.to_string_lossy().to_string(),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn is_symlink_or_reparse(meta: &std::fs::Metadata) -> bool {
|
|
if meta.file_type().is_symlink() {
|
|
return true;
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::fs::MetadataExt;
|
|
if meta.file_attributes() & 0x400 != 0 {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
pub async fn remove_dir_all(
|
|
path: impl AsRef<std::path::Path>,
|
|
) -> Result<(), IOError> {
|
|
let path = path.as_ref().to_path_buf();
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
fn remove_safe(path: &Path) -> Result<(), IOError> {
|
|
let meta = match std::fs::symlink_metadata(path) {
|
|
Ok(m) => m,
|
|
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(()),
|
|
Err(e) => return Err(io_error_with_lock_info(e, path)),
|
|
};
|
|
|
|
if is_symlink_or_reparse(&meta) {
|
|
return std::fs::remove_file(path)
|
|
.or_else(|_| std::fs::remove_dir(path))
|
|
.map_err(|e| io_error_with_lock_info(e, path));
|
|
}
|
|
|
|
if meta.is_file() {
|
|
return std::fs::remove_file(path)
|
|
.map_err(|e| io_error_with_lock_info(e, path));
|
|
}
|
|
|
|
let rd = match std::fs::read_dir(path) {
|
|
Ok(d) => d,
|
|
Err(e) => return Err(io_error_with_lock_info(e, path)),
|
|
};
|
|
for entry in rd {
|
|
let entry = match entry {
|
|
Ok(e) => e,
|
|
Err(e) => return Err(io_error_with_lock_info(e, path)),
|
|
};
|
|
remove_safe(&entry.path())?;
|
|
}
|
|
|
|
std::fs::remove_dir(path)
|
|
.map_err(|e| io_error_with_lock_info(e, path))
|
|
}
|
|
|
|
remove_safe(&path)
|
|
})
|
|
.await
|
|
.map_err(|e| {
|
|
IOError::IOError(std::io::Error::other(format!(
|
|
"background task failed: {e}"
|
|
)))
|
|
})?
|
|
}
|
|
|
|
/// Reads a text file to a string, automatically detecting its encoding and
|
|
/// substituting any invalid characters with the Unicode replacement character.
|
|
///
|
|
/// This function is best suited for reading Minecraft instance files, whose
|
|
/// encoding may vary depending on the platform, launchers, client versions
|
|
/// (older Minecraft versions tended to rely on the system's default codepage
|
|
/// more on Windows platforms), and mods used, while not being highly sensitive
|
|
/// to occasional occurrences of mojibake or character replacements.
|
|
pub async fn read_any_encoding_to_string(
|
|
path: impl AsRef<std::path::Path>,
|
|
) -> Result<(String, &'static encoding_rs::Encoding), IOError> {
|
|
let path = path.as_ref();
|
|
let file_bytes =
|
|
tokio::fs::read(path)
|
|
.await
|
|
.map_err(|e| IOError::IOPathError {
|
|
source: e,
|
|
path: path.to_string_lossy().to_string(),
|
|
})?;
|
|
|
|
let file_encoding = {
|
|
let mut encoding_detector = chardetng::EncodingDetector::new();
|
|
encoding_detector.feed(&file_bytes, true);
|
|
encoding_detector.guess(None, true)
|
|
};
|
|
|
|
let (file_string, actual_file_encoding, _) =
|
|
file_encoding.decode(&file_bytes);
|
|
Ok((file_string.to_string(), actual_file_encoding))
|
|
}
|
|
|
|
pub async fn read(
|
|
path: impl AsRef<std::path::Path>,
|
|
) -> Result<Vec<u8>, IOError> {
|
|
let path = path.as_ref();
|
|
tokio::fs::read(path)
|
|
.await
|
|
.map_err(|e| io_error_with_lock_info(e, path))
|
|
}
|
|
|
|
pub async fn write(
|
|
path: impl AsRef<std::path::Path>,
|
|
data: impl AsRef<[u8]>,
|
|
) -> Result<(), IOError> {
|
|
let path = path.as_ref().to_owned();
|
|
let data = data.as_ref().to_owned();
|
|
spawn_blocking(move || {
|
|
let cloned_path = path.clone();
|
|
sync_write(data, path)
|
|
.map_err(|e| io_error_with_lock_info(e, &cloned_path))
|
|
})
|
|
.await
|
|
.map_err(|_| std::io::Error::other("background task failed"))??;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn sync_write(
|
|
data: impl AsRef<[u8]>,
|
|
path: impl AsRef<Path>,
|
|
) -> Result<(), std::io::Error> {
|
|
let mut tempfile =
|
|
NamedTempFile::new_in(path.as_ref().parent().ok_or_else(|| {
|
|
std::io::Error::other(
|
|
"could not get parent directory for temporary file",
|
|
)
|
|
})?)?;
|
|
tempfile.write_all(data.as_ref())?;
|
|
let tmp_path = tempfile.into_temp_path();
|
|
let path = path.as_ref();
|
|
tmp_path.persist(path)?;
|
|
std::io::Result::Ok(())
|
|
}
|
|
|
|
pub fn is_same_disk(old_dir: &Path, new_dir: &Path) -> Result<bool> {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
use eyre::eyre;
|
|
|
|
// we need to use `symlink_metadata` instead of `metadata`, because
|
|
// if this file is a symlink, we need to query the symlink file itself,
|
|
// rather than the target.
|
|
// downloaded JREs use symlinks to point to certain stuff like LICENSE
|
|
// files.
|
|
// this fixes moving JRE dirs.
|
|
|
|
let old_meta = std::fs::symlink_metadata(old_dir)
|
|
.wrap_err_with(|| eyre!("getting meta of old dir {old_dir:?}"))?;
|
|
let new_meta = std::fs::symlink_metadata(new_dir)
|
|
.wrap_err_with(|| eyre!("getting meta of new dir {new_dir:?}"))?;
|
|
|
|
Ok(old_meta.dev() == new_meta.dev())
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
// Extract the volume prefix from the raw path without following
|
|
// symlinks/reparse points. `canonicalize` would follow them and
|
|
// fail on dangling links (target already deleted), so we compare
|
|
// drive letters directly from the path components first.
|
|
let old_prefix = old_dir.components().next();
|
|
let new_prefix = new_dir.components().next();
|
|
|
|
let same = match (old_prefix, new_prefix) {
|
|
(
|
|
Some(std::path::Component::Prefix(old)),
|
|
Some(std::path::Component::Prefix(new)),
|
|
) => old.as_os_str() == new.as_os_str(),
|
|
_ => {
|
|
// Fall back to canonicalization for relative paths, UNC, etc.
|
|
let old_dir = canonicalize(old_dir)?;
|
|
let new_dir = canonicalize(new_dir)?;
|
|
match (old_dir.components().next(), new_dir.components().next())
|
|
{
|
|
(
|
|
Some(std::path::Component::Prefix(old)),
|
|
Some(std::path::Component::Prefix(new)),
|
|
) => old.as_os_str() == new.as_os_str(),
|
|
_ => false,
|
|
}
|
|
}
|
|
};
|
|
Ok(same)
|
|
}
|
|
}
|
|
|
|
pub async fn rename_or_move(
|
|
from: impl AsRef<std::path::Path>,
|
|
to: impl AsRef<std::path::Path>,
|
|
) -> Result<()> {
|
|
let from = from.as_ref();
|
|
let to = to.as_ref();
|
|
|
|
let to_parent = to
|
|
.parent()
|
|
.wrap_err_with(|| eyre!("getting parent of `to` dir {to:?}"))?;
|
|
let same_disk = is_same_disk(from, to_parent).wrap_err_with(|| {
|
|
eyre!("checking if `to_parent` ({to_parent:?}) and `from` ({from:?}) are on the same disk")
|
|
})?;
|
|
|
|
if same_disk {
|
|
tokio::fs::rename(from, to)
|
|
.await
|
|
.map_err(|e| io_error_with_lock_info(e, from))
|
|
.wrap_err_with(|| eyre!("moving {from:?} to {to:?} on same disk"))
|
|
} else {
|
|
move_recursive(from, to).await.with_context(|| {
|
|
eyre!("moving {from:?} to {to:?} on different disks")
|
|
})
|
|
}
|
|
}
|
|
|
|
#[async_recursion::async_recursion]
|
|
async fn move_recursive(from: &Path, to: &Path) -> Result<()> {
|
|
let meta = tokio::fs::symlink_metadata(from)
|
|
.await
|
|
.wrap_err_with(|| eyre!("getting metadata of {from:?}"))?;
|
|
|
|
// Symlink / reparse point: recreate the link at the destination and
|
|
// remove the source link. Never follow/copy the target.
|
|
if is_symlink_or_reparse(&meta) {
|
|
let target = tokio::fs::read_link(from)
|
|
.await
|
|
.wrap_err_with(|| eyre!("reading link target of {from:?}"))?;
|
|
create_symlink(&target, to).await.wrap_err_with(|| {
|
|
eyre!("recreating symlink at {to:?} (target: {target:?})")
|
|
})?;
|
|
// Remove the source link. On Unix, remove_file works for all
|
|
// symlinks. On Windows, directory reparse points (junctions)
|
|
// may need remove_dir as fallback.
|
|
if tokio::fs::remove_file(from).await.is_err() {
|
|
tokio::fs::remove_dir(from)
|
|
.await
|
|
.map_err(|e| io_error_with_lock_info(e, from))
|
|
.wrap_err_with(|| eyre!("removing source link {from:?}"))?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
if meta.is_file() {
|
|
copy(from, to)
|
|
.await
|
|
.wrap_err_with(|| eyre!("copying {from:?} to {to:?}"))?;
|
|
remove_file(from).await.wrap_err_with(|| {
|
|
eyre!("removing {from:?} after copying to {to:?}")
|
|
})?;
|
|
return Ok(());
|
|
}
|
|
|
|
create_dir(to)
|
|
.await
|
|
.wrap_err_with(|| eyre!("creating dir for {to:?}"))?;
|
|
|
|
let mut dir = read_dir(from)
|
|
.await
|
|
.wrap_err_with(|| eyre!("reading dir {from:?}"))?;
|
|
while let Some(entry) = dir
|
|
.next_entry()
|
|
.await
|
|
.wrap_err_with(|| eyre!("reading dir entry in {from:?}"))?
|
|
{
|
|
let new_path = to.join(entry.file_name());
|
|
move_recursive(&entry.path(), &new_path)
|
|
.await
|
|
.with_context(|| {
|
|
eyre!("moving {:?} to {new_path:?}", entry.path())
|
|
})?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn copy(
|
|
from: impl AsRef<std::path::Path>,
|
|
to: impl AsRef<std::path::Path>,
|
|
) -> Result<u64, IOError> {
|
|
let from = from.as_ref().to_path_buf();
|
|
let to = to.as_ref().to_path_buf();
|
|
|
|
// Never follow symlinks / reparse points — recreate the link at the
|
|
// destination instead of copying the target's content.
|
|
let meta = tokio::fs::symlink_metadata(&from)
|
|
.await
|
|
.map_err(|e| io_error_with_lock_info(e, &from))?;
|
|
|
|
if is_symlink_or_reparse(&meta) {
|
|
let target = tokio::fs::read_link(&from)
|
|
.await
|
|
.map_err(|e| io_error_with_lock_info(e, &from))?;
|
|
create_symlink(&target, &to).await?;
|
|
return Ok(0);
|
|
}
|
|
|
|
retry_windows_sharing_violation(&to, "copying", || {
|
|
let from = from.clone();
|
|
let to = to.clone();
|
|
async move { tokio::fs::copy(from, to).await }
|
|
})
|
|
.await
|
|
.map_err(|e| io_error_with_lock_info(e, &from))
|
|
}
|
|
|
|
/// Recursively copy a directory from `from` to `to`.
|
|
///
|
|
/// Creates the target directory and recursively copies all files and
|
|
/// subdirectories. Directory symlinks / junctions are followed and their
|
|
/// target subtree is materialized as a real directory; file links are copied
|
|
/// as their target's content. Broken links and link cycles fail with a
|
|
/// descriptive error instead of a generic read failure.
|
|
pub async fn copy_dir(
|
|
from: impl AsRef<std::path::Path>,
|
|
to: impl AsRef<std::path::Path>,
|
|
) -> Result<(), IOError> {
|
|
let from = from.as_ref().to_path_buf();
|
|
let to = to.as_ref().to_path_buf();
|
|
copy_dir_inner(&from, &to, &mut Vec::new()).await
|
|
}
|
|
|
|
#[async_recursion::async_recursion]
|
|
async fn copy_dir_inner(
|
|
from: &Path,
|
|
to: &Path,
|
|
dir_stack: &mut Vec<PathBuf>,
|
|
) -> Result<(), IOError> {
|
|
use async_walkdir::WalkDir;
|
|
use futures::StreamExt as _;
|
|
|
|
create_dir_all(to).await?;
|
|
|
|
// Track canonicalized directories on the current recursion path so a
|
|
// symlink pointing back to an ancestor fails cleanly instead of
|
|
// recursing forever.
|
|
let canonical = canonicalize(from)?;
|
|
if dir_stack.contains(&canonical) {
|
|
return Err(IOError::with_path(
|
|
std::io::Error::other(format!(
|
|
"symlink cycle detected while copying {from:?}"
|
|
)),
|
|
from,
|
|
));
|
|
}
|
|
dir_stack.push(canonical);
|
|
|
|
let mut entries = WalkDir::new(from);
|
|
while let Some(entry) = entries.next().await {
|
|
let entry = entry.map_err(|e| {
|
|
IOError::with_path(std::io::Error::other(e.to_string()), from)
|
|
})?;
|
|
|
|
// Skip macOS resource forks.
|
|
if entry
|
|
.file_name()
|
|
.to_str()
|
|
.map(|s| s.starts_with("__MACOSX"))
|
|
.unwrap_or(false)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let entry_path = entry.path().to_path_buf();
|
|
let relative = entry_path.strip_prefix(from).map_err(|_| {
|
|
IOError::with_path(
|
|
std::io::Error::other("path prefix mismatch"),
|
|
&entry_path,
|
|
)
|
|
})?;
|
|
let target = to.join(relative);
|
|
|
|
let meta = tokio::fs::symlink_metadata(&entry_path)
|
|
.await
|
|
.map_err(|e| IOError::with_path(e, &entry_path))?;
|
|
|
|
if is_symlink_or_reparse(&meta) {
|
|
let raw_target = tokio::fs::read_link(&entry_path)
|
|
.await
|
|
.map_err(|e| IOError::with_path(e, &entry_path))?;
|
|
// Relative link targets resolve against the link's parent
|
|
// directory, not the process working directory.
|
|
let resolved = if raw_target.is_absolute() {
|
|
raw_target
|
|
} else {
|
|
entry_path
|
|
.parent()
|
|
.unwrap_or_else(|| Path::new(""))
|
|
.join(raw_target)
|
|
};
|
|
let target_meta = match tokio::fs::metadata(&resolved).await {
|
|
Ok(meta) => meta,
|
|
Err(_) => {
|
|
return Err(IOError::with_path(
|
|
std::io::Error::other(format!(
|
|
"broken symlink {entry_path:?} points to missing target {resolved:?}"
|
|
)),
|
|
&entry_path,
|
|
));
|
|
}
|
|
};
|
|
if target_meta.is_dir() {
|
|
// Materialize the directory link: copy the target subtree as
|
|
// a real directory instead of recreating the link.
|
|
copy_dir_inner(&resolved, &target, dir_stack).await?;
|
|
} else {
|
|
// Materialize file links as their target's content.
|
|
let bytes = read(&entry_path).await?;
|
|
write(&target, bytes).await?;
|
|
}
|
|
} else if meta.is_dir() {
|
|
create_dir_all(&target).await?;
|
|
} else {
|
|
let bytes = read(&entry_path).await?;
|
|
write(&target, bytes).await?;
|
|
}
|
|
}
|
|
|
|
dir_stack.pop();
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn remove_file(
|
|
path: impl AsRef<std::path::Path>,
|
|
) -> Result<(), IOError> {
|
|
let path = path.as_ref();
|
|
tokio::fs::remove_file(path)
|
|
.await
|
|
.map_err(|e| io_error_with_lock_info(e, path))
|
|
}
|
|
|
|
pub async fn remove_dir(
|
|
path: impl AsRef<std::path::Path>,
|
|
) -> Result<(), IOError> {
|
|
let path = path.as_ref();
|
|
tokio::fs::remove_dir(path)
|
|
.await
|
|
.map_err(|e| io_error_with_lock_info(e, path))
|
|
}
|
|
|
|
pub async fn metadata(
|
|
path: impl AsRef<std::path::Path>,
|
|
) -> Result<std::fs::Metadata, IOError> {
|
|
let path = path.as_ref();
|
|
tokio::fs::metadata(path)
|
|
.await
|
|
.map_err(|e| IOError::IOPathError {
|
|
source: e,
|
|
path: path.to_string_lossy().to_string(),
|
|
})
|
|
}
|
|
|
|
/// Gets a resource file from the executable. Returns `theseus::Result<(TempDir, PathBuf)>`.
|
|
#[macro_export]
|
|
macro_rules! get_resource_file {
|
|
(directory: $relative_dir:expr, file: $file_name:expr) => {
|
|
'get_resource_file: {
|
|
let dir = match tempfile::tempdir() {
|
|
Ok(dir) => dir,
|
|
Err(e) => {
|
|
break 'get_resource_file $crate::Result::Err(
|
|
$crate::util::io::IOError::from(e).into(),
|
|
);
|
|
}
|
|
};
|
|
let path = dir.path().join($file_name);
|
|
if let Err(e) = $crate::util::io::write(
|
|
&path,
|
|
include_bytes!(concat!($relative_dir, "/", $file_name)),
|
|
)
|
|
.await
|
|
{
|
|
break 'get_resource_file $crate::Result::Err(e.into());
|
|
}
|
|
let path = match $crate::util::io::canonicalize(path) {
|
|
Ok(path) => path,
|
|
Err(e) => {
|
|
break 'get_resource_file $crate::Result::Err(e.into());
|
|
}
|
|
};
|
|
$crate::Result::Ok((dir, path))
|
|
}
|
|
};
|
|
|
|
($relative_dir:literal / $file_name:literal) => {
|
|
get_resource_file!(directory: $relative_dir, file: $file_name)
|
|
};
|
|
|
|
(env $dir_env_name:literal / $file_name:literal) => {
|
|
get_resource_file!(directory: env!($dir_env_name), file: $file_name)
|
|
};
|
|
}
|
|
|
|
pub async fn create_symlink(
|
|
target: impl AsRef<std::path::Path>,
|
|
link: impl AsRef<std::path::Path>,
|
|
) -> Result<(), IOError> {
|
|
let target = target.as_ref().to_path_buf();
|
|
let link = link.as_ref().to_path_buf();
|
|
let link_for_error = link.clone();
|
|
|
|
if let Some(parent) = link.parent() {
|
|
tokio::fs::create_dir_all(parent)
|
|
.await
|
|
.map_err(|e| IOError::with_path(e, parent))?;
|
|
}
|
|
|
|
if !target.exists() {
|
|
return Err(IOError::with_path(
|
|
std::io::Error::new(
|
|
std::io::ErrorKind::NotFound,
|
|
format!("Target path does not exist: {}", target.display()),
|
|
),
|
|
&target,
|
|
));
|
|
}
|
|
|
|
let link_target = target.clone();
|
|
let link_path = link.clone();
|
|
let result = spawn_blocking(move || {
|
|
super::symlink::create_link_blocking(&link_target, &link_path)
|
|
})
|
|
.await
|
|
.map_err(|e| {
|
|
IOError::with_path(
|
|
std::io::Error::other(format!("symlink task panicked: {e}")),
|
|
&link_for_error,
|
|
)
|
|
})?;
|
|
|
|
match result {
|
|
Ok(()) => Ok(()),
|
|
Err(error) => {
|
|
#[cfg(target_os = "windows")]
|
|
if error.kind() == ErrorKind::PermissionDenied
|
|
|| error.raw_os_error() == Some(1314)
|
|
{
|
|
// Symbolic links need administrator privileges when Windows
|
|
// Developer Mode is off. Run the creation in a short-lived
|
|
// elevated helper process (UAC prompt) instead of elevating
|
|
// the launcher itself, which would break drag-and-drop.
|
|
return super::symlink::create_link_elevated(&target, &link)
|
|
.await
|
|
.map_err(|e| IOError::with_path(e, &link_for_error));
|
|
}
|
|
Err(IOError::with_path(error, &link_for_error))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, windows))]
|
|
mod windows_sharing_violation_tests {
|
|
use super::*;
|
|
use std::sync::{
|
|
Arc,
|
|
atomic::{AtomicUsize, Ordering},
|
|
};
|
|
|
|
#[tokio::test]
|
|
async fn retries_sharing_violation_then_succeeds() {
|
|
let attempts = Arc::new(AtomicUsize::new(0));
|
|
let action_attempts = attempts.clone();
|
|
let delays = [std::time::Duration::ZERO; 2];
|
|
|
|
retry_windows_sharing_violation_with_delays(
|
|
Path::new("locked.test"),
|
|
"testing",
|
|
&delays,
|
|
move || {
|
|
let attempt = action_attempts.fetch_add(1, Ordering::SeqCst);
|
|
async move {
|
|
if attempt < 2 {
|
|
Err(std::io::Error::from_raw_os_error(32))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.await
|
|
.expect("third attempt succeeds");
|
|
|
|
assert_eq!(attempts.load(Ordering::SeqCst), 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn does_not_retry_other_errors() {
|
|
let attempts = Arc::new(AtomicUsize::new(0));
|
|
let action_attempts = attempts.clone();
|
|
let delays = [std::time::Duration::ZERO; 2];
|
|
|
|
let error = retry_windows_sharing_violation_with_delays(
|
|
Path::new("failed.test"),
|
|
"testing",
|
|
&delays,
|
|
move || {
|
|
action_attempts.fetch_add(1, Ordering::SeqCst);
|
|
async { Err::<(), _>(std::io::Error::from_raw_os_error(5)) }
|
|
},
|
|
)
|
|
.await
|
|
.expect_err("access denied must not be retried");
|
|
|
|
assert_eq!(error.raw_os_error(), Some(5));
|
|
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sharing_violation_retries_are_bounded() {
|
|
let attempts = Arc::new(AtomicUsize::new(0));
|
|
let action_attempts = attempts.clone();
|
|
let delays = [std::time::Duration::ZERO; 2];
|
|
|
|
let error = retry_windows_sharing_violation_with_delays(
|
|
Path::new("locked.test"),
|
|
"testing",
|
|
&delays,
|
|
move || {
|
|
action_attempts.fetch_add(1, Ordering::SeqCst);
|
|
async { Err::<(), _>(std::io::Error::from_raw_os_error(32)) }
|
|
},
|
|
)
|
|
.await
|
|
.expect_err("sharing violation remains after retry budget");
|
|
|
|
assert_eq!(error.raw_os_error(), Some(32));
|
|
assert_eq!(attempts.load(Ordering::SeqCst), 3);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod copy_dir_tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[tokio::test]
|
|
async fn copy_dir_copies_plain_tree() {
|
|
let src = tempdir().expect("temp src");
|
|
std::fs::create_dir(src.path().join("sub")).expect("sub dir");
|
|
std::fs::write(src.path().join("root.txt"), b"root")
|
|
.expect("write root");
|
|
std::fs::write(src.path().join("sub/nested.txt"), b"nested")
|
|
.expect("write nested");
|
|
|
|
let dst = tempdir().expect("temp dst");
|
|
copy_dir(src.path(), dst.path().join("out"))
|
|
.await
|
|
.expect("copy");
|
|
|
|
assert_eq!(
|
|
std::fs::read(dst.path().join("out/root.txt")).expect("read root"),
|
|
b"root"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read(dst.path().join("out/sub/nested.txt"))
|
|
.expect("read nested"),
|
|
b"nested"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
mod unix_symlinks {
|
|
use super::*;
|
|
use std::os::unix::fs::symlink;
|
|
|
|
#[tokio::test]
|
|
async fn copy_dir_materializes_directory_symlink() {
|
|
let src = tempdir().expect("temp src");
|
|
let real = src.path().join("real");
|
|
std::fs::create_dir(&real).expect("real dir");
|
|
std::fs::write(real.join("file.txt"), b"content")
|
|
.expect("write file");
|
|
symlink(&real, src.path().join("link")).expect("create link");
|
|
|
|
let dst = tempdir().expect("temp dst");
|
|
copy_dir(src.path(), dst.path().join("out"))
|
|
.await
|
|
.expect("copy");
|
|
|
|
let copied = dst.path().join("out/link/file.txt");
|
|
assert!(
|
|
copied.is_file(),
|
|
"directory symlink should be materialized"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read(&copied).expect("read copied file"),
|
|
b"content"
|
|
);
|
|
assert!(
|
|
std::fs::symlink_metadata(dst.path().join("out/link"))
|
|
.expect("meta")
|
|
.file_type()
|
|
.is_dir()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn copy_dir_reports_broken_symlink() {
|
|
let src = tempdir().expect("temp src");
|
|
symlink(src.path().join("missing"), src.path().join("broken"))
|
|
.expect("create broken link");
|
|
|
|
let dst = tempdir().expect("temp dst");
|
|
let err = copy_dir(src.path(), dst.path().join("out"))
|
|
.await
|
|
.expect_err("broken link should fail");
|
|
let text = format!("{err}");
|
|
assert!(
|
|
text.contains("broken symlink"),
|
|
"error should mention the broken link: {text}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn copy_dir_rejects_symlink_cycle() {
|
|
let src = tempdir().expect("temp src");
|
|
std::fs::create_dir(src.path().join("a")).expect("a dir");
|
|
symlink(src.path().join("a"), src.path().join("a/back"))
|
|
.expect("create cycle link");
|
|
|
|
let dst = tempdir().expect("temp dst");
|
|
let err = copy_dir(src.path(), dst.path().join("out"))
|
|
.await
|
|
.expect_err("cycle should fail");
|
|
let text = format!("{err}");
|
|
assert!(
|
|
text.contains("symlink cycle"),
|
|
"error should mention the cycle: {text}"
|
|
);
|
|
}
|
|
}
|
|
}
|