/************************************************************************* * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * * File: ManagedArchive.cs * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . * *************************************************************************/ using System; using System.Collections.Generic; using System.Formats.Tar; using System.IO; using System.IO.Compression; using Server.Logging; using ZstdNet; namespace Server.Compression; /// /// Cross-platform archive creation and extraction using System.Formats.Tar + ZstdNet (native libzstd). /// Single-pass streaming: TarWriter → CompressionStream → FileStream. /// public static class ManagedArchive { private static readonly ILogger logger = LogFactory.GetLogger(typeof(ManagedArchive)); /// /// Creates a .tar.zst archive from one or more source directories. /// Writes to a temp file first, then atomically renames to the destination. /// /// Directories to include in the archive. /// Final .tar.zst file path. /// Base path for computing relative entry names. /// ZSTD compression level (1-22, default 3). /// The number of entries written, or -1 on failure. public static int CreateTarZstd( IEnumerable sourcePaths, string destinationFile, string relativeTo, int compressionLevel = 3) { var tempFile = destinationFile + ".tmp"; try { new FileInfo(destinationFile).EnsureDirectory(); using var fileStream = new FileStream( tempFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 81920 ); using var compressionOptions = new CompressionOptions(compressionLevel); using var zstdStream = new CompressionStream(fileStream, compressionOptions); using var tarWriter = new TarWriter(zstdStream, TarEntryFormat.Pax, leaveOpen: true); var entryCount = 0; long totalBytes = 0; foreach (var sourcePath in sourcePaths) { if (!Directory.Exists(sourcePath)) { logger.Warning("Source path does not exist, skipping: {Path}", sourcePath); continue; } var sourceDir = new DirectoryInfo(sourcePath); var relativeName = Path.GetRelativePath(relativeTo, sourcePath); foreach (var file in sourceDir.EnumerateFiles("*", SearchOption.AllDirectories)) { var entryName = Path.Combine(relativeName, Path.GetRelativePath(sourcePath, file.FullName)) .Replace('\\', '/'); tarWriter.WriteEntry(file.FullName, entryName); entryCount++; totalBytes += file.Length; if (entryCount % 100 == 0) { logger.Debug( "Archive progress: {Count} entries, {Size:F1} MB", entryCount, totalBytes / 1048576.0 ); } } } // Flush tar → zstd → file tarWriter.Dispose(); zstdStream.Dispose(); fileStream.Dispose(); // Atomic rename on same filesystem if (File.Exists(destinationFile)) { File.Delete(destinationFile); } File.Move(tempFile, destinationFile); logger.Information( "Created archive at {Path} ({Count} entries, {Size:F1} MB)", destinationFile, entryCount, totalBytes / 1048576.0 ); return entryCount; } catch (Exception ex) { logger.Error(ex, "Failed to create archive at {Path}", destinationFile); try { if (File.Exists(tempFile)) { File.Delete(tempFile); } } catch { // Best-effort cleanup } return -1; } } /// /// Extracts a .tar.zst archive to a directory. /// public static bool ExtractTarZstd(string archiveFile, string outputDirectory) { try { Directory.CreateDirectory(outputDirectory); using var fileStream = new FileStream( archiveFile, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 81920 ); using var zstdStream = new DecompressionStream(fileStream); return ExtractTarFromStream(zstdStream, outputDirectory); } catch (Exception ex) { logger.Error(ex, "Failed to extract archive {File}", archiveFile); return false; } } /// /// Extracts a .tar.gz archive to a directory. /// public static bool ExtractTarGz(string archiveFile, string outputDirectory) { try { Directory.CreateDirectory(outputDirectory); using var fileStream = new FileStream( archiveFile, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 81920 ); using var gzStream = new GZipStream(fileStream, CompressionMode.Decompress); return ExtractTarFromStream(gzStream, outputDirectory); } catch (Exception ex) { logger.Error(ex, "Failed to extract archive {File}", archiveFile); return false; } } /// /// Extracts a plain .tar archive to a directory. /// public static bool ExtractTar(string archiveFile, string outputDirectory) { try { Directory.CreateDirectory(outputDirectory); using var fileStream = new FileStream( archiveFile, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 81920 ); return ExtractTarFromStream(fileStream, outputDirectory); } catch (Exception ex) { logger.Error(ex, "Failed to extract archive {File}", archiveFile); return false; } } private static bool ExtractTarFromStream(Stream stream, string outputDirectory) { using var tarReader = new TarReader(stream); while (tarReader.GetNextEntry() is { } entry) { var destPath = Path.Combine(outputDirectory, entry.Name); var destDir = Path.GetDirectoryName(destPath); if (destDir != null) { Directory.CreateDirectory(destDir); } if (entry.EntryType is TarEntryType.RegularFile or TarEntryType.V7RegularFile) { entry.ExtractToFile(destPath, overwrite: true); } } return true; } /// /// Counts entries in a .tar.zst archive without extracting content. /// Used for verification after archive creation. /// public static int CountEntries(string archiveFile) { try { using var fileStream = new FileStream( archiveFile, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 81920 ); using var zstdStream = new DecompressionStream(fileStream); using var tarReader = new TarReader(zstdStream); var count = 0; while (tarReader.GetNextEntry() is not null) { count++; } return count; } catch (Exception ex) { logger.Error(ex, "Failed to count entries in archive {File}", archiveFile); return -1; } } }