using System.Runtime.InteropServices; using System.Text.RegularExpressions; namespace FluidExplorer.Services.Operations; /// /// 路径与文件系统元数据工具。 /// /// 设计约定(整个 Operations 引擎统一遵守): /// 1. 引擎内部、作业描述、UndoEntry 里保存的都是"普通路径"(不带 \\?\ 前缀), /// 只有真正触达 BCL / Win32 IO 的那一刻才通过 转换, /// 避免 \\?\ 前缀泄漏到 UI 显示、$I 解析、Shell API 调用里(Shell API 不接受前缀)。 /// 2. 所有拼接都用 (手工拼分隔符),不用 Path.Combine 后直接丢给 API, /// 以保证超长路径(>260)在所有环节都能正确走到 \\?\ 分支。 /// 3. 所有 IO 调用一律走 ,NET8 虽然自身也会兜底长路径, /// 但统一处理后行为可预期(尤其是 UNC:\\server\share → \\?\UNC\server\share)。 /// internal static partial class PathHelper { internal const string ExtendedPrefix = @"\\?\"; internal const string ExtendedUncPrefix = @"\\?\UNC\"; [GeneratedRegex(@"^(.*) \((\d+)\)$", RegexOptions.CultureInvariant)] private static partial Regex CopySuffixRegex(); /// 安全取全路径;非法路径(空、含非法字符、超长到无法规范化)返回 null 而不抛。 internal static string? TryGetFullPath(string? path) { if (string.IsNullOrWhiteSpace(path)) return null; try { return Path.GetFullPath(path); } catch (Exception) { return null; } } /// 转成 Win32 长路径形式(\\?\ 或 \\?\UNC\)。已是前缀形式则原样返回。 internal static string ToExtended(string path) { if (string.IsNullOrEmpty(path)) return path; if (path.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal) || path.StartsWith(ExtendedPrefix, StringComparison.Ordinal)) return path; string full; try { full = Path.GetFullPath(path); } catch (Exception) { // 无法规范化:原样返回,让后续 API 抛出可读异常,由重试/失败统计兜住。 return path; } if (full.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal) || full.StartsWith(ExtendedPrefix, StringComparison.Ordinal)) return full; // UNC:\\server\share\x → \\?\UNC\server\share\x if (full.StartsWith(@"\\", StringComparison.Ordinal)) return ExtendedUncPrefix + full[2..]; return ExtendedPrefix + full; } /// 去掉长路径前缀,得到可显示/可交给 Shell API 的普通路径。 internal static string StripExtended(string path) { if (string.IsNullOrEmpty(path)) return path; if (path.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal)) return @"\\" + path[ExtendedUncPrefix.Length..]; if (path.StartsWith(ExtendedPrefix, StringComparison.Ordinal)) return path[ExtendedPrefix.Length..]; return path; } /// 手工拼接子路径(不依赖 Path.Combine 的根路径语义)。 internal static string Combine(string directory, string name) { if (string.IsNullOrEmpty(directory)) return name; var last = directory[^1]; return last is '\\' or '/' ? directory + name : directory + Path.DirectorySeparatorChar + name; } /// 取最后一段名字(同时兼容带/不带 \\?\ 前缀)。 internal static string GetFileName(string path) { var p = StripExtended(path); if (string.IsNullOrEmpty(p)) return p; var trimmed = p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (trimmed.Length == 0) return p; // 卷根,如 "E:\" var idx = trimmed.LastIndexOfAny(['\\', '/']); return idx < 0 ? trimmed : trimmed[(idx + 1)..]; } /// 取父目录;已是卷根时返回卷根本身(不返回 null,方便调用方继续拼接)。 internal static string GetDirectoryName(string path) { var p = StripExtended(path); var trimmed = p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var idx = trimmed.LastIndexOfAny(['\\', '/']); if (idx < 0) return p; var parent = trimmed[..idx]; // "E:" → "E:\" if (parent.Length == 2 && parent[1] == ':') return parent + Path.DirectorySeparatorChar; if (parent.Length == 0) return @"\"; return parent; } /// 取卷根(用于同卷判定);UNC 时返回 \\server\share\。 internal static string GetVolumeRoot(string path) { var full = TryGetFullPath(path) ?? StripExtended(path); var root = Path.GetPathRoot(full); return string.IsNullOrEmpty(root) ? full : root; } /// 是否同一卷(同卷 Move 才能走瞬时的 File.Move/Directory.Move)。 internal static bool SameVolume(string a, string b) => string.Equals(GetVolumeRoot(a), GetVolumeRoot(b), StringComparison.OrdinalIgnoreCase); internal static bool FileExists(string path) { try { return File.Exists(ToExtended(path)); } catch (Exception) { return false; } } internal static bool DirectoryExists(string path) { try { return Directory.Exists(ToExtended(path)); } catch (Exception) { return false; } } internal static bool Exists(string path) => FileExists(path) || DirectoryExists(path); internal static FileAttributes? TryGetAttributes(string path) { try { return File.GetAttributes(ToExtended(path)); } catch (Exception) { return null; } } internal static bool IsDirectory(string path) => (TryGetAttributes(path) ?? 0) is var a && (a & FileAttributes.Directory) != 0; internal static bool IsReparsePoint(string path) => (TryGetAttributes(path) ?? 0) is var a && (a & FileAttributes.ReparsePoint) != 0; internal static long TryGetLength(string path) { try { return new FileInfo(ToExtended(path)).Length; } catch (Exception) { return 0; } } /// 清掉只读属性,否则覆盖/删除会抛 UnauthorizedAccessException。 internal static void ClearReadOnly(string path) { try { var attrs = File.GetAttributes(ToExtended(path)); if ((attrs & FileAttributes.ReadOnly) != 0) File.SetAttributes(ToExtended(path), attrs & ~FileAttributes.ReadOnly); } catch (Exception) { // 不存在或无权访问:交给真正的操作去抛错,这里不吞掉信息。 } } /// 确保父目录存在(撤销时目标父目录可能已被删掉)。 internal static void EnsureParentDirectory(string path) { var parent = GetDirectoryName(path); if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(ToExtended(parent)); } /// /// 生成 "名字 (2).ext" 风格的不冲突路径;若本身已带 " (n)" 后缀,先剥离再递增, /// 避免出现 "a (2) (2).txt" 这种叠加命名。 /// internal static string MakeUniquePath(string desiredPath) { if (!Exists(desiredPath)) return desiredPath; var directory = GetDirectoryName(desiredPath); var name = GetFileName(desiredPath); var ext = Path.GetExtension(name); var stem = ext.Length > 0 ? name[..^ext.Length] : name; var m = CopySuffixRegex().Match(stem); if (m.Success) stem = m.Groups[1].Value; for (var i = 2; i < 100_000; i++) { var candidate = Combine(directory, $"{stem} ({i}){ext}"); if (!Exists(candidate)) return candidate; } throw new IOException($"无法为“{desiredPath}”生成不冲突的新名称。"); } /// 校验文件名(重命名/新建文件夹用),错误信息为中文。 internal static bool TryValidateFileName(string? name, out string? error) { error = null; if (string.IsNullOrWhiteSpace(name)) { error = "名称不能为空。"; return false; } if (name.Length > 255) { error = "名称过长(最多 255 个字符)。"; return false; } var invalid = Path.GetInvalidFileNameChars(); if (name.IndexOfAny(invalid) >= 0) { var bad = new string(name.Where(c => Array.IndexOf(invalid, c) >= 0).Distinct().ToArray()); error = $"名称包含非法字符:{bad}"; return false; } if (name.EndsWith(' ') || name.EndsWith('.')) { error = "名称不能以空格或点结尾。"; return false; } if (name.TrimEnd(' ', '.').Length == 0) { error = "名称不能只由空格或点组成。"; return false; } var stem = Path.GetFileNameWithoutExtension(name); if (IsReservedDeviceName(stem)) { error = $"“{stem}”是 Windows 保留设备名,不能用作文件名。"; return false; } return true; } private static bool IsReservedDeviceName(string stem) { if (stem.Length is 3 or 4) { if (stem.Equals("CON", StringComparison.OrdinalIgnoreCase) || stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) || stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) || stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)) return true; if (stem.Length == 4 && stem[3] is >= '1' and <= '9') { var head = stem[..3]; if (head.Equals("COM", StringComparison.OrdinalIgnoreCase) || head.Equals("LPT", StringComparison.OrdinalIgnoreCase)) return true; } } return false; } /// /// 安全枚举某个目录的直接子项(返回普通路径)。 /// 单个子目录无权限/枚举中途出错时返回已拿到的部分并回调警告,绝不抛出。 /// internal static List EnumerateChildrenSafe(string directory, Action? onWarning = null) { var result = new List(); IEnumerator? enumerator = null; try { enumerator = Directory.EnumerateFileSystemEntries(ToExtended(directory)).GetEnumerator(); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) { onWarning?.Invoke($"无法枚举目录“{directory}”:{ex.Message}"); return result; } try { while (true) { string current; try { if (!enumerator.MoveNext()) break; current = enumerator.Current; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { onWarning?.Invoke($"枚举目录“{directory}”时中断:{ex.Message}"); break; } result.Add(StripExtended(current)); } } finally { enumerator.Dispose(); } return result; } /// 把 \\?\ 前缀路径还原成普通路径(供 P/Invoke Shell API 使用)。 internal static string ToShellPath(string path) => StripExtended(path); /// 判断 extended 前缀是否已存在(调试用)。 internal static bool HasExtendedPrefix(string path) => path.StartsWith(ExtendedPrefix, StringComparison.Ordinal); /// 分配 UTF-16 双 null 结尾路径列表(SHFileOperationW 要求)。 internal static IntPtr AllocDoubleNullList(IEnumerable paths) { var joined = string.Join('\0', paths) + "\0\0"; return Marshal.StringToHGlobalUni(joined); } }