PathUtils.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. namespace MeloongCore;
  2. public static class PathUtils {
  3. #region 分隔符
  4. /// <summary>
  5. /// 确保路径的末尾包含任意文件夹分隔符。
  6. /// </summary>
  7. public static string AddSlashSuffix(string folder)
  8. => folder + (folder.EndsWithF(Path.DirectorySeparatorChar) || folder.EndsWithF(Path.AltDirectorySeparatorChar) ? "" : Path.DirectorySeparatorChar);
  9. /// <summary>
  10. /// 确保路径的结尾不包含文件夹分隔符。
  11. /// </summary>
  12. public static string RemoveSlashSuffix(string folder)
  13. => folder.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
  14. #endregion
  15. #region 长路径
  16. /// <summary>
  17. /// 若路径较长,则尽量将其转换为短路径。
  18. /// <para/>结果的开头不含 <c>\\?\</c>,结尾不含分隔符。
  19. /// </summary>
  20. public static string ToShortPath(string pathName) {
  21. // 快速返回
  22. if (string.IsNullOrEmpty(pathName)) return pathName;
  23. if (!pathName.Contains(":") || pathName.Contains("://")) return pathName;
  24. if (pathName.Length <= 220) return pathName;
  25. // 开始实际处理
  26. pathName = PathUtils.ForCompare(pathName);
  27. // 尽量保留原始文件名
  28. string pathToKeep = "";
  29. string pathToShorten = pathName;
  30. if (FileUtils.Exists(pathName)) {
  31. var fileName = PathUtils.GetLastPart(pathName);
  32. if (fileName.Length >= 180) {
  33. Logger.Warn($"文件名过长,将为文件名生成短路径:{pathName}", LogBehavior.None); // 部分程序,例如 Forge 的模块加载,依赖于读取文件名进行判断,因此对文件名生成短路径是有风险的
  34. } else {
  35. pathToKeep = fileName;
  36. pathToShorten = PathUtils.RemoveLastPart(pathName);
  37. }
  38. }
  39. // 逐级向上寻找已存在的文件夹,将不存在的部分挪到 suffix,不再缩短
  40. while (!DirectoryUtils.Exists(pathToShorten) && !FileUtils.Exists(pathToShorten)) { // 如果路径不存在
  41. string? parentPath = Path.GetDirectoryName(pathToShorten);
  42. if (string.IsNullOrEmpty(parentPath) || PathUtils.IsSame(parentPath, pathToShorten)) return pathName; // 已经到达根目录,全都不存在,直接返回
  43. pathToKeep = Path.Combine(PathUtils.GetLastPart(pathToShorten), pathToKeep);
  44. pathToShorten = parentPath;
  45. }
  46. if (pathToKeep.Length >= 200) {
  47. Logger.Warn($"路径中的大部分内容都尚未建立,无法在内容不存在时缩短路径:{pathName} → {pathToShorten}", LogBehavior.None);
  48. return pathName;
  49. }
  50. // 获取短路径
  51. Func<string, string> TryGetShortPath = static pathName => {
  52. char[] buffer = new char[220];
  53. int result = GetShortPathNameW(PathUtils.ForApi(pathName), buffer, buffer.Length);
  54. return result is 0 or > 220 ? pathName : new string(buffer, 0, result);
  55. };
  56. var shortPath = TryGetShortPath(pathToShorten);
  57. // 尝试生成新的短名称,以将长度降低到 220 以下(只会在 Windows 没有为其生成过短名称的情况下才会触发,大多数情况都不会进到这里)
  58. if (Path.Combine(shortPath, pathToKeep).Length > 220) {
  59. Logger.Warn($"{nameof(GetShortPathNameW)} 未能有效缩短路径,将尝试生成新的短名称:{pathToShorten} → {shortPath}", LogBehavior.None);
  60. var candidates = new List<(string Path, string Name)>();
  61. string? candidatePath = pathToShorten;
  62. while (!string.IsNullOrEmpty(candidatePath)) {
  63. string candidateName = PathUtils.GetLastPart(candidatePath);
  64. if (candidateName.Length > 12 &&
  65. string.Equals(PathUtils.GetLastPart(TryGetShortPath(candidatePath)), candidateName, StringComparison.OrdinalIgnoreCase)) {
  66. candidates.Add((candidatePath, candidateName));
  67. }
  68. candidatePath = Path.GetDirectoryName(candidatePath);
  69. }
  70. foreach (var candidate in candidates.OrderByDescending(candidate => candidate.Name.Length)) {
  71. using var handle = CreateFileW(PathUtils.ForApi(candidate.Path), 0x40010000, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero); // GENERIC_WRITE | DELETE;共享读、写、删除;OPEN_EXISTING;FILE_FLAG_BACKUP_SEMANTICS
  72. if (handle.IsInvalid) continue;
  73. bool created = false;
  74. try {
  75. unsafe {
  76. fixed (char* originalNameBuffer = candidate.Name) {
  77. char* shortNameBuffer = stackalloc char[12]; // 8.3 名称最长 12 个 Unicode 字符
  78. var name = new UnicodeString {Length = (ushort)(candidate.Name.Length * 2), MaximumLength = (ushort)((candidate.Name.Length + 1) * 2), Buffer = originalNameBuffer};
  79. var shortName = new UnicodeString {MaximumLength = 24, Buffer = shortNameBuffer};
  80. var context = new GenerateNameContext();
  81. for (int i = 0; i < 20; i++) { // 使用 Windows 自身的 8.3 名称生成算法,并通过 context 依次生成避让冲突的候选名
  82. shortName.Length = 0;
  83. if (RtlGenerate8dot3Name(ref name, 0, ref context, ref shortName) != 0) break;
  84. string generatedName = new(shortName.Buffer, 0, shortName.Length / 2);
  85. if (SetFileShortNameW(handle, generatedName)) { created = true; break; }
  86. if (Marshal.GetLastWin32Error() != 183) break; // ERROR_ALREADY_EXISTS
  87. }
  88. }
  89. }
  90. } catch (EntryPointNotFoundException) {}
  91. if (!created) continue;
  92. shortPath = TryGetShortPath(pathToShorten);
  93. if (Path.Combine(shortPath, pathToKeep).Length <= 220) break;
  94. }
  95. }
  96. return PathUtils.RemoveSlashSuffix(PathUtils.RemoveExtendedPrefix(Path.Combine(shortPath, pathToKeep)));
  97. }
  98. [StructLayout(LayoutKind.Sequential)]
  99. private unsafe struct UnicodeString { public ushort Length, MaximumLength; public char* Buffer; }
  100. [StructLayout(LayoutKind.Sequential)]
  101. private unsafe struct GenerateNameContext { public ushort Checksum; public byte ChecksumInserted, NameLength; public fixed char NameBuffer[8]; public uint ExtensionLength; public fixed char ExtensionBuffer[4]; public uint LastIndexValue; }
  102. [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
  103. private static extern int GetShortPathNameW(string lpszLongPath, [Out] char[]? buffer, int cchBuffer);
  104. [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
  105. private static extern Microsoft.Win32.SafeHandles.SafeFileHandle CreateFileW(string fileName, uint desiredAccess, uint shareMode, IntPtr securityAttributes, uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile);
  106. [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
  107. private static extern bool SetFileShortNameW(Microsoft.Win32.SafeHandles.SafeFileHandle file, string shortName);
  108. [DllImport("ntdll.dll")]
  109. private static extern int RtlGenerate8dot3Name(ref UnicodeString name, byte allowExtendedCharacters, ref GenerateNameContext context, ref UnicodeString name8dot3);
  110. /// <summary>
  111. /// 将完整路径转换为以 <c>\\?\</c> 开头的扩展格式。
  112. /// 这会去除路径末尾的分隔符,将 / 替换为 \。
  113. /// </summary>
  114. public static string ToExtendedFormat(string pathName) {
  115. if (string.IsNullOrWhiteSpace(pathName)) return pathName;
  116. if (!pathName.Contains(@":")) return pathName; // 可能是相对路径,保持不变
  117. if (pathName.StartsWithF(@"\\?\")) return pathName; // 已经是长路径
  118. pathName = RemoveSlashSuffix(pathName).Replace('/', '\\'); // API 要求这个格式……
  119. if (pathName.StartsWithF(@"\\")) {
  120. return $@"\\?\UNC\{pathName[2..]}";
  121. } else {
  122. return $@"\\?\{pathName}";
  123. }
  124. }
  125. /// <summary>
  126. /// 去除路径开头的 <c>\\?\</c>。
  127. /// </summary>
  128. public static string RemoveExtendedPrefix(string pathName) {
  129. if (pathName.StartsWithF(@"\\?\UNC\")) return @"\\" + pathName.AfterLast(@"\\?\UNC\");
  130. if (pathName.StartsWithF(@"\\?\")) return pathName.AfterLast(@"\\?\");
  131. return pathName;
  132. }
  133. #endregion
  134. #region 路径处理
  135. /// <summary>
  136. /// 去除路径的最后一级,并移除末尾的分隔符。
  137. /// <code>
  138. /// "C:\foo\bar.txt" → "C:\foo"
  139. /// "C:\foo\bar\" → "C:\foo"
  140. /// "C:\foo\bar" → "C:\foo"
  141. /// "C:\foo\" → "C:"
  142. /// "https://foo.bar/file/pack.zip?arg=1" → "https://foo.bar/file"
  143. /// </code></summary>
  144. public static string RemoveLastPart(string pathName) {
  145. if (pathName.Contains("://")) { // 网络路径
  146. pathName = PathUtils.RemoveSlashSuffix(pathName.BeforeFirst("#").BeforeFirst("?")); // 去除参数
  147. }
  148. return PathUtils.RemoveSlashSuffix(pathName).BeforeLastOfAny([@"\", "/"]);
  149. }
  150. /// <summary>
  151. /// 获取路径的最后一级,即文件名,或当前文件夹名。
  152. /// <code>
  153. /// "C:\foo\bar.txt" → "bar.txt"
  154. /// "C:\foo\bar\" → "bar"
  155. /// "C:\foo\bar" → "bar"
  156. /// "https://foo.bar/file/pack.zip?arg=1" → "pack.zip"
  157. /// </code></summary>
  158. public static string GetLastPart(string pathName) {
  159. if (pathName.Contains("://")) { // 网络路径
  160. pathName = PathUtils.RemoveSlashSuffix(pathName.BeforeFirst("#").BeforeFirst("?")); // 去除参数
  161. } else { // 文件路径
  162. pathName = PathUtils.RemoveSlashSuffix(pathName);
  163. }
  164. return pathName.AfterLastOfAny([@"\", "/"]);
  165. }
  166. /// <summary>
  167. /// 获取路径或文件名中,仅不包含最后一级扩展名的部分。
  168. /// <code>
  169. /// "C:\foo\bar.txt" → "bar"
  170. /// "create.jar.disabled" → "create.jar"
  171. /// "https://foo.bar/file/page.xaml.vb?arg=1" → "page.xaml"
  172. /// </code>
  173. /// </summary>
  174. public static string GetFileNameWithoutExtension(string pathName)
  175. => GetLastPart(pathName).BeforeLast(".");
  176. /// <summary>
  177. /// 获取路径或文件名中的最后一级文件扩展名,不包含 <c>.</c>,固定小写。
  178. /// 如果没有 <c>.</c> 则返回空字符串。
  179. /// <code>
  180. /// "C:\FOO\BAR.TXT" → "txt"
  181. /// "create.jar.disabled" → "disabled"
  182. /// "C:\foo\bar" → ""
  183. /// "https://foo.bar/file/page.xaml.vb?arg=1" → "vb"
  184. /// </code>
  185. /// </summary>
  186. public static string GetExtension(string pathName) {
  187. var name = GetLastPart(pathName);
  188. return name.Contains(".") ? name.AfterLast(".").Lower() : "";
  189. }
  190. /// <summary>
  191. /// 获取路径的盘符,返回一个大写字母。
  192. /// 若路径不包含盘符,则返回 <c>null</c>。
  193. /// </summary>
  194. public static char? GetDiskName(string? pathName) {
  195. if (string.IsNullOrEmpty(pathName)) return null;
  196. pathName = PathUtils.RemoveExtendedPrefix(pathName!);
  197. if (pathName.StartsWithF(@"\\") || pathName.StartsWithF("/")) return null;
  198. return char.ToUpper(pathName[0]);
  199. }
  200. /// <summary>
  201. /// 统一路径格式,以便比较。
  202. /// <para/>具体而言:将短路径展开,去除前导的 <c>\\?\</c>,将分隔符改为 \,去除末尾的分隔符。
  203. /// </summary>
  204. public static string ForCompare(string pathName) {
  205. try {
  206. return PathUtils.RemoveSlashSuffix(PathUtils.RemoveExtendedPrefix(Path.GetFullPath(pathName)).Replace("/", @"\"));
  207. } catch (ArgumentException ex) {
  208. throw new ArgumentException($"路径不正确:{pathName}", ex);
  209. }
  210. }
  211. /// <summary>
  212. /// 比较两个路径是否相同。<c>null</c> 与空字符串视为相同的空路径。
  213. /// </summary>
  214. public static bool IsSame(string? path1, string? path2) {
  215. return PathUtils.Compare(path1, path2) == 0;
  216. }
  217. /// <summary>
  218. /// 比较两个路径,忽略大小写。
  219. /// <para/><c>null</c> 与空字符串视为相同的空路径,空路径排在非空路径之前。
  220. /// </summary>
  221. /// <returns>
  222. /// 小于 0:<paramref name="path1"/> 排在 <paramref name="path2"/> 之前。
  223. /// <para/>等于 0:两个路径相等。
  224. /// <para/>大于 0:<paramref name="path1"/> 排在 <paramref name="path2"/> 之后。
  225. /// </returns>
  226. public static int Compare(string? path1, string? path2) {
  227. if (string.IsNullOrEmpty(path1)) return string.IsNullOrEmpty(path2) ? 0 : -1;
  228. if (string.IsNullOrEmpty(path2)) return 1;
  229. return string.Compare(PathUtils.ForCompare(path1!), PathUtils.ForCompare(path2!), true);
  230. }
  231. /// <summary>
  232. /// 将路径转换为兼容各种 Windows API 的格式。
  233. /// <para/>具体而言:将分隔符改为 \。添加前导的 <c>\\?\</c>。若为驱动器则添加末尾分隔符,否则去除分隔符。
  234. /// </summary>
  235. public static string ForApi(string pathName) {
  236. pathName = PathUtils.ToExtendedFormat(pathName);
  237. if (pathName.EndsWithF(":")) pathName += @"\"; // 驱动器路径必须保留末尾的分隔符
  238. if (pathName.EndsWithF(@":\")) return pathName;
  239. return PathUtils.RemoveSlashSuffix(pathName);
  240. }
  241. #endregion
  242. /// <summary>
  243. /// 判断 <param name="parentPath"/> 是否为 <paramref name="childPath"/> 的父路径,或两者是否相同。
  244. /// </summary>
  245. public static bool IsParentOf(string parentPath, string childPath) {
  246. parentPath = PathUtils.ForCompare(parentPath);
  247. childPath = PathUtils.ForCompare(childPath);
  248. return childPath.StartsWithF(parentPath + @"\", true) || PathUtils.IsSame(childPath, parentPath);
  249. }
  250. }
  251. /// <summary>
  252. /// 常用路径合集。
  253. /// 所有路径均以分隔符结尾。
  254. /// </summary>
  255. public static class Paths {
  256. /// <summary>
  257. /// 可执行文件所在的文件夹。
  258. /// </summary>
  259. public static string Base => PathUtils.AddSlashSuffix(AppContext.BaseDirectory);
  260. /// <summary>
  261. /// 可执行文件所在的文件夹中,名为 <see cref="Main.AppName"/> 的子文件夹。
  262. /// </summary>
  263. public static string BaseThenName => PathUtils.AddSlashSuffix(Base + Main.AppName);
  264. /// <summary>
  265. /// %AppData% 文件夹。
  266. /// </summary>
  267. public static string AppData => PathUtils.AddSlashSuffix(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData));
  268. /// <summary>
  269. /// %AppData% 文件夹中,名为 <see cref="Main.AppName"/> 的子文件夹。
  270. /// </summary>
  271. public static string AppDataThenName => PathUtils.AddSlashSuffix(AppData + Main.AppName);
  272. }