BackupService.cs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. using System;
  2. using System.Collections.Generic;
  3. using System.IO;
  4. using System.IO.Compression;
  5. using System.Linq;
  6. using System.Text.Json;
  7. using System.Text.Json.Nodes;
  8. using System.Text.Json.Serialization;
  9. using System.Threading;
  10. using System.Threading.Tasks;
  11. using Jellyfin.Database.Implementations;
  12. using Jellyfin.Server.Implementations.StorageHelpers;
  13. using Jellyfin.Server.Implementations.SystemBackupService;
  14. using MediaBrowser.Controller;
  15. using MediaBrowser.Controller.SystemBackupService;
  16. using Microsoft.EntityFrameworkCore;
  17. using Microsoft.EntityFrameworkCore.Infrastructure;
  18. using Microsoft.EntityFrameworkCore.Migrations;
  19. using Microsoft.Extensions.Hosting;
  20. using Microsoft.Extensions.Logging;
  21. namespace Jellyfin.Server.Implementations.FullSystemBackup;
  22. /// <summary>
  23. /// Contains methods for creating and restoring backups.
  24. /// </summary>
  25. public class BackupService : IBackupService
  26. {
  27. private const string ManifestEntryName = "manifest.json";
  28. private readonly ILogger<BackupService> _logger;
  29. private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
  30. private readonly IServerApplicationHost _applicationHost;
  31. private readonly IServerApplicationPaths _applicationPaths;
  32. private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
  33. private readonly IHostApplicationLifetime _hostApplicationLifetime;
  34. private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
  35. {
  36. AllowTrailingCommas = true,
  37. ReferenceHandler = ReferenceHandler.IgnoreCycles,
  38. };
  39. private readonly Version _backupEngineVersion = new Version(0, 2, 0);
  40. /// <summary>
  41. /// Initializes a new instance of the <see cref="BackupService"/> class.
  42. /// </summary>
  43. /// <param name="logger">A logger.</param>
  44. /// <param name="dbProvider">A Database Factory.</param>
  45. /// <param name="applicationHost">The Application host.</param>
  46. /// <param name="applicationPaths">The application paths.</param>
  47. /// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
  48. /// <param name="applicationLifetime">The SystemManager.</param>
  49. public BackupService(
  50. ILogger<BackupService> logger,
  51. IDbContextFactory<JellyfinDbContext> dbProvider,
  52. IServerApplicationHost applicationHost,
  53. IServerApplicationPaths applicationPaths,
  54. IJellyfinDatabaseProvider jellyfinDatabaseProvider,
  55. IHostApplicationLifetime applicationLifetime)
  56. {
  57. _logger = logger;
  58. _dbProvider = dbProvider;
  59. _applicationHost = applicationHost;
  60. _applicationPaths = applicationPaths;
  61. _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
  62. _hostApplicationLifetime = applicationLifetime;
  63. }
  64. /// <inheritdoc/>
  65. public void ScheduleRestoreAndRestartServer(string archivePath)
  66. {
  67. _applicationHost.RestoreBackupPath = archivePath;
  68. _applicationHost.ShouldRestart = true;
  69. _applicationHost.NotifyPendingRestart();
  70. _ = Task.Run(async () =>
  71. {
  72. await Task.Delay(500).ConfigureAwait(false);
  73. _hostApplicationLifetime.StopApplication();
  74. });
  75. }
  76. /// <inheritdoc/>
  77. public async Task RestoreBackupAsync(string archivePath)
  78. {
  79. _logger.LogWarning("Begin restoring system to {BackupArchive}", archivePath); // Info isn't cutting it
  80. if (!File.Exists(archivePath))
  81. {
  82. throw new FileNotFoundException($"Requested backup file '{archivePath}' does not exist.");
  83. }
  84. StorageHelper.TestCommonPathsForStorageCapacity(_applicationPaths, _logger);
  85. var fileStream = File.OpenRead(archivePath);
  86. await using (fileStream.ConfigureAwait(false))
  87. {
  88. using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read, false);
  89. var zipArchiveEntry = zipArchive.GetEntry(ManifestEntryName);
  90. if (zipArchiveEntry is null)
  91. {
  92. throw new NotSupportedException($"The loaded archive '{archivePath}' does not appear to be a Jellyfin backup as its missing the '{ManifestEntryName}'.");
  93. }
  94. BackupManifest? manifest;
  95. var manifestStream = zipArchiveEntry.Open();
  96. await using (manifestStream.ConfigureAwait(false))
  97. {
  98. manifest = await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
  99. }
  100. if (manifest!.ServerVersion > _applicationHost.ApplicationVersion) // newer versions of Jellyfin should be able to load older versions as we have migrations.
  101. {
  102. throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
  103. }
  104. if (!TestBackupVersionCompatibility(manifest.BackupEngineVersion))
  105. {
  106. throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
  107. }
  108. void CopyDirectory(string source, string target)
  109. {
  110. var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
  111. var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
  112. foreach (var item in zipArchive.Entries)
  113. {
  114. var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
  115. var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
  116. if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
  117. || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
  118. || Path.EndsInDirectorySeparator(item.FullName))
  119. {
  120. continue;
  121. }
  122. _logger.LogInformation("Restore and override {File}", targetPath);
  123. Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
  124. item.ExtractToFile(targetPath, overwrite: true);
  125. }
  126. }
  127. CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
  128. CopyDirectory("Data", _applicationPaths.DataPath);
  129. CopyDirectory("Root", _applicationPaths.RootFolderPath);
  130. if (manifest.Options.Database)
  131. {
  132. _logger.LogInformation("Begin restoring Database");
  133. var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
  134. await using (dbContext.ConfigureAwait(false))
  135. {
  136. // restore migration history manually
  137. var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{nameof(HistoryRow)}.json")));
  138. if (historyEntry is null)
  139. {
  140. _logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation");
  141. throw new InvalidOperationException("Cannot restore backup that has no History data.");
  142. }
  143. HistoryRow[] historyEntries;
  144. var historyArchive = historyEntry.Open();
  145. await using (historyArchive.ConfigureAwait(false))
  146. {
  147. historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ??
  148. throw new InvalidOperationException("Cannot restore backup that has no History data.");
  149. }
  150. var historyRepository = dbContext.GetService<IHistoryRepository>();
  151. await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
  152. foreach (var item in await historyRepository.GetAppliedMigrationsAsync(CancellationToken.None).ConfigureAwait(false))
  153. {
  154. var insertScript = historyRepository.GetDeleteScript(item.MigrationId);
  155. await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
  156. }
  157. foreach (var item in historyEntries)
  158. {
  159. var insertScript = historyRepository.GetInsertScript(item);
  160. await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
  161. }
  162. dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
  163. var entityTypes = typeof(JellyfinDbContext).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
  164. .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
  165. .Select(e => (Type: e, Set: e.GetValue(dbContext) as IQueryable))
  166. .ToArray();
  167. var tableNames = entityTypes.Select(f => dbContext.Model.FindEntityType(f.Type.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!);
  168. _logger.LogInformation("Begin purging database");
  169. await _jellyfinDatabaseProvider.PurgeDatabase(dbContext, tableNames).ConfigureAwait(false);
  170. _logger.LogInformation("Database Purged");
  171. foreach (var entityType in entityTypes)
  172. {
  173. _logger.LogInformation("Read backup of {Table}", entityType.Type.Name);
  174. var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json")));
  175. if (zipEntry is null)
  176. {
  177. _logger.LogInformation("No backup of expected table {Table} is present in backup, continuing anyway", entityType.Type.Name);
  178. continue;
  179. }
  180. var zipEntryStream = zipEntry.Open();
  181. await using (zipEntryStream.ConfigureAwait(false))
  182. {
  183. _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
  184. var records = 0;
  185. await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStream, _serializerSettings).ConfigureAwait(false))
  186. {
  187. var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]);
  188. if (entity is null)
  189. {
  190. throw new InvalidOperationException($"Cannot deserialize entity '{item}'");
  191. }
  192. try
  193. {
  194. records++;
  195. dbContext.Add(entity);
  196. }
  197. catch (Exception ex)
  198. {
  199. _logger.LogError(ex, "Could not store entity {Entity}, continuing anyway", item);
  200. }
  201. }
  202. _logger.LogInformation("Prepared to restore {Number} entries for {Table}", records, entityType.Type.Name);
  203. }
  204. }
  205. _logger.LogInformation("Try restore Database");
  206. await dbContext.SaveChangesAsync().ConfigureAwait(false);
  207. _logger.LogInformation("Restored database");
  208. }
  209. }
  210. _logger.LogInformation("Restored Jellyfin system from {Date}", manifest.DateCreated);
  211. }
  212. }
  213. private bool TestBackupVersionCompatibility(Version backupEngineVersion)
  214. {
  215. if (backupEngineVersion == _backupEngineVersion)
  216. {
  217. return true;
  218. }
  219. return false;
  220. }
  221. /// <inheritdoc/>
  222. public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
  223. {
  224. var manifest = new BackupManifest()
  225. {
  226. DateCreated = DateTime.UtcNow,
  227. ServerVersion = _applicationHost.ApplicationVersion,
  228. DatabaseTables = null!,
  229. BackupEngineVersion = _backupEngineVersion,
  230. Options = Map(backupOptions)
  231. };
  232. _logger.LogInformation("Running database optimization before backup");
  233. await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
  234. var backupFolder = Path.Combine(_applicationPaths.BackupPath);
  235. if (!Directory.Exists(backupFolder))
  236. {
  237. Directory.CreateDirectory(backupFolder);
  238. }
  239. var backupStorageSpace = StorageHelper.GetFreeSpaceOf(_applicationPaths.BackupPath);
  240. const long FiveGigabyte = 5_368_709_115;
  241. if (backupStorageSpace.FreeSpace < FiveGigabyte)
  242. {
  243. throw new InvalidOperationException($"The backup directory '{backupStorageSpace.Path}' does not have at least '{StorageHelper.HumanizeStorageSize(FiveGigabyte)}' free space. Cannot create backup.");
  244. }
  245. var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip");
  246. try
  247. {
  248. _logger.LogInformation("Attempting to create a new backup at {BackupPath}", backupPath);
  249. var fileStream = File.OpenWrite(backupPath);
  250. await using (fileStream.ConfigureAwait(false))
  251. using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
  252. {
  253. _logger.LogInformation("Starting backup process");
  254. var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
  255. await using (dbContext.ConfigureAwait(false))
  256. {
  257. dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
  258. static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
  259. {
  260. var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
  261. var enumerable = method.Invoke(dbSet, null)!;
  262. return (IAsyncEnumerable<object>)enumerable;
  263. }
  264. // include the migration history as well
  265. var historyRepository = dbContext.GetService<IHistoryRepository>();
  266. var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
  267. ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes =
  268. [
  269. .. typeof(JellyfinDbContext)
  270. .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
  271. .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
  272. .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
  273. (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
  274. ];
  275. manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
  276. var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
  277. await using (transaction.ConfigureAwait(false))
  278. {
  279. _logger.LogInformation("Begin Database backup");
  280. foreach (var entityType in entityTypes)
  281. {
  282. _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
  283. var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json")));
  284. var entities = 0;
  285. var zipEntryStream = zipEntry.Open();
  286. await using (zipEntryStream.ConfigureAwait(false))
  287. {
  288. var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
  289. await using (jsonSerializer.ConfigureAwait(false))
  290. {
  291. jsonSerializer.WriteStartArray();
  292. var set = entityType.ValueFactory().ConfigureAwait(false);
  293. await foreach (var item in set.ConfigureAwait(false))
  294. {
  295. entities++;
  296. try
  297. {
  298. using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
  299. document.WriteTo(jsonSerializer);
  300. }
  301. catch (Exception ex)
  302. {
  303. _logger.LogError(ex, "Could not load entity {Entity}", item);
  304. throw;
  305. }
  306. }
  307. jsonSerializer.WriteEndArray();
  308. }
  309. }
  310. _logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities);
  311. }
  312. }
  313. }
  314. _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
  315. foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
  316. .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
  317. {
  318. zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item))));
  319. }
  320. void CopyDirectory(string source, string target, string filter = "*")
  321. {
  322. if (!Directory.Exists(source))
  323. {
  324. return;
  325. }
  326. _logger.LogInformation("Backup of folder {Table}", source);
  327. foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
  328. {
  329. zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item))));
  330. }
  331. }
  332. CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users"));
  333. CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks"));
  334. CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
  335. CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
  336. CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
  337. CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks"));
  338. if (backupOptions.Subtitles)
  339. {
  340. CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
  341. }
  342. if (backupOptions.Trickplay)
  343. {
  344. CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
  345. }
  346. if (backupOptions.Metadata)
  347. {
  348. CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
  349. }
  350. var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
  351. await using (manifestStream.ConfigureAwait(false))
  352. {
  353. await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
  354. }
  355. }
  356. _logger.LogInformation("Backup created");
  357. return Map(manifest, backupPath);
  358. }
  359. catch (Exception ex)
  360. {
  361. _logger.LogError(ex, "Failed to create backup, removing {BackupPath}", backupPath);
  362. try
  363. {
  364. if (File.Exists(backupPath))
  365. {
  366. File.Delete(backupPath);
  367. }
  368. }
  369. catch (Exception innerEx)
  370. {
  371. _logger.LogWarning(innerEx, "Unable to remove failed backup");
  372. }
  373. throw;
  374. }
  375. }
  376. /// <inheritdoc/>
  377. public async Task<BackupManifestDto?> GetBackupManifest(string archivePath)
  378. {
  379. if (!File.Exists(archivePath))
  380. {
  381. return null;
  382. }
  383. BackupManifest? manifest;
  384. try
  385. {
  386. manifest = await GetManifest(archivePath).ConfigureAwait(false);
  387. }
  388. catch (Exception ex)
  389. {
  390. _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", archivePath);
  391. return null;
  392. }
  393. if (manifest is null)
  394. {
  395. return null;
  396. }
  397. return Map(manifest, archivePath);
  398. }
  399. /// <inheritdoc/>
  400. public async Task<BackupManifestDto[]> EnumerateBackups()
  401. {
  402. if (!Directory.Exists(_applicationPaths.BackupPath))
  403. {
  404. return [];
  405. }
  406. var archives = Directory.EnumerateFiles(_applicationPaths.BackupPath, "*.zip");
  407. var manifests = new List<BackupManifestDto>();
  408. foreach (var item in archives)
  409. {
  410. try
  411. {
  412. var manifest = await GetManifest(item).ConfigureAwait(false);
  413. if (manifest is null)
  414. {
  415. continue;
  416. }
  417. manifests.Add(Map(manifest, item));
  418. }
  419. catch (Exception ex)
  420. {
  421. _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", item);
  422. }
  423. }
  424. return manifests.ToArray();
  425. }
  426. private static async ValueTask<BackupManifest?> GetManifest(string archivePath)
  427. {
  428. var archiveStream = File.OpenRead(archivePath);
  429. await using (archiveStream.ConfigureAwait(false))
  430. {
  431. using var zipStream = new ZipArchive(archiveStream, ZipArchiveMode.Read);
  432. var manifestEntry = zipStream.GetEntry(ManifestEntryName);
  433. if (manifestEntry is null)
  434. {
  435. return null;
  436. }
  437. var manifestStream = manifestEntry.Open();
  438. await using (manifestStream.ConfigureAwait(false))
  439. {
  440. return await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
  441. }
  442. }
  443. }
  444. private static BackupManifestDto Map(BackupManifest manifest, string path)
  445. {
  446. return new BackupManifestDto()
  447. {
  448. BackupEngineVersion = manifest.BackupEngineVersion,
  449. DateCreated = manifest.DateCreated,
  450. ServerVersion = manifest.ServerVersion,
  451. Path = path,
  452. Options = Map(manifest.Options)
  453. };
  454. }
  455. private static BackupOptionsDto Map(BackupOptions options)
  456. {
  457. return new BackupOptionsDto()
  458. {
  459. Metadata = options.Metadata,
  460. Subtitles = options.Subtitles,
  461. Trickplay = options.Trickplay,
  462. Database = options.Database
  463. };
  464. }
  465. private static BackupOptions Map(BackupOptionsDto options)
  466. {
  467. return new BackupOptions()
  468. {
  469. Metadata = options.Metadata,
  470. Subtitles = options.Subtitles,
  471. Trickplay = options.Trickplay,
  472. Database = options.Database
  473. };
  474. }
  475. /// <summary>
  476. /// Windows is able to handle '/' as a path seperator in zip files
  477. /// but linux isn't able to handle '\' as a path seperator in zip files,
  478. /// So normalize to '/'.
  479. /// </summary>
  480. /// <param name="path">The path to normalize.</param>
  481. /// <returns>The normalized path. </returns>
  482. private static string NormalizePathSeparator(string path)
  483. => path.Replace('\\', '/');
  484. }