You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
413 lines
14 KiB
413 lines
14 KiB
using System.Diagnostics.CodeAnalysis; |
|
using System.Runtime.Versioning; |
|
using System.Text; |
|
using System.Text.RegularExpressions; |
|
using NLog; |
|
using SharpCompress.Common; |
|
using SharpCompress.Readers; |
|
using StabilityMatrix.Core.Exceptions; |
|
using StabilityMatrix.Core.Extensions; |
|
using StabilityMatrix.Core.Models.FileInterfaces; |
|
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(); |
|
|
|
/// <summary> |
|
/// Platform-specific 7z executable name. |
|
/// </summary> |
|
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<ArchiveInfo> 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<ArchiveInfo> Extract7Z(string archivePath, string extractDirectory) |
|
{ |
|
var args = $"x {ProcessRunner.Quote(archivePath)} -o{ProcessRunner.Quote(extractDirectory)} -y"; |
|
|
|
var result = await ProcessRunner |
|
.GetProcessResultAsync(SevenZipPath, args) |
|
.EnsureSuccessExitCode() |
|
.ConfigureAwait(false); |
|
|
|
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<ArchiveInfo> Extract7Z( |
|
string archivePath, |
|
string extractDirectory, |
|
IProgress<ProgressReport> progress |
|
) |
|
{ |
|
var outputStore = new StringBuilder(); |
|
var onOutput = new Action<string?>(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 process.WaitForExitAsync().ConfigureAwait(false); |
|
|
|
ProcessException.ThrowIfNonZeroExitCode(process, outputStore); |
|
|
|
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()}"); |
|
} |
|
} |
|
|
|
/// <summary> |
|
/// Extracts a zipped tar (i.e. '.tar.gz') archive. |
|
/// First extracts the zipped tar, then extracts the tar and removes the tar. |
|
/// </summary> |
|
/// <param name="archivePath"></param> |
|
/// <param name="extractDirectory"></param> |
|
/// <returns></returns> |
|
public static async Task<ArchiveInfo> 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); |
|
} |
|
} |
|
} |
|
|
|
/// <summary> |
|
/// Extracts with auto handling of tar.gz files. |
|
/// </summary> |
|
public static async Task<ArchiveInfo> 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); |
|
} |
|
} |
|
|
|
/// <summary> |
|
/// Extract an archive to the output directory. |
|
/// </summary> |
|
/// <param name="progress"></param> |
|
/// <param name="archivePath"></param> |
|
/// <param name="outputDirectory">Output directory, created if does not exist.</param> |
|
public static async Task Extract( |
|
string archivePath, |
|
string outputDirectory, |
|
IProgress<ProgressReport>? 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); |
|
} |
|
|
|
/// <summary> |
|
/// Extract an archive to the output directory, using SharpCompress managed code. |
|
/// does not require 7z to be installed, but no progress reporting. |
|
/// </summary> |
|
public static async Task ExtractManaged(string archivePath, string outputDirectory) |
|
{ |
|
await using var stream = File.OpenRead(archivePath); |
|
await ExtractManaged(stream, outputDirectory).ConfigureAwait(false); |
|
} |
|
|
|
/// <summary> |
|
/// Extract an archive to the output directory, using SharpCompress managed code. |
|
/// does not require 7z to be installed, but no progress reporting. |
|
/// </summary> |
|
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); |
|
} |
|
} |
|
} |
|
|
|
[SupportedOSPlatform("macos")] |
|
public static async Task ExtractDmg(string archivePath, DirectoryPath extractDir) |
|
{ |
|
using var mountPoint = new TempDirectoryPath(); |
|
|
|
// Mount the dmg |
|
await ProcessRunner |
|
.GetProcessResultAsync("hdiutil", ["attach", archivePath, "-mountpoint", mountPoint]) |
|
.EnsureSuccessExitCode(); |
|
|
|
try |
|
{ |
|
// Copy apps |
|
foreach (var sourceDir in mountPoint.EnumerateDirectories("*.app")) |
|
{ |
|
var destDir = extractDir.JoinDir(sourceDir.RelativeTo(mountPoint)); |
|
|
|
await ProcessRunner |
|
.GetProcessResultAsync("cp", ["-R", sourceDir, destDir]) |
|
.EnsureSuccessExitCode() |
|
.ConfigureAwait(false); |
|
} |
|
} |
|
finally |
|
{ |
|
// Unmount the dmg |
|
await ProcessRunner |
|
.GetProcessResultAsync("hdiutil", ["detach", mountPoint]) |
|
.ConfigureAwait(false); |
|
} |
|
} |
|
}
|
|
|