Skip to content

Commit

Permalink
Closes #3156 (#3182)
Browse files Browse the repository at this point in the history
* Closes #3156

* Misc

* Misc

* Rewrite update mechanism ONCE AGAIN, this time to eradicate FSW

* Make creating debug directory non-fatal again, like it used to be

* Deduplicate code

* Remove dead code

* Print update cleanup just once

* Address remaining feedback, go back to _old and _new

* One more nice improvement
  • Loading branch information
JustArchi authored Apr 4, 2024
1 parent edc7c38 commit ae9dfca
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 159 deletions.
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));

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 @@ internal static (bool IsWeak, string? Reason) TestPasswordStrength(string passwo
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) {
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 @@ internal static void WarnAboutIncompleteTranslation(ResourceManager resourceMana
}
}

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 @@ private static bool RelativeDirectoryStartsWith(string directory, params string[
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

0 comments on commit ae9dfca

Please sign in to comment.