From f32fbf3120d7b66fe1d1e803883a1e406aff202a Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:01:55 +0200 Subject: [PATCH] fix(core): Fix symlinks and logging --- Cargo.lock | 1 + Cargo.toml | 4 +- packages/oneclient_core/Cargo.toml | 2 +- packages/oneclient_core/src/game/launch.rs | 17 +- packages/oneclient_core/src/game/mod.rs | 5 +- .../oneclient_core/src/game/shared_dir.rs | 221 ++++++++++++++---- packages/oneclient_core/src/logs/manage.rs | 10 +- packages/oneclient_core/src/logs/mod.rs | 2 +- .../oneclient_core/src/packages/store/link.rs | 13 +- packages/oneclient_core/src/paths.rs | 2 +- packages/polyio/src/file.rs | 180 +++++++++++--- 11 files changed, 365 insertions(+), 92 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b0230ef..f615c33c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4170,6 +4170,7 @@ dependencies = [ "parking_lot", "polyio", "rand 0.10.1", + "regex", "reqwest 0.13.3", "sanitize-filename", "serde", diff --git a/Cargo.toml b/Cargo.toml index 22331ea6..df448dc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,10 +113,10 @@ trash = {version = "=5.2.6"} # required for the SKSL player renderer freya-skia-safe = "=0.98.0" -freya = { git = "https://github.com/marc2332/freya.git", ref = "be57ee3184e047ac932057cd063f7858741d41d9", branch = "fix/blur", features = ["engine", "winit", "router", "remote-asset", "sdk", "markdown", "query", "titlebar"] } +freya = { git = "https://github.com/marc2332/freya.git", branch = "fix/blur", features = ["engine", "winit", "router", "remote-asset", "sdk", "markdown", "query", "titlebar"] } [patch.crates-io] -freya = { git = "https://github.com/marc2332/freya.git", ref = "be57ee3184e047ac932057cd063f7858741d41d9", branch = "fix/blur" } +freya = { git = "https://github.com/marc2332/freya.git", branch = "fix/blur" } [profile.release] opt-level = 3 diff --git a/packages/oneclient_core/Cargo.toml b/packages/oneclient_core/Cargo.toml index 5a51883f..89cfd4d3 100644 --- a/packages/oneclient_core/Cargo.toml +++ b/packages/oneclient_core/Cargo.toml @@ -94,6 +94,7 @@ trash.workspace = true strum.workspace = true serde_repr = { version = "=0.1.20" } thiserror.workspace = true +regex.workspace = true # crypto digest.workspace = true @@ -115,4 +116,3 @@ sanitize-filename.workspace = true dunce.workspace = true bytes.workspace = true indicatif.workspace = true - diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 2ced4500..ff4a2213 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -198,12 +198,22 @@ pub async fn launch_cluster( let cwd = game_dir; tokio::fs::create_dir_all(&cwd).await.ok(); - if !dedicated - && let Err(err) = + if let Err(err) = crate::game::write_allowed_symlinks(&cwd).await { + tracing::warn!(cluster_id, error = %err, "failed to write allowed_symlinks.txt"); + } + + if !dedicated { + if let Err(err) = crate::game::sync_shared_content(&state.services, &cluster, &cwd).await { tracing::warn!(cluster_id, error = %err, "failed to sync shared content"); } + // Redirect the shared dir's `logs`/`crash-reports` into this cluster's + // own folder so its output is attributable while it plays; unlinked on + // exit. Keeps the shared `.minecraft` (and the launcher's own logs dir) + // free of another cluster's leftovers. + crate::game::link_cluster_logs(&cluster, &cwd).await; + } let client_jar = paths::versions_dir()? .join(&version_name) @@ -286,7 +296,7 @@ pub async fn launch_cluster( state.games.set_pid(cluster_id, pid); state.games.set_dir(cluster_id, cwd.clone()); - let log_path = paths::logs_dir()?.join(format!("cluster-{cluster_id}.log")); + let log_path = crate::logs::cluster_output_log(&cluster)?; if let Some(parent) = log_path.parent() { tokio::fs::create_dir_all(parent).await.ok(); } @@ -347,6 +357,7 @@ pub async fn launch_cluster( if let Err(err) = crate::game::clear_shared_content(&cwd).await { tracing::warn!(cluster_id, error = %err, "failed to clear shared content on exit"); } + crate::game::unlink_cluster_logs(&cwd).await; } match status { diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index e717c123..bae80370 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -27,4 +27,7 @@ pub use metadata::{ resolve_minecraft_version, }; pub use rules::{validate_rule, validate_rules}; -pub use shared_dir::{clear_shared_content, import_manual_content, sync_shared_content}; +pub use shared_dir::{ + clear_shared_content, import_manual_content, link_cluster_logs, sync_shared_content, + unlink_cluster_logs, write_allowed_symlinks, +}; diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index 39df7c49..e54cea82 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -1,18 +1,22 @@ - use std::collections::HashSet; use std::path::Path; use oneclient_db::dao::artifact as artifact_dao; +use crate::LauncherResult; use crate::clusters::Cluster; +use crate::packages::PackageStore; use crate::packages::domain::ContentType; use crate::packages::store::{artifact_absolute_path, link_or_copy}; -use crate::packages::PackageStore; use crate::state::LauncherServices; -use crate::LauncherResult; -const SWAP_TYPES: [ContentType; 3] = - [ContentType::Mod, ContentType::ResourcePack, ContentType::Shader]; +const REDIRECTED_DIRS: [&str; 2] = ["logs", "crash-reports"]; + +const SWAP_TYPES: [ContentType; 3] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, +]; const FABRIC_DEP_OVERRIDES: &str = "config/fabric_loader_dependencies.json"; @@ -21,16 +25,15 @@ pub async fn sync_shared_content( cluster: &Cluster, game_dir: &Path, ) -> LauncherResult<()> { - tokio::fs::create_dir_all(game_dir).await.ok(); + polyio::create_dir_all(game_dir).await.ok(); import_manual_content(services, cluster, game_dir).await; for content_type in SWAP_TYPES { let dir = game_dir.join(content_type.folder_name()); - if dir.exists() { - tokio::fs::remove_dir_all(&dir).await.ok(); - } - tokio::fs::create_dir_all(&dir).await.ok(); + polyio::create_dir_all(&dir).await.ok(); + clear_content_files(&dir).await; + ensure_note(&dir, content_type).await; } let linked = PackageStore::list_linked_artifacts(cluster.id, services).await?; @@ -39,14 +42,13 @@ pub async fn sync_shared_content( continue; } - let Some(artifact) = - artifact_dao::get_artifact_by_hash(&services.db, &link.hash).await? + let Some(artifact) = artifact_dao::get_artifact_by_hash(&services.db, &link.hash).await? else { continue; }; let src = artifact_absolute_path(&artifact.path)?; - if !src.exists() { + if !polyio::try_exists(&src).await.unwrap_or(false) { tracing::warn!(hash = %link.hash, "cached artifact missing; skipping shared link"); continue; } @@ -69,7 +71,11 @@ pub async fn sync_shared_content( Ok(()) } -pub async fn import_manual_content(services: &LauncherServices, cluster: &Cluster, game_dir: &Path) { +pub async fn import_manual_content( + services: &LauncherServices, + cluster: &Cluster, + game_dir: &Path, +) { let linked = match PackageStore::list_linked_artifacts(cluster.id, services).await { Ok(linked) => linked, Err(err) => { @@ -80,7 +86,7 @@ pub async fn import_manual_content(services: &LauncherServices, cluster: &Cluste for content_type in SWAP_TYPES { let dir = game_dir.join(content_type.folder_name()); - let Ok(mut entries) = tokio::fs::read_dir(&dir).await else { + let Ok(mut entries) = polyio::read_dir(&dir).await else { continue; }; @@ -132,50 +138,187 @@ fn has_content_extension(content_type: ContentType, name: &str) -> bool { } } -const EMPTY_NOTE_NAME: &str = "WHY_IS_THIS_EMPTY.txt"; +const ALLOWED_SYMLINKS_NAME: &str = "allowed_symlinks.txt"; -fn empty_note_body(content_type: ContentType) -> String { - let noun = match content_type { - ContentType::ResourcePack => "resource packs", - ContentType::Shader => "shader packs", - _ => "mods", +pub async fn write_allowed_symlinks(game_dir: &Path) -> LauncherResult<()> { + let root = crate::paths::launcher_dir()?; + let base = polyio::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let sep = std::path::MAIN_SEPARATOR; + + let body = format!("[prefix]{}{}", base.to_string_lossy(), sep); + + polyio::write(game_dir.join(ALLOWED_SYMLINKS_NAME), body).await?; + Ok(()) +} + +const EMPTY_NOTE_NAME: &str = "PLEASE_READ_ME.txt"; + +async fn clear_content_files(dir: &Path) { + let Ok(mut entries) = polyio::read_dir(dir).await else { + return; }; - format!( - "It's empty here, but nothing is broken!\n\ + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if name == EMPTY_NOTE_NAME || name.starts_with('.') { + continue; + } + + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + if !file_type.is_file() && !file_type.is_symlink() { + continue; + } + + polyio::remove_file(&path).await.ok(); + } +} + +async fn ensure_note(dir: &Path, content_type: ContentType) { + let note = dir.join(EMPTY_NOTE_NAME); + + if polyio::try_exists(¬e).await.unwrap_or(false) { + return; + } + + polyio::write(¬e, { + let noun = content_type.folder_name(); + + format!( + "It's empty here, but nothing is broken!\n\ \n\ - OneClient keeps your {noun} safe somewhere else and only puts them here \ - while you play. When you close the game, it tidies them away again.\n\ + OneClient keeps your {noun} safe somewhere else (specifically in the global launcher cache)\ + and only puts them here while you play. When you close the game, it tidies them away again.\n\ \n\ - Want to add {noun}? The easy way is right inside OneClient. Or you can drop \ + Want to add {noun}? The best way is to do it right inside OneClient. Or you can drop \ files in this folder, and OneClient will pick them up the next time you play.\n" - ) + ) + }) + .await + .ok(); } pub async fn clear_shared_content(game_dir: &Path) -> LauncherResult<()> { for content_type in SWAP_TYPES { let dir = game_dir.join(content_type.folder_name()); - if dir.exists() { - tokio::fs::remove_dir_all(&dir).await.ok(); - } - tokio::fs::create_dir_all(&dir).await.ok(); - tokio::fs::write(dir.join(EMPTY_NOTE_NAME), empty_note_body(content_type)) - .await - .ok(); + polyio::create_dir_all(&dir).await.ok(); + clear_content_files(&dir).await; + ensure_note(&dir, content_type).await; } Ok(()) } +pub async fn link_cluster_logs(cluster: &Cluster, game_dir: &Path) { + let cluster_dir = match cluster.dir() { + Ok(dir) => dir, + Err(err) => { + tracing::warn!(error = %err, "cannot resolve cluster dir; skipping log redirect"); + return; + } + }; + + for name in REDIRECTED_DIRS { + let target = cluster_dir.join(name); + let shared = game_dir.join(name); + + if let Err(err) = redirect_dir(&shared, &target).await { + tracing::warn!( + dir = name, + error = %err, + "failed to redirect shared game dir into cluster; logs may pool in shared dir" + ); + } + } +} + +async fn redirect_dir(shared: &Path, target: &Path) -> LauncherResult<()> { + polyio::create_dir_all(target).await.ok(); + + match polyio::symlink_metadata(shared).await { + // check if a symlink from a previous launch + Ok(meta) if meta.file_type().is_symlink() => { + polyio::remove_symlink_dir(shared).await?; + } + + // real directory (most likely either done by the user or a tool) + // so instead of deleting it we first move the contents of it into the designated + // cluster folder + Ok(meta) if meta.is_dir() => { + move_dir_contents(shared, target).await; + polyio::remove_dir_all(shared).await.ok(); + } + + // some file, so remove it so the link can take its place. + Ok(_) => { + polyio::remove_file(shared).await.ok(); + } + + Err(_) => {} + } + + if let Some(parent) = shared.parent() { + polyio::create_dir_all(parent).await.ok(); + } + polyio::symlink_dir(target, shared).await?; + Ok(()) +} + +pub async fn unlink_cluster_logs(game_dir: &Path) { + for name in REDIRECTED_DIRS { + let shared = game_dir.join(name); + + match polyio::symlink_metadata(&shared).await { + Ok(meta) if meta.file_type().is_symlink() => { + if let Err(err) = polyio::remove_symlink_dir(&shared).await { + tracing::warn!(dir = name, error = %err, "failed to unlink shared log dir"); + } + } + Ok(_) => { + tracing::warn!( + dir = name, + "shared log dir is not our link; leaving as-is (next launch will salvage it)" + ); + } + Err(_) => {} + } + } +} + +async fn move_dir_contents(from: &Path, to: &Path) { + let Ok(mut entries) = polyio::read_dir(from).await else { + return; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let src = entry.path(); + let Some(name) = src.file_name() else { + continue; + }; + + let dest = to.join(name); + + if tokio::fs::rename(&src, &dest).await.is_err() { + tracing::warn!(file = %src.display(), "failed to salvage leaked log file"); + } + } +} + async fn sync_fabric_dep_overrides(cluster: &Cluster, game_dir: &Path) -> LauncherResult<()> { let src = cluster.dir()?.join(FABRIC_DEP_OVERRIDES); let dest = game_dir.join(FABRIC_DEP_OVERRIDES); - if src.exists() { + if polyio::try_exists(&src).await.unwrap_or(false) { if let Some(parent) = dest.parent() { - tokio::fs::create_dir_all(parent).await.ok(); + polyio::create_dir_all(parent).await.ok(); } - tokio::fs::copy(&src, &dest).await?; - } else if dest.exists() { - tokio::fs::remove_file(&dest).await.ok(); + polyio::copy(&src, &dest).await?; + } else if polyio::try_exists(&dest).await.unwrap_or(false) { + polyio::remove_file(&dest).await.ok(); } Ok(()) diff --git a/packages/oneclient_core/src/logs/manage.rs b/packages/oneclient_core/src/logs/manage.rs index 83946480..3c4f47d8 100644 --- a/packages/oneclient_core/src/logs/manage.rs +++ b/packages/oneclient_core/src/logs/manage.rs @@ -67,7 +67,8 @@ fn collect_dir( pub fn list_cluster_logs(cluster: &Cluster) -> LauncherResult> { let mut out = Vec::new(); - let dir = cluster.game_dir()?; + + let dir = cluster.dir()?; collect_dir( &dir.join("logs"), @@ -83,9 +84,8 @@ pub fn list_cluster_logs(cluster: &Cluster) -> LauncherResult> &mut out, ); - let captured = paths::logs_dir()?.join(format!("cluster-{}.log", cluster.id)); push_file( - captured, + cluster_output_log(cluster)?, "Launcher output".to_string(), LogKind::Game { cluster_id: cluster.id, @@ -97,6 +97,10 @@ pub fn list_cluster_logs(cluster: &Cluster) -> LauncherResult> Ok(out) } +pub fn cluster_output_log(cluster: &Cluster) -> LauncherResult { + Ok(cluster.dir()?.join("cluster-output.log")) +} + pub(super) fn lines_from(content: &str, opts: &ReadOptions) -> Vec { let mut prev = LogLevel::Unknown; let mut lines: Vec = Vec::new(); diff --git a/packages/oneclient_core/src/logs/mod.rs b/packages/oneclient_core/src/logs/mod.rs index 39d19a3d..bd20e1dc 100644 --- a/packages/oneclient_core/src/logs/mod.rs +++ b/packages/oneclient_core/src/logs/mod.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use chrono::{DateTime, Utc}; pub use error::LogsError; -pub use manage::{delete_log_at, list_cluster_logs, read_log_at}; +pub use manage::{cluster_output_log, delete_log_at, list_cluster_logs, read_log_at}; pub use mclogs::upload_log_at; pub use parse::parse_level; diff --git a/packages/oneclient_core/src/packages/store/link.rs b/packages/oneclient_core/src/packages/store/link.rs index de1ffa35..53462be1 100644 --- a/packages/oneclient_core/src/packages/store/link.rs +++ b/packages/oneclient_core/src/packages/store/link.rs @@ -49,21 +49,14 @@ pub async fn link_artifact_to_cluster( pub async fn link_or_copy(src: &Path, dest: &Path) -> LauncherResult<()> { if dest.exists() { - tokio::fs::remove_file(dest).await?; + polyio::remove_file(dest).await?; } - #[cfg(unix)] - { - if tokio::fs::symlink(src, dest).await.is_ok() { - return Ok(()); - } - } - - if tokio::fs::hard_link(src, dest).await.is_ok() { + if polyio::symlink_file(src, dest).await.is_ok() { return Ok(()); } - tokio::fs::copy(src, dest).await?; + polyio::copy(src, dest).await?; Ok(()) } diff --git a/packages/oneclient_core/src/paths.rs b/packages/oneclient_core/src/paths.rs index e183b7cd..a4f18c3d 100644 --- a/packages/oneclient_core/src/paths.rs +++ b/packages/oneclient_core/src/paths.rs @@ -61,7 +61,7 @@ pub fn clusters_dir() -> LauncherResult { } pub fn shared_minecraft_dir() -> LauncherResult { - Ok(launcher_dir()?.join("minecraft")) + Ok(launcher_dir()?.join(".minecraft")) } pub fn packages_cache_dir() -> LauncherResult { diff --git a/packages/polyio/src/file.rs b/packages/polyio/src/file.rs index f669317b..855cb247 100644 --- a/packages/polyio/src/file.rs +++ b/packages/polyio/src/file.rs @@ -9,7 +9,7 @@ use crate::{IOError, PolyIOResult}; /// Returns a stream over the entries within a directory. #[instrument( level = "debug", - skip(path), + skip(path), fields(path = %path.as_ref().display()) )] pub async fn read_dir(path: impl AsRef) -> PolyIOResult { @@ -26,7 +26,7 @@ pub async fn read_dir(path: impl AsRef) -> PolyIOResult) -> PolyIOResult<()> { @@ -46,7 +46,7 @@ pub async fn create_dir(path: impl AsRef) -> PolyIOResult<()> { /// Recursively creates a directory and all of its parent components if they are missing. #[instrument( level = "debug", - skip(path), + skip(path), fields(path = %path.as_ref().display()) )] pub async fn create_dir_all(path: impl AsRef) -> PolyIOResult<()> { @@ -62,7 +62,7 @@ pub async fn create_dir_all(path: impl AsRef) -> PolyIOResult<( /// Removes a directory at this path, after removing all its contents. Use carefully! #[instrument( level = "debug", - skip(path), + skip(path), fields(path = %path.as_ref().display()) )] pub async fn remove_dir_all(path: impl AsRef) -> PolyIOResult<()> { @@ -79,23 +79,23 @@ pub async fn remove_dir_all(path: impl AsRef) -> PolyIOResult<( /// Checks if a path exists #[instrument( level = "debug", - skip(path), + skip(path), fields(path = %path.as_ref().display()) )] pub async fn try_exists(path: impl AsRef) -> PolyIOResult { let path = path.as_ref(); - + tokio::fs::try_exists(path).await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string() + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string() }) } /// Creates a future which will open a gzip compressed file for reading and read the entire contents into a string and return said string. #[instrument( level = "debug", - skip(path), + skip(path), fields(path = %path.as_ref().display()) )] pub async fn read_gz_to_string(path: impl AsRef) -> PolyIOResult { @@ -113,7 +113,7 @@ pub async fn read_gz_to_string(path: impl AsRef) -> PolyIOResul /// Creates a future which will open a file for reading and read the entire contents into a string and return said string. #[instrument( level = "debug", - skip(path), + skip(path), fields(path = %path.as_ref().display()) )] pub async fn read_to_string(path: impl AsRef) -> PolyIOResult { @@ -130,12 +130,12 @@ pub async fn read_to_string(path: impl AsRef) -> PolyIOResult) -> PolyIOResult> { let path = path.as_ref(); - + tokio::fs::read(path) .await .map_err(|e| IOError::PathIOError { @@ -147,23 +147,23 @@ pub async fn read(path: impl AsRef) -> PolyIOResult> { /// Asynchronously read a file as JSON and return the deserialized object #[instrument( level = "debug", - skip(path), + skip(path), fields(path = %path.as_ref().display()) )] pub async fn read_json( path: impl AsRef, ) -> PolyIOResult { serde_json::from_slice(&read(&path).await?) - .map_err(|err| IOError::JsonFileParseError { - source: err, - file: path.as_ref().to_path_buf() + .map_err(|err| IOError::JsonFileParseError { + source: err, + file: path.as_ref().to_path_buf() }) } /// Asynchrously write to a file. #[instrument( level = "debug", - skip(path, data), + skip(path, data), fields(path = %path.as_ref().display()) )] pub async fn write( @@ -171,7 +171,7 @@ pub async fn write( data: impl AsRef<[u8]>, ) -> PolyIOResult<()> { let path = path.as_ref(); - + tokio::fs::write(path, data) .await .map_err(|e| IOError::PathIOError { @@ -212,7 +212,7 @@ where fields(path = %path.as_ref().display()) )] pub async fn write_stream( - path: impl AsRef, + path: impl AsRef, mut stream: S ) -> Result<(), E> where @@ -245,7 +245,7 @@ where /// Asynchronously write json to a file, creating it if it does not exist. #[instrument( level = "debug", - skip(path, data), + skip(path, data), fields(path = %path.as_ref().display()) )] pub async fn write_json( @@ -253,9 +253,9 @@ pub async fn write_json( data: T, ) -> PolyIOResult<()> { write(&path, serde_json::to_vec(&data) - .map_err(|err| IOError::JsonFileParseError { - source: err, - file: path.as_ref().to_path_buf() + .map_err(|err| IOError::JsonFileParseError { + source: err, + file: path.as_ref().to_path_buf() })? ).await } @@ -263,9 +263,9 @@ pub async fn write_json( /// Renames a file or directory to a new name, replacing the original file if `to` already exists. #[instrument( level = "debug", - skip(from, to), + skip(from, to), fields( - from = %from.as_ref().display(), + from = %from.as_ref().display(), to = %to.as_ref().display() ) )] @@ -287,9 +287,9 @@ pub async fn rename( /// Copies the contents of one file to another. This function will also copy the permission bits of the original file to the destination file. This function will overwrite the contents of to. #[instrument( level = "debug", - skip(from, to), + skip(from, to), fields( - from = %from.as_ref().display(), + from = %from.as_ref().display(), to = %to.as_ref().display() ) )] @@ -311,7 +311,7 @@ pub async fn copy( /// Removes a file from the filesystem. #[instrument( level = "debug", - skip(path), + skip(path), fields(path = %path.as_ref().display()) )] pub async fn remove_file(path: impl AsRef) -> PolyIOResult<()> { @@ -324,6 +324,124 @@ pub async fn remove_file(path: impl AsRef) -> PolyIOResult<()> }) } +/// Queries metadata about a path without following symlinks. +/// +/// Unlike [`stat`], if `path` is a symlink this returns metadata about the +/// link itself, not its target. +#[instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn symlink_metadata(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + tokio::fs::symlink_metadata(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +/// Links a file `link` to `original`. +/// +/// On Unix this creates a symlink. On Windows it creates a hard link +/// instead (both paths must then live on the same volume). Either way the +/// caller gets a file at `link` that shares the contents of `original`. +#[instrument( + level = "debug", + skip(original, link), + fields( + original = %original.as_ref().display(), + link = %link.as_ref().display() + ) +)] +pub async fn symlink_file( + original: impl AsRef, + link: impl AsRef, +) -> PolyIOResult<()> { + let original = original.as_ref(); + let link = link.as_ref(); + + #[cfg(windows)] + let res = tokio::fs::hard_link(original, link).await; + #[cfg(not(windows))] + let res = tokio::fs::symlink(original, link).await; + + res.map_err(|e| IOError::PathIOError { + source: e, + path: link.to_string_lossy().to_string(), + }) +} + +/// Links a directory `link` to `original`. +/// +/// On Windows this creates a directory *junction* (a reparse point), which +/// needs no elevated privilege unlike a real directory symlink. On Unix it +/// creates an ordinary directory symlink. Remove it with [`remove_symlink_dir`]. +#[instrument( + level = "debug", + skip(original, link), + fields( + original = %original.as_ref().display(), + link = %link.as_ref().display() + ) +)] +pub async fn symlink_dir( + original: impl AsRef, + link: impl AsRef, +) -> PolyIOResult<()> { + let original = original.as_ref(); + let link = link.as_ref(); + + // tokio has no async junction API, so the Windows path runs the blocking + // std call on the blocking pool; Unix uses tokio's async symlink directly. + #[cfg(windows)] + { + let path = link.to_string_lossy().to_string(); + let original = original.to_path_buf(); + let link = link.to_path_buf(); + return tokio::task::spawn_blocking(move || { + std::os::windows::fs::junction_point(&original, &link) + }) + .await + .map_err(std::io::Error::other)? + .map_err(|e| IOError::PathIOError { source: e, path }); + } + + #[cfg(not(windows))] + tokio::fs::symlink(original, link) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: link.to_string_lossy().to_string(), + }) +} + +/// Removes a directory link created by [`symlink_dir`]. +/// +/// On Windows a junction must be removed with `remove_dir` rather than +/// `remove_file`; this handles the platform difference. +#[instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn remove_symlink_dir(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + + #[cfg(windows)] + let res = tokio::fs::remove_dir(path).await; + + #[cfg(not(windows))] + let res = tokio::fs::remove_file(path).await; + + res.map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + /// Creates a temporary directory. #[instrument(level = "debug")] pub async fn tempdir() -> PolyIOResult { @@ -339,7 +457,7 @@ pub async fn tempfile() -> PolyIOResult { /// Makes sure a path is a valid path #[instrument( level = "debug", - skip(path), + skip(path), fields(path = %path.as_ref().display()) )] pub fn sanitize_path(path: impl AsRef) -> PathBuf { @@ -354,7 +472,7 @@ pub fn sanitize_path(path: impl AsRef) -> PathBuf { /// Returns file metadata #[instrument( level = "debug", - skip(path), + skip(path), fields(path = %path.as_ref().display()) )] pub async fn stat(path: impl AsRef) -> PolyIOResult {