Files
fluid-explorer/Services/Operations/PathHelper.cs
T

331 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
namespace FluidExplorer.Services.Operations;
/// <summary>
/// 路径与文件系统元数据工具。
///
/// 设计约定(整个 Operations 引擎统一遵守):
/// 1. 引擎内部、作业描述、UndoEntry 里保存的都是"普通路径"(不带 \\?\ 前缀),
/// 只有真正触达 BCL / Win32 IO 的那一刻才通过 <see cref="ToExtended"/> 转换,
/// 避免 \\?\ 前缀泄漏到 UI 显示、$I 解析、Shell API 调用里(Shell API 不接受前缀)。
/// 2. 所有拼接都用 <see cref="Combine"/>(手工拼分隔符),不用 Path.Combine 后直接丢给 API,
/// 以保证超长路径(&gt;260)在所有环节都能正确走到 \\?\ 分支。
/// 3. 所有 IO 调用一律走 <see cref="ToExtended"/>,NET8 虽然自身也会兜底长路径,
/// 但统一处理后行为可预期(尤其是 UNC:\\server\share → \\?\UNC\server\share)。
/// </summary>
internal static partial class PathHelper
{
internal const string ExtendedPrefix = @"\\?\";
internal const string ExtendedUncPrefix = @"\\?\UNC\";
[GeneratedRegex(@"^(.*) \((\d+)\)$", RegexOptions.CultureInvariant)]
private static partial Regex CopySuffixRegex();
/// <summary>安全取全路径;非法路径(空、含非法字符、超长到无法规范化)返回 null 而不抛。</summary>
internal static string? TryGetFullPath(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return null;
try
{
return Path.GetFullPath(path);
}
catch (Exception)
{
return null;
}
}
/// <summary>转成 Win32 长路径形式(\\?\ 或 \\?\UNC\)。已是前缀形式则原样返回。</summary>
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;
}
/// <summary>去掉长路径前缀,得到可显示/可交给 Shell API 的普通路径。</summary>
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;
}
/// <summary>手工拼接子路径(不依赖 Path.Combine 的根路径语义)。</summary>
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;
}
/// <summary>取最后一段名字(同时兼容带/不带 \\?\ 前缀)。</summary>
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)..];
}
/// <summary>取父目录;已是卷根时返回卷根本身(不返回 null,方便调用方继续拼接)。</summary>
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;
}
/// <summary>取卷根(用于同卷判定);UNC 时返回 \\server\share\。</summary>
internal static string GetVolumeRoot(string path)
{
var full = TryGetFullPath(path) ?? StripExtended(path);
var root = Path.GetPathRoot(full);
return string.IsNullOrEmpty(root) ? full : root;
}
/// <summary>是否同一卷(同卷 Move 才能走瞬时的 File.Move/Directory.Move)。</summary>
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; }
}
/// <summary>清掉只读属性,否则覆盖/删除会抛 UnauthorizedAccessException。</summary>
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)
{
// 不存在或无权访问:交给真正的操作去抛错,这里不吞掉信息。
}
}
/// <summary>确保父目录存在(撤销时目标父目录可能已被删掉)。</summary>
internal static void EnsureParentDirectory(string path)
{
var parent = GetDirectoryName(path);
if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(ToExtended(parent));
}
/// <summary>
/// 生成 "名字 (2).ext" 风格的不冲突路径;若本身已带 " (n)" 后缀,先剥离再递增,
/// 避免出现 "a (2) (2).txt" 这种叠加命名。
/// </summary>
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}”生成不冲突的新名称。");
}
/// <summary>校验文件名(重命名/新建文件夹用),错误信息为中文。</summary>
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;
}
/// <summary>
/// 安全枚举某个目录的直接子项(返回普通路径)。
/// 单个子目录无权限/枚举中途出错时返回已拿到的部分并回调警告,绝不抛出。
/// </summary>
internal static List<string> EnumerateChildrenSafe(string directory, Action<string>? onWarning = null)
{
var result = new List<string>();
IEnumerator<string>? 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;
}
/// <summary>把 \\?\ 前缀路径还原成普通路径(供 P/Invoke Shell API 使用)。</summary>
internal static string ToShellPath(string path) => StripExtended(path);
/// <summary>判断 extended 前缀是否已存在(调试用)。</summary>
internal static bool HasExtendedPrefix(string path)
=> path.StartsWith(ExtendedPrefix, StringComparison.Ordinal);
/// <summary>分配 UTF-16 双 null 结尾路径列表(SHFileOperationW 要求)。</summary>
internal static IntPtr AllocDoubleNullList(IEnumerable<string> paths)
{
var joined = string.Join('\0', paths) + "\0\0";
return Marshal.StringToHGlobalUni(joined);
}
}