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.IsLinux)
{
return "7zzs";
}
if (Compat.IsMacOS)
{
return "7zz";
}
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.StartAnsiProcess(SevenZipPath, new[] { "t", archivePath });
await process.WaitForExitAsync().ConfigureAwait(false);
var output = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
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 result = await ProcessRunner
.GetProcessResultAsync(
SevenZipPath,
new[] { "a", archivePath, sourceDirName + @"\", "-y" },
workingDirectory: sourceParent
)
.ConfigureAwait(false);
result.EnsureSuccessExitCode();
}
public static async Task Extract7Z(string archivePath, string extractDirectory)
{
var args =
$"x {ProcessRunner.Quote(archivePath)} -o{ProcessRunner.Quote(extractDirectory)} -y";
var result = await ProcessRunner
.GetProcessResultAsync(SevenZipPath, args)
.ConfigureAwait(false);
result.EnsureSuccessExitCode();
var output = result.StandardOutput ?? "";
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 =>
{
if (s == null)
return;
// Parse progress
Logger.Trace($"7z: {s}");
outputStore.AppendLine(s);
var match = Regex7ZProgressFull().Match(s);
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}'");
using var process = ProcessRunner.StartProcess(
SevenZipPath,
args,
outputDataReceived: onOutput
);
await ProcessRunner.WaitForExitConditionAsync(process).ConfigureAwait(false);
progress.Report(new ProgressReport(1f, "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).ConfigureAwait(false);
// Extract the tar
var tarPath = Path.Combine(extractDirectory, Path.GetFileNameWithoutExtension(archivePath));
if (!File.Exists(tarPath))
{
throw new FileNotFoundException("Tar file not found.", tarPath);
}
try
{
return await Extract7Z(tarPath, extractDirectory).ConfigureAwait(false);
}
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).ConfigureAwait(false);
}
else
{
return await Extract7Z(archivePath, extractDirectory).ConfigureAwait(false);
}
}
///
/// 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).ConfigureAwait(false);
// 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
)
.ConfigureAwait(false);
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).ConfigureAwait(false);
}
///
/// 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);
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).ConfigureAwait(false);
}
}
}
}