using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.RegularExpressions; using NLog; using SharpCompress.Common; using SharpCompress.Readers; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; using Timer = System.Timers.Timer; namespace StabilityMatrix.Core.Helper; public record struct ArchiveInfo(ulong Size, ulong CompressedSize); [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] public static partial class ArchiveHelper { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); /// /// Platform-specific 7z executable name. /// public static string SevenZipFileName { get { if (Compat.IsWindows) { return "7za.exe"; } if (Compat.Platform.HasFlag(PlatformKind.Linux)) { return "7zzs"; } throw new PlatformNotSupportedException("7z is not supported on this platform."); } } // HomeDir is set by ISettingsManager.TryFindLibrary() public static string HomeDir { get; set; } = string.Empty; public static string SevenZipPath => Path.Combine(HomeDir, "Assets", SevenZipFileName); [GeneratedRegex(@"(?<=Size:\s*)\d+|(?<=Compressed:\s*)\d+")] private static partial Regex Regex7ZOutput(); [GeneratedRegex(@"(?<=\s*)\d+(?=%)")] private static partial Regex Regex7ZProgressDigits(); [GeneratedRegex(@"(\d+)%.*- (.*)")] private static partial Regex Regex7ZProgressFull(); public static async Task TestArchive(string archivePath) { var process = ProcessRunner.StartProcess(SevenZipPath, new[] {"t", archivePath}); await process.WaitForExitAsync(); var output = await process.StandardOutput.ReadToEndAsync(); var matches = Regex7ZOutput().Matches(output); var size = ulong.Parse(matches[0].Value); var compressed = ulong.Parse(matches[1].Value); return new ArchiveInfo(size, compressed); } public static async Task AddToArchive7Z(string archivePath, string sourceDirectory) { // Start 7z in the parent directory of the source directory var sourceParent = Directory.GetParent(sourceDirectory)?.FullName ?? ""; // We must pass in as `directory\` for archive path to be correct var sourceDirName = new DirectoryInfo(sourceDirectory).Name; var process = ProcessRunner.StartProcess(SevenZipPath, new[] { "a", archivePath, sourceDirName + @"\", "-y" }, workingDirectory: sourceParent); await ProcessRunner.WaitForExitConditionAsync(process); } public static async Task Extract7Z(string archivePath, string extractDirectory) { var args = $"x {ProcessRunner.Quote(archivePath)} -o{ProcessRunner.Quote(extractDirectory)} -y"; Logger.Debug($"Starting process '{SevenZipPath}' with arguments '{args}'"); using var process = new Process(); process.StartInfo = new ProcessStartInfo(SevenZipPath, args) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; process.Start(); await ProcessRunner.WaitForExitConditionAsync(process); var output = await process.StandardOutput.ReadToEndAsync(); try { var matches = Regex7ZOutput().Matches(output); var size = ulong.Parse(matches[0].Value); var compressed = ulong.Parse(matches[1].Value); return new ArchiveInfo(size, compressed); } catch (Exception e) { throw new Exception($"Could not parse 7z output [{e.Message}]: {output.ToRepr()}"); } } public static async Task Extract7Z(string archivePath, string extractDirectory, IProgress progress) { var outputStore = new StringBuilder(); var onOutput = new Action(s => { // Parse progress Logger.Trace($"7z: {s}"); outputStore.AppendLine(s.Text); var match = Regex7ZProgressFull().Match(s.Text); if (match.Success) { var percent = int.Parse(match.Groups[1].Value); var currentFile = match.Groups[2].Value; progress.Report(new ProgressReport(percent / (float) 100, "Extracting", currentFile, type: ProgressType.Extract)); } }); progress.Report(new ProgressReport(-1, isIndeterminate: true, type: ProgressType.Extract)); // Need -bsp1 for progress reports var args = $"x {ProcessRunner.Quote(archivePath)} -o{ProcessRunner.Quote(extractDirectory)} -y -bsp1"; Logger.Debug($"Starting process '{SevenZipPath}' with arguments '{args}'"); var process = ProcessRunner.StartProcess(SevenZipPath, args, outputDataReceived: onOutput); await ProcessRunner.WaitForExitConditionAsync(process); progress.Report(new ProgressReport(1, "Finished extracting", type: ProgressType.Extract)); var output = outputStore.ToString(); try { var matches = Regex7ZOutput().Matches(output); var size = ulong.Parse(matches[0].Value); var compressed = ulong.Parse(matches[1].Value); return new ArchiveInfo(size, compressed); } catch (Exception e) { throw new Exception($"Could not parse 7z output [{e.Message}]: {output.ToRepr()}"); } } /// /// Extracts a zipped tar (i.e. '.tar.gz') archive. /// First extracts the zipped tar, then extracts the tar and removes the tar. /// /// /// /// public static async Task Extract7ZTar(string archivePath, string extractDirectory) { if (!archivePath.EndsWith(".tar.gz")) { throw new ArgumentException("Archive must be a zipped tar."); } // Extract the tar.gz to tar await Extract7Z(archivePath, extractDirectory); // Extract the tar var tarPath = Path.Combine(extractDirectory, Path.GetFileNameWithoutExtension(archivePath) + ".tar"); if (!File.Exists(tarPath)) { throw new FileNotFoundException("Tar file not found.", tarPath); } try { return await Extract7Z(tarPath, extractDirectory); } finally { // Remove the tar if (File.Exists(tarPath)) { File.Delete(tarPath); } } } /// /// Extracts with auto handling of tar.gz files. /// public static async Task Extract7ZAuto(string archivePath, string extractDirectory) { if (archivePath.EndsWith(".tar.gz")) { return await Extract7ZTar(archivePath, extractDirectory); } else { return await Extract7Z(archivePath, extractDirectory); } } /// /// Extract an archive to the output directory. /// /// /// /// Output directory, created if does not exist. public static async Task Extract(string archivePath, string outputDirectory, IProgress? progress = default) { Directory.CreateDirectory(outputDirectory); progress?.Report(new ProgressReport(-1, isIndeterminate: true)); var count = 0ul; // Get true size var (total, _) = await TestArchive(archivePath); // If not available, use the size of the archive file if (total == 0) { total = (ulong) new FileInfo(archivePath).Length; } // Create an DispatchTimer that monitors the progress of the extraction var progressMonitor = progress switch { null => null, _ => new Timer(TimeSpan.FromMilliseconds(36)) }; if (progressMonitor != null) { progressMonitor.Elapsed += (_, _) => { if (count == 0) return; progress!.Report(new ProgressReport(count, total, message: "Extracting")); }; } await Task.Factory.StartNew(() => { var extractOptions = new ExtractionOptions { Overwrite = true, ExtractFullPath = true, }; using var stream = File.OpenRead(archivePath); using var archive = ReaderFactory.Open(stream); // Start the progress reporting timer progressMonitor?.Start(); while (archive.MoveToNextEntry()) { var entry = archive.Entry; if (!entry.IsDirectory) { count += (ulong) entry.CompressedSize; } archive.WriteEntryToDirectory(outputDirectory, extractOptions); } }, TaskCreationOptions.LongRunning); progress?.Report(new ProgressReport(progress: 1, message: "Done extracting")); progressMonitor?.Stop(); Logger.Info("Finished extracting archive {}", archivePath); } /// /// Extract an archive to the output directory, using SharpCompress managed code. /// does not require 7z to be installed, but no progress reporting. /// public static async Task ExtractManaged(string archivePath, string outputDirectory) { await using var stream = File.OpenRead(archivePath); await ExtractManaged(stream, outputDirectory); } /// /// Extract an archive to the output directory, using SharpCompress managed code. /// does not require 7z to be installed, but no progress reporting. /// public static async Task ExtractManaged(Stream stream, string outputDirectory) { var fullOutputDir = Path.GetFullPath(outputDirectory); using var reader = ReaderFactory.Open(stream); while (reader.MoveToNextEntry()) { var entry = reader.Entry; var outputPath = Path.Combine(outputDirectory, entry.Key); Logger.Debug($"Extracting key: {entry.Key.ToRepr()}"); if (entry.IsDirectory) { if (!Directory.Exists(outputPath)) { Directory.CreateDirectory(outputPath); } } else { var folder = Path.GetDirectoryName(entry.Key)!; var destDir = Path.GetFullPath(Path.Combine(fullOutputDir, folder)); if (!Directory.Exists(destDir)) { if (!destDir.StartsWith(fullOutputDir, StringComparison.Ordinal)) { throw new ExtractionException( "Entry is trying to create a directory outside of the destination directory." ); } Directory.CreateDirectory(destDir); } // Check if symbolic link if (entry.LinkTarget != null) { // Not sure why but symlink entries have a key that ends with a space // and some broken path suffix, so we'll remove everything after the last space Logger.Debug($"Checking if output path {outputPath} contains space char: {outputPath.Contains(' ')}"); if (outputPath.Contains(' ')) { outputPath = outputPath[..outputPath.LastIndexOf(' ')]; } Logger.Debug($"Extracting symbolic link [{entry.Key.ToRepr()}] " + $"({outputPath.ToRepr()} to {entry.LinkTarget.ToRepr()})"); // Try to write link, if fail, continue copy file try { // Delete path if exists File.Delete(outputPath); File.CreateSymbolicLink(outputPath, entry.LinkTarget); continue; } catch (IOException e) { Logger.Warn($"Could not extract symbolic link, copying file instead: {e.Message}"); } } // Write file await using var entryStream = reader.OpenEntryStream(); await using var fileStream = File.Create(outputPath); await entryStream.CopyToAsync(fileStream); } } } }