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
37 changes: 6 additions & 31 deletions ArchiSteamFarm/Core/ASF.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ public static class ASF {

internal static readonly SemaphoreSlim OpenConnectionsSemaphore = new(WebBrowser.MaxConnections, WebBrowser.MaxConnections);

internal static string DebugDirectory => Path.Combine(SharedInfo.DebugDirectory, OS.ProcessStartTime.ToString("yyyy-MM-dd-THH-mm-ss", CultureInfo.InvariantCulture));
JustArchi marked this conversation as resolved.
Show resolved Hide resolved

internal static ICrossProcessSemaphore? ConfirmationsSemaphore { get; private set; }
internal static ICrossProcessSemaphore? GiftsSemaphore { get; private set; }
internal static ICrossProcessSemaphore? InventorySemaphore { get; private set; }
Expand Down Expand Up @@ -779,36 +781,9 @@ 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 (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 directories from previous update exist, it's a good idea to purge them now
if (!await Utilities.UpdateCleanup(SharedInfo.HomeDirectory).ConfigureAwait(false)) {
return (false, null);
}

ArchiLogger.LogGenericInfo(Strings.UpdateCheckingNewVersion);
Expand Down Expand Up @@ -994,7 +969,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
234 changes: 138 additions & 96 deletions ArchiSteamFarm/Core/Utilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,12 @@
namespace ArchiSteamFarm.Core;

public static class Utilities {
private const byte MaxSharingViolationTries = 15;
private const uint SharingViolationHResult = 0x80070020;
private const byte TimeoutForLongRunningTasksInSeconds = 60;

private static readonly FrozenSet<char> DirectorySeparators = new HashSet<char>(2) { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.ToFrozenSet();

// normally we'd just use words like "steam" and "farm", but the library we're currently using is a bit iffy about banned words, so we need to also add combinations such as "steamfarm"
private static readonly FrozenSet<string> ForbiddenPasswordPhrases = new HashSet<string>(10, StringComparer.InvariantCultureIgnoreCase) { "archisteamfarm", "archi", "steam", "farm", "archisteam", "archifarm", "steamfarm", "asf", "asffarm", "password" }.ToFrozenSet(StringComparer.InvariantCultureIgnoreCase);

Expand Down Expand Up @@ -322,117 +326,76 @@
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) {
ArgumentNullException.ThrowIfNull(zipArchive);
internal static async Task<bool> UpdateCleanup(string targetDirectory) {
ArgumentException.ThrowIfNullOrEmpty(targetDirectory);

// Firstly we'll move all our existing files to a backup directory
string backupDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectory);

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

if (string.IsNullOrEmpty(fileName)) {
ASF.ArchiLogger.LogNullError(fileName);
bool updateCleanup = false;

return false;
}
try {
string updateDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectoryNew);

string relativeFilePath = Path.GetRelativePath(targetDirectory, file);
if (Directory.Exists(updateDirectory)) {
if (!updateCleanup) {
updateCleanup = true;

if (string.IsNullOrEmpty(relativeFilePath)) {
ASF.ArchiLogger.LogNullError(relativeFilePath);
ASF.ArchiLogger.LogGenericInfo(Strings.UpdateCleanup);
}

return false;
Directory.Delete(updateDirectory, true);
}

string? relativeDirectoryName = Path.GetDirectoryName(relativeFilePath);

switch (relativeDirectoryName) {
case null:
ASF.ArchiLogger.LogNullError(relativeDirectoryName);
string backupDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectoryOld);

return false;
case "":
// No directory, root folder
switch (fileName) {
case Logging.NLogConfigurationFile:
case SharedInfo.LogFile:
// Files with those names in root directory we want to keep
continue;
}
if (Directory.Exists(backupDirectory)) {
if (!updateCleanup) {
updateCleanup = true;

break;
case SharedInfo.ArchivalLogsDirectory:
case SharedInfo.ConfigDirectory:
case SharedInfo.DebugDirectory:
case SharedInfo.PluginsDirectory:
case SharedInfo.UpdateDirectory:
// 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)) {
continue;
}
ASF.ArchiLogger.LogGenericInfo(Strings.UpdateCleanup);
}

break;
await DeletePotentiallyUsedDirectory(backupDirectory).ConfigureAwait(false);
}
} catch (Exception e) {

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

View workflow job for this annotation

GitHub Actions / Qodana for .NET

RoslynAnalyzers Do not catch general exception types

Modify 'UpdateCleanup' to catch a more specific allowed exception type, or rethrow the exception
ASF.ArchiLogger.LogGenericException(e);

string targetBackupDirectory = relativeDirectoryName.Length > 0 ? Path.Combine(backupDirectory, relativeDirectoryName) : backupDirectory;
Directory.CreateDirectory(targetBackupDirectory);

string targetBackupFile = Path.Combine(targetBackupDirectory, fileName);

File.Move(file, targetBackupFile, true);
return false;
}

// We can now get rid of directories that are empty
DeleteEmptyDirectoriesRecursively(targetDirectory);

if (!Directory.Exists(targetDirectory)) {
Directory.CreateDirectory(targetDirectory);
if (updateCleanup) {
ASF.ArchiLogger.LogGenericInfo(Strings.Done);
}

// 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));
return true;
}

if (!file.StartsWith(targetDirectory, StringComparison.Ordinal)) {
throw new InvalidOperationException(nameof(file));
}
internal static async Task<bool> UpdateFromArchive(ZipArchive zipArchive, string targetDirectory) {
ArgumentNullException.ThrowIfNull(zipArchive);
ArgumentException.ThrowIfNullOrEmpty(targetDirectory);

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";
// Firstly, ensure once again our directories are purged and ready to work with
if (!await UpdateCleanup(targetDirectory).ConfigureAwait(false)) {
return false;
}

File.Move(file, targetBackupFile, true);
}
// Now extract the zip file to entirely new location, this decreases chance of corruptions if user kills the process during this stage
string updateDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectoryNew);

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

if (string.IsNullOrEmpty(directory)) {
ASF.ArchiLogger.LogNullError(directory);
// Now, critical section begins, we're going to move all files from target directory to a backup directory
string backupDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectoryOld);

return false;
}
Directory.CreateDirectory(backupDirectory);

if (!Directory.Exists(directory)) {
Directory.CreateDirectory(directory);
}
MoveAllUpdateFiles(targetDirectory, backupDirectory, true);

// We're not interested in extracting placeholder files (but we still want directories created for them, done above)
switch (zipFile.Name) {
case ".gitkeep":
continue;
}
}
// Finally, we can move the newly extracted files to target directory
MoveAllUpdateFiles(updateDirectory, targetDirectory, false);

