FileUtilsTest.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. using System.IO.Compression;
  2. namespace MeloongCore.Tests;
  3. public class FileUtilsTest : IDisposable {
  4. #region 辅助方法
  5. private readonly string _tempDir;
  6. public FileUtilsTest() {
  7. _tempDir = Path.Combine(Path.GetTempPath(), "MeloongCoreTests_" + Guid.NewGuid().ToString("N"));
  8. Directory.CreateDirectory(_tempDir);
  9. }
  10. public void Dispose() {
  11. if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true);
  12. }
  13. /// <summary>
  14. /// 创建一个包含若干文件的临时文件夹,并返回该文件夹路径。
  15. /// </summary>
  16. private string CreateSourceDirectory(params (string relativePath, string content)[] files) {
  17. string dir = Path.Combine(_tempDir, "src_" + Guid.NewGuid().ToString("N"));
  18. Directory.CreateDirectory(dir);
  19. foreach (var (relativePath, content) in files) {
  20. string fullPath = Path.Combine(dir, relativePath);
  21. Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
  22. File.WriteAllText(fullPath, content, Encoding.UTF8);
  23. }
  24. return dir;
  25. }
  26. /// <summary>
  27. /// 创建一个只含 ASCII 文件名的合法 ZIP,使用指定编码写入文件名。
  28. /// </summary>
  29. private string CreateZipWithEncoding(string zipName, Encoding encoding, params (string entryName, string content)[] entries) {
  30. string zipPath = Path.Combine(_tempDir, zipName);
  31. using var stream = File.Create(zipPath);
  32. using var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: false, encoding);
  33. foreach (var (entryName, content) in entries) {
  34. var entry = archive.CreateEntry(entryName);
  35. using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
  36. writer.Write(content);
  37. }
  38. return zipPath;
  39. }
  40. /// <summary>
  41. /// 创建一个用于 GZip 解压测试的 .gz 文件。
  42. /// </summary>
  43. private string CreateGzFile(string innerFileName, string content) {
  44. string gzPath = Path.Combine(_tempDir, innerFileName + ".gz");
  45. byte[] contentBytes = Encoding.UTF8.GetBytes(content);
  46. using var fileStream = File.Create(gzPath);
  47. using var gzStream = new GZipStream(fileStream, CompressionMode.Compress);
  48. gzStream.Write(contentBytes, 0, contentBytes.Length);
  49. return gzPath;
  50. }
  51. /// <summary>
  52. /// 读取文件内容(兼容长路径)。
  53. /// </summary>
  54. private static string ReadFile(string path) {
  55. return File.ReadAllText(path, Encoding.UTF8);
  56. }
  57. #endregion
  58. #region OpenZip
  59. public void OpenZip_ValidUtf8Zip_OpensSuccessfully() {
  60. string zipPath = CreateZipWithEncoding("utf8.zip", new UTF8Encoding(false, true),
  61. ("hello.txt", "world"));
  62. using var archive = FileUtils.OpenZip(zipPath);
  63. Assert.Single(archive.Entries);
  64. Assert.Equal("hello.txt", archive.Entries[0].FullName);
  65. }
  66. public void OpenZip_ValidUtf8Zip_EntryContentIsCorrect() {
  67. const string expectedContent = "测试内容";
  68. string zipPath = CreateZipWithEncoding("utf8content.zip", new UTF8Encoding(false, true),
  69. ("data.txt", expectedContent));
  70. using var archive = FileUtils.OpenZip(zipPath);
  71. using var entryStream = archive.Entries[0].Open();
  72. using var reader = new StreamReader(entryStream, Encoding.UTF8);
  73. Assert.Equal(expectedContent, reader.ReadToEnd());
  74. }
  75. public void OpenZip_MultipleEntries_AllEntriesAccessible() {
  76. string zipPath = CreateZipWithEncoding("multi.zip", new UTF8Encoding(false, true),
  77. ("a.txt", "aaa"),
  78. ("b.txt", "bbb"),
  79. ("c.txt", "ccc"));
  80. using var archive = FileUtils.OpenZip(zipPath);
  81. Assert.Equal(3, archive.Entries.Count);
  82. }
  83. public void OpenZip_InvalidFile_ThrowsInvalidDataException() {
  84. string invalidPath = Path.Combine(_tempDir, "invalid.zip");
  85. File.WriteAllBytes(invalidPath, [0x00, 0x01, 0x02, 0x03]); // 非 zip 数据
  86. Assert.Throws<InvalidDataException>(() => FileUtils.OpenZip(invalidPath));
  87. }
  88. public void OpenZip_EmptyZip_ReturnsEmptyEntries() {
  89. string zipPath = CreateZipWithEncoding("empty.zip", new UTF8Encoding(false, true));
  90. using var archive = FileUtils.OpenZip(zipPath);
  91. Assert.Empty(archive.Entries);
  92. }
  93. #endregion
  94. #region ExtractToDirectory 测试
  95. public void ExtractToDirectory_ZipWithSingleFile_ExtractsCorrectly() {
  96. const string fileContent = "hello extract";
  97. string zipPath = CreateZipWithEncoding("single.zip", new UTF8Encoding(false, true),
  98. ("file.txt", fileContent));
  99. string outDir = Path.Combine(_tempDir, "out_single");
  100. FileUtils.ExtractToDirectory(zipPath, outDir);
  101. string extractedFile = Path.Combine(outDir, "file.txt");
  102. Assert.True(File.Exists(extractedFile));
  103. Assert.Equal(fileContent, ReadFile(extractedFile));
  104. }
  105. public void ExtractToDirectory_ZipWithMultipleFiles_AllExtracted() {
  106. string zipPath = CreateZipWithEncoding("multi_extract.zip", new UTF8Encoding(false, true),
  107. ("a.txt", "aaa"),
  108. ("b.txt", "bbb"));
  109. string outDir = Path.Combine(_tempDir, "out_multi");
  110. FileUtils.ExtractToDirectory(zipPath, outDir);
  111. Assert.True(File.Exists(Path.Combine(outDir, "a.txt")));
  112. Assert.True(File.Exists(Path.Combine(outDir, "b.txt")));
  113. }
  114. public void ExtractToDirectory_ZipWithSubdirectory_DirectoryStructurePreserved() {
  115. string zipPath = CreateZipWithEncoding("subdir.zip", new UTF8Encoding(false, true),
  116. ("subdir/nested.txt", "nested content"));
  117. string outDir = Path.Combine(_tempDir, "out_subdir");
  118. FileUtils.ExtractToDirectory(zipPath, outDir);
  119. string nestedFile = Path.Combine(outDir, "subdir", "nested.txt");
  120. Assert.True(File.Exists(nestedFile));
  121. Assert.Equal("nested content", ReadFile(nestedFile));
  122. }
  123. public void ExtractToDirectory_OutputDirectoryAutoCreated() {
  124. string zipPath = CreateZipWithEncoding("autocreate.zip", new UTF8Encoding(false, true),
  125. ("f.txt", "x"));
  126. string outDir = Path.Combine(_tempDir, "nonexistent", "deep", "dir");
  127. FileUtils.ExtractToDirectory(zipPath, outDir);
  128. Assert.True(Directory.Exists(outDir));
  129. }
  130. public void ExtractToDirectory_OverwritesExistingFile() {
  131. string zipPath = CreateZipWithEncoding("overwrite.zip", new UTF8Encoding(false, true),
  132. ("f.txt", "new content"));
  133. string outDir = Path.Combine(_tempDir, "out_overwrite");
  134. Directory.CreateDirectory(outDir);
  135. File.WriteAllText(Path.Combine(outDir, "f.txt"), "old content");
  136. FileUtils.ExtractToDirectory(zipPath, outDir);
  137. Assert.Equal("new content", ReadFile(Path.Combine(outDir, "f.txt")));
  138. }
  139. public void ExtractToDirectory_ZipSlip_ThrowsUnauthorizedAccessException() {
  140. // 手动构造一个包含路径穿越条目(../../evil.txt)的 zip
  141. string zipPath = Path.Combine(_tempDir, "zipslip.zip");
  142. using (var stream = File.Create(zipPath))
  143. using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: false, new UTF8Encoding(false, true))) {
  144. var entry = archive.CreateEntry("../../evil.txt");
  145. using var writer = new StreamWriter(entry.Open());
  146. writer.Write("pwned");
  147. }
  148. string outDir = Path.Combine(_tempDir, "out_zipslip");
  149. Directory.CreateDirectory(outDir);
  150. Assert.Throws<UnauthorizedAccessException>(() => FileUtils.ExtractToDirectory(zipPath, outDir));
  151. }
  152. public void ExtractToDirectory_GzFile_ExtractsInnerFile() {
  153. const string innerContent = "gzip content";
  154. const string innerFileName = "inner.txt";
  155. string gzPath = CreateGzFile(innerFileName, innerContent);
  156. string outDir = Path.Combine(_tempDir, "out_gz");
  157. FileUtils.ExtractToDirectory(gzPath, outDir);
  158. string extractedFile = Path.Combine(outDir, innerFileName);
  159. Assert.True(File.Exists(extractedFile));
  160. Assert.Equal(innerContent, ReadFile(extractedFile));
  161. }
  162. public void ExtractToDirectory_JarFile_ExtractsAsZip() {
  163. // jar 文件实际上是 zip 格式
  164. string zipPath = CreateZipWithEncoding("app.jar", new UTF8Encoding(false, true),
  165. ("META-INF/MANIFEST.MF", "Manifest-Version: 1.0\r\n"));
  166. string outDir = Path.Combine(_tempDir, "out_jar");
  167. FileUtils.ExtractToDirectory(zipPath, outDir);
  168. Assert.True(File.Exists(Path.Combine(outDir, "META-INF", "MANIFEST.MF")));
  169. }
  170. #endregion
  171. #region CreateZipFromDirectory 测试
  172. public void CreateZipFromDirectory_SingleFile_ZipContainsFile() {
  173. string srcDir = CreateSourceDirectory(("hello.txt", "hello world"));
  174. string outZip = Path.Combine(_tempDir, "from_dir.zip");
  175. FileUtils.CreateZipFromDirectory(outZip, srcDir);
  176. Assert.True(File.Exists(outZip));
  177. using var archive = ZipFile.OpenRead(outZip);
  178. Assert.Single(archive.Entries);
  179. Assert.Equal("hello.txt", archive.Entries[0].Name);
  180. }
  181. public void CreateZipFromDirectory_MultipleFiles_AllFilesIncluded() {
  182. string srcDir = CreateSourceDirectory(
  183. ("a.txt", "aaa"),
  184. ("b.txt", "bbb"),
  185. ("c.txt", "ccc"));
  186. string outZip = Path.Combine(_tempDir, "from_dir_multi.zip");
  187. FileUtils.CreateZipFromDirectory(outZip, srcDir);
  188. using var archive = ZipFile.OpenRead(outZip);
  189. Assert.Equal(3, archive.Entries.Count);
  190. }
  191. public void CreateZipFromDirectory_SubdirectoryFiles_StructurePreserved() {
  192. string srcDir = CreateSourceDirectory(
  193. ("sub/file.txt", "nested"),
  194. ("root.txt", "root"));
  195. string outZip = Path.Combine(_tempDir, "from_dir_sub.zip");
  196. FileUtils.CreateZipFromDirectory(outZip, srcDir);
  197. using var archive = ZipFile.OpenRead(outZip);
  198. var names = archive.Entries.Select(e => e.FullName.Replace('\\', '/')).ToList();
  199. Assert.Contains(names, n => n.Contains("sub/file.txt"));
  200. Assert.Contains(names, n => n.Contains("root.txt"));
  201. }
  202. public void CreateZipFromDirectory_OverwritesExistingZip() {
  203. string srcDir = CreateSourceDirectory(("new.txt", "new content"));
  204. string outZip = Path.Combine(_tempDir, "overwrite_dir.zip");
  205. // 先创建一个旧 zip
  206. string oldSrcDir = CreateSourceDirectory(("old.txt", "old content"));
  207. FileUtils.CreateZipFromDirectory(outZip, oldSrcDir);
  208. // 再用新目录覆盖
  209. FileUtils.CreateZipFromDirectory(outZip, srcDir);
  210. using var archive = ZipFile.OpenRead(outZip);
  211. Assert.Single(archive.Entries);
  212. Assert.Equal("new.txt", archive.Entries[0].Name);
  213. }
  214. public void CreateZipFromDirectory_OutputDirectoryAutoCreated() {
  215. string srcDir = CreateSourceDirectory(("f.txt", "x"));
  216. string outZip = Path.Combine(_tempDir, "newdir", "nested.zip");
  217. FileUtils.CreateZipFromDirectory(outZip, srcDir);
  218. Assert.True(File.Exists(outZip));
  219. }
  220. #endregion
  221. #region CreateZipFromFiles(params 版)测试
  222. public void CreateZipFromFiles_Params_SingleFile_ZipContainsFile() {
  223. string srcFile = Path.Combine(_tempDir, "single.txt");
  224. File.WriteAllText(srcFile, "content");
  225. string outZip = Path.Combine(_tempDir, "from_files_single.zip");
  226. FileUtils.CreateZipFromFiles(outZip, srcFile);
  227. using var archive = ZipFile.OpenRead(outZip);
  228. Assert.Single(archive.Entries);
  229. Assert.Equal("single.txt", archive.Entries[0].Name);
  230. }
  231. public void CreateZipFromFiles_Params_MultipleFiles_AllIncluded() {
  232. string file1 = Path.Combine(_tempDir, "f1.txt");
  233. string file2 = Path.Combine(_tempDir, "f2.txt");
  234. File.WriteAllText(file1, "1");
  235. File.WriteAllText(file2, "2");
  236. string outZip = Path.Combine(_tempDir, "from_files_multi.zip");
  237. FileUtils.CreateZipFromFiles(outZip, file1, file2);
  238. using var archive = ZipFile.OpenRead(outZip);
  239. Assert.Equal(2, archive.Entries.Count);
  240. var names = archive.Entries.Select(e => e.Name).ToHashSet();
  241. Assert.Contains("f1.txt", names);
  242. Assert.Contains("f2.txt", names);
  243. }
  244. public void CreateZipFromFiles_Params_DuplicateFileNames_ThrowsArgumentException() {
  245. string file1 = Path.Combine(_tempDir, "dup", "a", "same.txt");
  246. string file2 = Path.Combine(_tempDir, "dup", "b", "same.txt");
  247. Directory.CreateDirectory(Path.GetDirectoryName(file1)!);
  248. Directory.CreateDirectory(Path.GetDirectoryName(file2)!);
  249. File.WriteAllText(file1, "1");
  250. File.WriteAllText(file2, "2");
  251. string outZip = Path.Combine(_tempDir, "dup_names.zip");
  252. Assert.Throws<ArgumentException>(() => FileUtils.CreateZipFromFiles(outZip, file1, file2));
  253. }
  254. public void CreateZipFromFiles_Params_OverwritesExistingZip() {
  255. string oldFile = Path.Combine(_tempDir, "old.txt");
  256. string newFile = Path.Combine(_tempDir, "new.txt");
  257. File.WriteAllText(oldFile, "old");
  258. File.WriteAllText(newFile, "new");
  259. string outZip = Path.Combine(_tempDir, "overwrite_files.zip");
  260. FileUtils.CreateZipFromFiles(outZip, oldFile);
  261. FileUtils.CreateZipFromFiles(outZip, newFile);
  262. using var archive = ZipFile.OpenRead(outZip);
  263. Assert.Single(archive.Entries);
  264. Assert.Equal("new.txt", archive.Entries[0].Name);
  265. }
  266. #endregion
  267. #region CreateZipFromFiles(IDictionary 版)测试
  268. public void CreateZipFromFiles_Dict_CustomEntryPath_EntryNameMatchesDictKey() {
  269. string srcFile = Path.Combine(_tempDir, "source.txt");
  270. File.WriteAllText(srcFile, "data");
  271. string outZip = Path.Combine(_tempDir, "from_dict.zip");
  272. var sources = new Dictionary<string, string> {
  273. ["custom/path/file.txt"] = srcFile
  274. };
  275. FileUtils.CreateZipFromFiles(outZip, sources);
  276. using var archive = ZipFile.OpenRead(outZip);
  277. Assert.Single(archive.Entries);
  278. Assert.Equal("custom/path/file.txt", archive.Entries[0].FullName);
  279. }
  280. public void CreateZipFromFiles_Dict_MultipleEntries_AllIncluded() {
  281. string f1 = Path.Combine(_tempDir, "d1.txt");
  282. string f2 = Path.Combine(_tempDir, "d2.txt");
  283. File.WriteAllText(f1, "1");
  284. File.WriteAllText(f2, "2");
  285. string outZip = Path.Combine(_tempDir, "from_dict_multi.zip");
  286. var sources = new Dictionary<string, string> {
  287. ["a/one.txt"] = f1,
  288. ["b/two.txt"] = f2
  289. };
  290. FileUtils.CreateZipFromFiles(outZip, sources);
  291. using var archive = ZipFile.OpenRead(outZip);
  292. Assert.Equal(2, archive.Entries.Count);
  293. var fullNames = archive.Entries.Select(e => e.FullName).ToHashSet();
  294. Assert.Contains("a/one.txt", fullNames);
  295. Assert.Contains("b/two.txt", fullNames);
  296. }
  297. public void CreateZipFromFiles_Dict_FileContentPreserved() {
  298. const string expectedContent = "preserved content";
  299. string srcFile = Path.Combine(_tempDir, "preserve.txt");
  300. File.WriteAllText(srcFile, expectedContent, Encoding.UTF8);
  301. string outZip = Path.Combine(_tempDir, "from_dict_content.zip");
  302. var sources = new Dictionary<string, string> { ["entry.txt"] = srcFile };
  303. FileUtils.CreateZipFromFiles(outZip, sources);
  304. string outDir = Path.Combine(_tempDir, "dict_content_out");
  305. Directory.CreateDirectory(outDir);
  306. using var archive = ZipFile.OpenRead(outZip);
  307. using var entryStream = archive.Entries[0].Open();
  308. using var reader = new StreamReader(entryStream, Encoding.UTF8);
  309. Assert.Equal(expectedContent, reader.ReadToEnd());
  310. }
  311. public void CreateZipFromFiles_Dict_BackslashInKey_NormalizedToForwardSlash() {
  312. string srcFile = Path.Combine(_tempDir, "bs.txt");
  313. File.WriteAllText(srcFile, "x");
  314. string outZip = Path.Combine(_tempDir, "backslash.zip");
  315. var sources = new Dictionary<string, string> {
  316. [@"dir\sub\file.txt"] = srcFile
  317. };
  318. FileUtils.CreateZipFromFiles(outZip, sources);
  319. using var archive = ZipFile.OpenRead(outZip);
  320. // 条目路径中的反斜杠应被规范化为正斜杠
  321. Assert.Equal("dir/sub/file.txt", archive.Entries[0].FullName);
  322. }
  323. #endregion
  324. }