Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closes #3156 #3182

Merged
merged 12 commits into from
Apr 4, 2024
36 changes: 6 additions & 30 deletions ArchiSteamFarm/Core/ASF.cs
Original file line number Diff line number Diff line change
Expand Up @@ -779,36 +779,12 @@ private static async Task UpdateAndRestart() {
await UpdateSemaphore.WaitAsync().ConfigureAwait(false);

try {
// If backup directory from previous update exists, it's a good idea to purge it now
string backupDirectory = Path.Combine(SharedInfo.HomeDirectory, SharedInfo.UpdateDirectory);
// If directories from previous update exists, it's a good idea to purge them now
string updateDirectory = Path.Combine(SharedInfo.HomeDirectory, SharedInfo.UpdateDirectoryNew);
string backupDirectory = Path.Combine(SharedInfo.HomeDirectory, SharedInfo.UpdateDirectoryOld);

if (Directory.Exists(backupDirectory)) {
ArchiLogger.LogGenericInfo(Strings.UpdateCleanup);

for (byte i = 0; (i < WebBrowser.MaxTries) && Directory.Exists(backupDirectory); i++) {
if (i > 0) {
// It's entirely possible that old process is still running, wait a short moment for eventual cleanup
await Task.Delay(5000).ConfigureAwait(false);
}

try {
Directory.Delete(backupDirectory, true);
} catch (Exception e) {
ArchiLogger.LogGenericDebuggingException(e);

continue;
}

break;
}

if (Directory.Exists(backupDirectory)) {
ArchiLogger.LogGenericError(Strings.WarningFailed);

return (false, null);
}

ArchiLogger.LogGenericInfo(Strings.Done);
if (!await Utilities.EnsureUpdateDirectoriesPurged(updateDirectory, backupDirectory).ConfigureAwait(false)) {
return (false, null);
}

ArchiLogger.LogGenericInfo(Strings.UpdateCheckingNewVersion);
Expand Down Expand Up @@ -994,7 +970,7 @@ private static async Task<bool> UpdateFromArchive(Version newVersion, GlobalConf
// We're ready to start update process, handle any plugin updates ready for new version
await PluginsCore.UpdatePlugins(newVersion, true, updateChannel, updateOverride, forced).ConfigureAwait(false);

return Utilities.UpdateFromArchive(zipArchive, SharedInfo.HomeDirectory);
return await Utilities.UpdateFromArchive(zipArchive, SharedInfo.HomeDirectory).ConfigureAwait(false);
}

[PublicAPI]
Expand Down
232 changes: 164 additions & 68 deletions ArchiSteamFarm/Core/Utilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
using ArchiSteamFarm.Localization;
using ArchiSteamFarm.NLog;
using ArchiSteamFarm.Storage;
using ArchiSteamFarm.Web;
using Humanizer;
using Humanizer.Localisation;
using JetBrains.Annotations;
Expand Down Expand Up @@ -267,6 +268,78 @@
return true;
}

internal static async Task<bool> EnsureUpdateDirectoriesPurged(params string[] directories) {
if ((directories == null) || (directories.Length == 0)) {
throw new ArgumentNullException(nameof(directories));
}

foreach (string directory in directories.Where(Directory.Exists)) {
ASF.ArchiLogger.LogGenericInfo(Strings.UpdateCleanup);

// Apart from simple removal, we're also going to do two additional things:
// 1. We're going to retry if the directory is still being used
// 2. We're going to ensure the changes were synchronized to disk, aka fsync()
JustArchi marked this conversation as resolved.
Show resolved Hide resolved
using FileSystemWatcher watcher = new(Path.Combine(directory, ".."), Path.GetFileName(directory));

watcher.NotifyFilter = NotifyFilters.DirectoryName;

TaskCompletionSource<bool> tcs = new();

watcher.Deleted += onDeleted;

watcher.EnableRaisingEvents = true;

bool deleted = false;

for (byte i = 1; (i <= WebBrowser.MaxTries) && Directory.Exists(directory); i++) {
JustArchi marked this conversation as resolved.
Show resolved Hide resolved
if (i > 1) {
await Task.Delay(5000).ConfigureAwait(false);
}

try {
Directory.Delete(directory, true);
} catch (IOException e) when ((i < WebBrowser.MaxTries) && ((uint) e.HResult == 0x80070020)) {
JustArchi marked this conversation as resolved.
Show resolved Hide resolved
// It's entirely possible that old process is still running, we allow this to happen and add additional delay
ASF.ArchiLogger.LogGenericDebuggingException(e);

continue;
} catch (Exception e) {

Check notice on line 306 in ArchiSteamFarm/Core/Utilities.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

RoslynAnalyzers Do not catch general exception types

Modify 'EnsureUpdateDirectoriesPurged' to catch a more specific allowed exception type, or rethrow the exception
JustArchi marked this conversation as resolved.
Show resolved Hide resolved
ASF.ArchiLogger.LogGenericException(e);

return false;
}

deleted = true;

break;
}

if (deleted) {
try {
// Wait for the event to arrive
await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
} catch (TimeoutException e) {
// The event didn't arrive on time, let's assume the directory was purged as requested
ASF.ArchiLogger.LogGenericDebuggingException(e);
}
}

if (Directory.Exists(directory)) {
ASF.ArchiLogger.LogGenericError(Strings.WarningFailed);

return false;
}

ASF.ArchiLogger.LogGenericInfo(Strings.Done);

continue;

void onDeleted(object sender, FileSystemEventArgs e) => tcs.TrySetResult(true);
}

return true;
}

internal static ulong MathAdd(ulong first, int second) {
if (second >= 0) {
return first + (uint) second;
Expand Down Expand Up @@ -322,37 +395,73 @@
return (result.Score < 4, suggestions is { Count: > 0 } ? string.Join(' ', suggestions.Where(static suggestion => suggestion.Length > 0)) : null);
}

internal static bool UpdateFromArchive(ZipArchive zipArchive, string targetDirectory) {
internal static async Task<bool> UpdateFromArchive(ZipArchive zipArchive, string targetDirectory) {
ArgumentNullException.ThrowIfNull(zipArchive);
ArgumentException.ThrowIfNullOrEmpty(targetDirectory);

// Firstly we'll move all our existing files to a backup directory
string backupDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectory);
// Firstly, ensure once again our directories are purged and ready to work with
string updateDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectoryNew);
string backupDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectoryOld);

if (!await EnsureUpdateDirectoriesPurged(updateDirectory, backupDirectory).ConfigureAwait(false)) {
return false;
}

// Now enumerate over files in the zip archive and extract them to entirely new location, this decreases chance of corruptions if user kills the process during this stage
Directory.CreateDirectory(updateDirectory);

foreach (ZipArchiveEntry zipFile in zipArchive.Entries) {
switch (zipFile.Name) {
case null:
case "":
JustArchi marked this conversation as resolved.
Show resolved Hide resolved
case ".gitkeep":
// We're not interested in extracting placeholder files
continue;
}

string file = Path.GetFullPath(Path.Combine(updateDirectory, zipFile.FullName));

if (!file.StartsWith(updateDirectory, StringComparison.Ordinal)) {
throw new InvalidOperationException(nameof(file));
}

// Check if this file requires its own folder
if (zipFile.Name != zipFile.FullName) {
string? directory = Path.GetDirectoryName(file);

if (string.IsNullOrEmpty(directory)) {
throw new InvalidOperationException(nameof(directory));
}

if (!Directory.Exists(directory)) {
Directory.CreateDirectory(directory);
}
}

zipFile.ExtractToFile(file);
}

// Now, critical section begins, we're going to move all files from target directory to a backup directory
Directory.CreateDirectory(backupDirectory);

foreach (string file in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.AllDirectories)) {
string fileName = Path.GetFileName(file);

if (string.IsNullOrEmpty(fileName)) {
ASF.ArchiLogger.LogNullError(fileName);

return false;
throw new InvalidOperationException(nameof(fileName));
}

string relativeFilePath = Path.GetRelativePath(targetDirectory, file);

if (string.IsNullOrEmpty(relativeFilePath)) {
ASF.ArchiLogger.LogNullError(relativeFilePath);

return false;
throw new InvalidOperationException(nameof(relativeFilePath));
}

string? relativeDirectoryName = Path.GetDirectoryName(relativeFilePath);

switch (relativeDirectoryName) {
case null:
ASF.ArchiLogger.LogNullError(relativeDirectoryName);

return false;
throw new InvalidOperationException(nameof(relativeDirectoryName));
case "":
// No directory, root folder
switch (fileName) {
Expand All @@ -367,72 +476,79 @@
case SharedInfo.ConfigDirectory:
case SharedInfo.DebugDirectory:
case SharedInfo.PluginsDirectory:
case SharedInfo.UpdateDirectory:
case SharedInfo.UpdateDirectoryNew:
case SharedInfo.UpdateDirectoryOld:
// Files in those directories we want to keep in their current place
continue;
default:
// Files in subdirectories of those directories we want to keep as well
if (RelativeDirectoryStartsWith(relativeDirectoryName, SharedInfo.ArchivalLogsDirectory, SharedInfo.ConfigDirectory, SharedInfo.DebugDirectory, SharedInfo.PluginsDirectory, SharedInfo.UpdateDirectory)) {
if (RelativeDirectoryStartsWith(relativeDirectoryName, SharedInfo.ArchivalLogsDirectory, SharedInfo.ConfigDirectory, SharedInfo.DebugDirectory, SharedInfo.PluginsDirectory, SharedInfo.UpdateDirectoryNew, SharedInfo.UpdateDirectoryOld)) {
continue;
}

break;
}

string targetBackupDirectory = relativeDirectoryName.Length > 0 ? Path.Combine(backupDirectory, relativeDirectoryName) : backupDirectory;
Directory.CreateDirectory(targetBackupDirectory);
// We're going to move this file out of the current place, overwriting existing one if needed
string targetBackupDirectory;

string targetBackupFile = Path.Combine(targetBackupDirectory, fileName);
if (relativeDirectoryName.Length > 0) {
// File inside a subdirectory
targetBackupDirectory = Path.Combine(backupDirectory, relativeDirectoryName);

File.Move(file, targetBackupFile, true);
}
Directory.CreateDirectory(targetBackupDirectory);
} else {
// File in root directory
targetBackupDirectory = backupDirectory;
}

// We can now get rid of directories that are empty
DeleteEmptyDirectoriesRecursively(targetDirectory);
string targetBackupFile = Path.Combine(targetBackupDirectory, fileName);

if (!Directory.Exists(targetDirectory)) {
Directory.CreateDirectory(targetDirectory);
File.Move(file, targetBackupFile, true);
}

// Now enumerate over files in the zip archive, skip directory entries that we're not interested in (we can create them ourselves if needed)
foreach (ZipArchiveEntry zipFile in zipArchive.Entries.Where(static zipFile => !string.IsNullOrEmpty(zipFile.Name))) {
string file = Path.GetFullPath(Path.Combine(targetDirectory, zipFile.FullName));
// Finally, we can move the newly extracted files to target directory
foreach (string file in Directory.EnumerateFiles(updateDirectory, "*", SearchOption.AllDirectories)) {
string fileName = Path.GetFileName(file);

if (!file.StartsWith(targetDirectory, StringComparison.Ordinal)) {
throw new InvalidOperationException(nameof(file));
if (string.IsNullOrEmpty(fileName)) {
throw new InvalidOperationException(nameof(fileName));
}

if (File.Exists(file)) {
// This is possible only with files that we decided to leave in place during our backup function
string targetBackupFile = $"{file}.bak";
string relativeFilePath = Path.GetRelativePath(updateDirectory, file);

File.Move(file, targetBackupFile, true);
if (string.IsNullOrEmpty(relativeFilePath)) {
throw new InvalidOperationException(nameof(relativeFilePath));
}

// Check if this file requires its own folder
if (zipFile.Name != zipFile.FullName) {
string? directory = Path.GetDirectoryName(file);
string? relativeDirectoryName = Path.GetDirectoryName(relativeFilePath);

if (string.IsNullOrEmpty(directory)) {
ASF.ArchiLogger.LogNullError(directory);
if (relativeDirectoryName == null) {
throw new InvalidOperationException(nameof(relativeDirectoryName));
}

return false;
}
// We're going to move this file out of the current place, overwriting existing one if needed
string targetUpdateDirectory;

if (!Directory.Exists(directory)) {
Directory.CreateDirectory(directory);
}
if (relativeDirectoryName.Length > 0) {
// File inside a subdirectory
targetUpdateDirectory = Path.Combine(targetDirectory, relativeDirectoryName);

// We're not interested in extracting placeholder files (but we still want directories created for them, done above)
switch (zipFile.Name) {
case ".gitkeep":
continue;
}
Directory.CreateDirectory(targetUpdateDirectory);
} else {
// File in root directory
targetUpdateDirectory = targetDirectory;
}

zipFile.ExtractToFile(file);
string targetUpdateFile = Path.Combine(targetUpdateDirectory, fileName);

File.Move(file, targetUpdateFile, true);
JustArchi marked this conversation as resolved.
Show resolved Hide resolved
}

// Critical section has finished, we can now cleanup the update directory, backup directory must wait for the process restart
Directory.Delete(updateDirectory, true);

// The update process is done
return true;
}

Expand Down Expand Up @@ -491,26 +607,6 @@
}
}

private static void DeleteEmptyDirectoriesRecursively(string directory) {
ArgumentException.ThrowIfNullOrEmpty(directory);

if (!Directory.Exists(directory)) {
return;
}

try {
foreach (string subDirectory in Directory.EnumerateDirectories(directory)) {
DeleteEmptyDirectoriesRecursively(subDirectory);
}

if (!Directory.EnumerateFileSystemEntries(directory).Any()) {
Directory.Delete(directory);
}
} catch (Exception e) {
ASF.ArchiLogger.LogGenericException(e);
}
}

private static bool RelativeDirectoryStartsWith(string directory, params string[] prefixes) {
ArgumentException.ThrowIfNullOrEmpty(directory);

Expand Down
Loading
Loading