// 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, ) -> 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, ) -> 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 { 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::>() .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( path: &Path, operation: &str, action: F, ) -> std::io::Result where F: FnMut() -> Fut, Fut: Future>, { 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( path: &Path, operation: &str, delays: &[std::time::Duration], mut action: F, ) -> std::io::Result where F: FnMut() -> Fut, Fut: Future>, { 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( _path: &Path, _operation: &str, mut action: F, ) -> std::io::Result where F: FnMut() -> Fut, Fut: Future>, { action().await } pub fn canonicalize( path: impl AsRef, ) -> Result { 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, ) -> Result { 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, ) -> 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, ) -> 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, ) -> 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, ) -> 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, ) -> Result, 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, 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, ) -> 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 { #[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, to: impl AsRef, ) -> 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, to: impl AsRef, ) -> Result { 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, to: impl AsRef, ) -> 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, ) -> 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, ) -> 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, ) -> 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, ) -> Result { 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, link: impl AsRef, ) -> 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}" ); } } }