2
0

PathUtils.cs 14 KB

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