Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions AppDataStorage.Test/AppDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,24 @@
Assert.AreEqual(string.Empty, appData.Data, "Data should be default if loaded from corrupt file.");
}

[TestMethod]
public void TestLoadOrCreateArchivesAnUnreadableFileInsteadOfDeletingIt()
{
using TestAppData original = CreateTestAppDataWithContent("years of user settings");
original.Save();
AbsoluteFilePath filePath = original.FilePath;
string unreadable = AppData.FileSystem.File.ReadAllText(filePath).Replace("{", "{,", StringComparison.Ordinal);
AppData.FileSystem.File.WriteAllText(filePath, unreadable);

TestAppData appData = TestAppData.LoadOrCreate();

Assert.AreEqual(string.Empty, appData.Data, "Data should be default if the file could not be read.");

string[] archived = AppData.FileSystem.Directory.GetFiles(filePath.AbsoluteDirectoryPath.ToString(), $"{Path.GetFileName(filePath.ToString())}.corrupt.*");
Assert.AreEqual(1, archived.Length, "The unreadable file must be kept, not deleted.");

Check warning on line 458 in AppDataStorage.Test/AppDataTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.HasCount' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_AppDataStorage&issues=AaEM7YcnAvSUTv5KxPv3&open=AaEM7YcnAvSUTv5KxPv3&pullRequest=337
Assert.AreEqual(unreadable, AppData.FileSystem.File.ReadAllText(archived[0]), "The archive must hold the original content.");
}

[TestMethod]
public void TestLoadOrCreateHandlesNullJsonFile()
{
Expand Down
31 changes: 23 additions & 8 deletions AppDataStorage/AppData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,19 +220,32 @@

// Archiving rather than deleting keeps the recovered content for inspection, and moving it
// out of the way is what stops a candidate that turns out to be unreadable from being
// promoted again on the next attempt: LoadOrCreate deletes a file it cannot deserialize and
// promoted again on the next attempt: LoadOrCreate archives a file it cannot deserialize and
// reads again, which would otherwise restore the same bad content forever.
_ = Archive(candidate, string.Empty);
return true;
}

/// <summary>
/// Moves <paramref name="filePath"/> aside under a unique timestamped name, so its content
/// survives without being read again in its place.
/// </summary>
/// <param name="filePath">The file to archive.</param>
/// <param name="label">A suffix naming why the file was archived, such as ".corrupt", or empty.</param>
/// <returns>The path the file was archived to.</returns>
internal static AbsoluteFilePath Archive(AbsoluteFilePath filePath, string label)
{
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
AbsoluteFilePath archived = candidate.WithSuffix($".{timestamp}");
AbsoluteFilePath archived = filePath.WithSuffix($"{label}.{timestamp}");
int counter = 0;
while (FileSystem.File.Exists(archived))
{
counter++;
archived = candidate.WithSuffix($".{timestamp}_{counter}");
archived = filePath.WithSuffix($"{label}.{timestamp}_{counter}");
}

FileSystem.File.Move(candidate, archived);
return true;
FileSystem.File.Move(filePath, archived);
return archived;
}

/// <summary>
Expand Down Expand Up @@ -453,10 +466,10 @@
/// </summary>
#if NET9_0_OR_GREATER
[JsonIgnore]
public static Lock Lock { get; } = new();

Check warning on line 469 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 469 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 469 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 469 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.
#else
[JsonIgnore]
public static object Lock { get; } = new();

Check warning on line 472 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 472 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 472 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 472 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 472 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 472 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.
#endif

internal bool IsSaveQueued()
Expand Down Expand Up @@ -581,9 +594,11 @@
}
catch (JsonException)
{
// file was corrupt or could not be deserialized
// delete and try load a backup
AppData.FileSystem.File.Delete(newAppData.FilePath);
// The file could not be read as T, whether from corruption, a hand edit or a model
// change in an app update. It is usually the only copy of the user's data, since a
// successful save removes the backup, so it is archived rather than deleted. The
// retry then finds it missing and falls back to a temp or backup file, or defaults.
_ = AppData.Archive(newAppData.FilePath, ".corrupt");
return LoadOrCreate(subdirectory, fileName);
}
}
Expand Down
Loading