zipFile.ExtractToFile(file);
}
// 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,23 +454,104 @@
}
}

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

if (!Directory.Exists(directory)) {
for (byte i = 1; (i <= MaxSharingViolationTries) && Directory.Exists(directory); i++) {
if (i > 1) {
await Task.Delay(1000).ConfigureAwait(false);
}

try {
Directory.Delete(directory, true);
} catch (IOException e) when ((i < MaxSharingViolationTries) && ((uint) e.HResult == SharingViolationHResult)) {
// It's entirely possible that old process is still running, we allow this to happen and add additional delay
ASF.ArchiLogger.LogGenericDebuggingException(e);

continue;
}

return;
}
}

try {
foreach (string subDirectory in Directory.EnumerateDirectories(directory)) {
DeleteEmptyDirectoriesRecursively(subDirectory);
private static void MoveAllUpdateFiles(string sourceDirectory, string targetDirectory, bool keepUserFiles) {
ArgumentException.ThrowIfNullOrEmpty(sourceDirectory);
ArgumentException.ThrowIfNullOrEmpty(sourceDirectory);

// Determine if targetDirectory is within sourceDirectory, if yes we need to skip it from enumeration further below
string targetRelativeDirectoryPath = Path.GetRelativePath(sourceDirectory, targetDirectory);

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

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

if (!Directory.EnumerateFileSystemEntries(directory).Any()) {
Directory.Delete(directory);
string relativeFilePath = Path.GetRelativePath(sourceDirectory, file);

if (string.IsNullOrEmpty(relativeFilePath)) {
throw new InvalidOperationException(nameof(relativeFilePath));
}
} catch (Exception e) {
ASF.ArchiLogger.LogGenericException(e);

string? relativeDirectoryName = Path.GetDirectoryName(relativeFilePath);

switch (relativeDirectoryName) {
case null:
throw new InvalidOperationException(nameof(keepUserFiles));
case "":
// No directory, root folder
switch (fileName) {
case Logging.NLogConfigurationFile when keepUserFiles:
case SharedInfo.LogFile when keepUserFiles:
// Files with those names in root directory we want to keep
continue;
}

break;
case SharedInfo.ArchivalLogsDirectory when keepUserFiles:
case SharedInfo.ConfigDirectory when keepUserFiles:
case SharedInfo.DebugDirectory when keepUserFiles:
case SharedInfo.PluginsDirectory when keepUserFiles:
case SharedInfo.UpdateDirectoryNew:
case SharedInfo.UpdateDirectoryOld:
// Files in those constant directories we want to keep in their current place
continue;
default:
// If we're moving files deeper into source location, we need to skip the newly created location from it
if (!string.IsNullOrEmpty(targetRelativeDirectoryPath) && ((relativeDirectoryName == targetRelativeDirectoryPath) || RelativeDirectoryStartsWith(relativeDirectoryName, targetRelativeDirectoryPath))) {
continue;
}

// Below code block should match the case above, it handles subdirectories
if (RelativeDirectoryStartsWith(relativeDirectoryName, SharedInfo.UpdateDirectoryNew, SharedInfo.UpdateDirectoryOld)) {
continue;
}

if (keepUserFiles && RelativeDirectoryStartsWith(relativeDirectoryName, SharedInfo.ArchivalLogsDirectory, SharedInfo.ConfigDirectory, SharedInfo.DebugDirectory, SharedInfo.PluginsDirectory)) {
continue;
}

break;
}

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

if (relativeDirectoryName.Length > 0) {
// File inside a subdirectory
targetUpdateDirectory = Path.Combine(targetDirectory, relativeDirectoryName);

Directory.CreateDirectory(targetUpdateDirectory);
} else {
// File in root directory
targetUpdateDirectory = targetDirectory;
}

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

File.Move(file, targetUpdateFile, true);
}
}

Expand All @@ -518,8 +562,6 @@
throw new ArgumentNullException(nameof(prefixes));
}

HashSet<char> separators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];

return prefixes.Where(prefix => (directory.Length > prefix.Length) && separators.Contains(directory[prefix.Length])).Any(prefix => directory.StartsWith(prefix, StringComparison.Ordinal));
return prefixes.Any(prefix => !string.IsNullOrEmpty(prefix) && (directory.Length > prefix.Length) && DirectorySeparators.Contains(directory[prefix.Length]) && directory.StartsWith(prefix, StringComparison.Ordinal));
}
}
4 changes: 1 addition & 3 deletions ArchiSteamFarm/NLog/Logging.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,7 @@ internal static void InitCoreLoggers(bool uniqueInstance) {

if (uniqueInstance) {
try {
if (!Directory.Exists(SharedInfo.ArchivalLogsDirectory)) {
Directory.CreateDirectory(SharedInfo.ArchivalLogsDirectory);
}
Directory.CreateDirectory(SharedInfo.ArchivalLogsDirectory);
} catch (Exception e) {
ASF.ArchiLogger.LogGenericException(e);
}
Expand Down
Loading
Loading