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
55 changes: 55 additions & 0 deletions Schema.Editor.Test/SchemaFileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@

Assert.IsFalse(saved);
Assert.AreEqual("Access to the path is denied.", failure);
Assert.HasCount(0, Directory.GetFiles(scratchDirectory));

Check warning on line 134 in Schema.Editor.Test/SchemaFileTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.HasCount'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Schema&issues=AaFhkgUUQ6aU80gGlbXq&open=AaFhkgUUQ6aU80gGlbXq&pullRequest=329
}

/// <summary>
Expand Down Expand Up @@ -166,6 +166,61 @@
Assert.HasCount(1, Directory.GetFiles(scratchDirectory));
}

/// <summary>
/// Saving through a symbolic link writes the file the link names and leaves the link a link. The
/// atomic rename used to replace the link itself with a plain file, so a schema shared between
/// projects through a link silently stopped being shared.
/// </summary>
[TestMethod]
public void SavingThroughASymbolicLinkUpdatesTheFileItNames()
{
AbsoluteFilePath target = ScratchFile("shared.schema.json");
AbsoluteFilePath link = ScratchFile("link.schema.json");
Assert.IsTrue(SchemaFile.TrySave(new Schema(), target));

try
{
File.CreateSymbolicLink(link, target);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
Assert.Inconclusive($"Symbolic links cannot be made here: {exception.Message}");
}

Schema edited = new();
edited.AddClass("Order".As<ClassName>());
Assert.IsTrue(SchemaFile.TrySave(edited, link));

Assert.IsNotNull(new FileInfo(link).LinkTarget, "The link was replaced by a plain file.");
Assert.IsTrue(SchemaFile.TryLoad(target, out Schema? loaded));
Assert.IsNotNull(loaded);
Assert.IsNotNull(loaded.GetClass("Order".As<ClassName>()));
Assert.HasCount(2, Directory.GetFileSystemEntries(scratchDirectory));
}

/// <summary>
/// Saving over a file keeps its permission bits. The rename used to give the saved file the
/// process's default mode, so a group-writable schema became writable by its owner alone.
/// </summary>
[TestMethod]
public void SavingOverAFileKeepsItsPermissions()
{
if (OperatingSystem.IsWindows())
{
Assert.Inconclusive("Unix file modes do not apply on Windows.");
return;
}

Check warning on line 212 in Schema.Editor.Test/SchemaFileTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[OSCondition]' attribute instead of 'RuntimeInformation.IsOSPlatform' calls with early return or 'Assert.Inconclusive'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Schema&issues=AaFhkgUUQ6aU80gGlbXr&open=AaFhkgUUQ6aU80gGlbXr&pullRequest=329

AbsoluteFilePath path = ScratchFile("shared-mode.schema.json");
Assert.IsTrue(SchemaFile.TrySave(new Schema(), path));
const UnixFileMode Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.OtherRead;
File.SetUnixFileMode(path, Mode);

Assert.IsTrue(SchemaFile.TrySave(new Schema(), path));

Assert.AreEqual(Mode, File.GetUnixFileMode(path));
}

[TestMethod]
public void SavingWithNoPathFails() =>
Assert.IsFalse(SchemaFile.TrySave(new Schema(), new AbsoluteFilePath()));
Expand Down
33 changes: 33 additions & 0 deletions Schema.Editor/SchemaFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,23 @@ internal static bool TrySave(Schema schema, AbsoluteFilePath filePath, Action<st
/// over the target, so the target only ever holds a complete previous save or a complete new one.
/// </summary>
/// <remarks>
/// <para>
/// The temporary file is in the same directory so the final step is a rename on one volume rather
/// than a copy. It is deleted on any failure, which is what the <c>finally</c> is for: an
/// exception the caller does not catch still must not leave it behind.
/// </para>
/// <para>
/// A rename replaces a directory entry, not a file, so two things the target's entry carried
/// would be lost to it. A symbolic link would become a plain file holding the new content while
/// the file it pointed at - a schema shared between projects - kept the old one, so the path is
/// followed to the file it finally names and that file is the one replaced. And the new file
/// would have the process's default mode, so on Unix the existing file's permission bits are
/// copied onto the temporary file before the swap.
/// </para>
/// </remarks>
private static void WriteAtomically(string targetPath, string content, Action<string, string> writeFile)
{
targetPath = ResolveFinalTarget(targetPath);
string temporaryPath = $"{targetPath}.tmp-{Guid.NewGuid():N}";

try
Expand All @@ -175,6 +186,11 @@ private static void WriteAtomically(string targetPath, string content, Action<st

if (File.Exists(targetPath))
{
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(temporaryPath, File.GetUnixFileMode(targetPath));
}

File.Replace(temporaryPath, targetPath, destinationBackupFileName: null);
}
else
Expand All @@ -190,4 +206,21 @@ private static void WriteAtomically(string targetPath, string content, Action<st
}
}
}

/// <summary>
/// Follows <paramref name="path"/> through any symbolic links to the path of the file it finally
/// names, or returns it unchanged when it is not a link.
/// </summary>
/// <remarks>
/// A link whose target does not exist yet still resolves to that target, so the first save through
/// it creates the file the link names rather than replacing the link.
/// </remarks>
private static string ResolveFinalTarget(string path)
{
FileInfo file = new(path);

// Resolving a path that names nothing throws rather than answering null, and a first save
// always names nothing, so only a path that is a link is resolved.
return file.LinkTarget is null ? path : file.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? path;
}
}
2 changes: 1 addition & 1 deletion Schema/Cli/SchemaCommandLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public static int Run(string[] args, TextWriter output, TextWriter error, Schema
{
"generate" => RejectUnknownOptions([.. args.Skip(1)], [GeneratorOption], output, error, host)
?? Generate([.. args.Skip(1)], output, error),
"validate" => RejectUnknownOptions([.. args.Skip(1)], [], output, error, host)
"validate" => RejectUnknownOptions([.. args.Skip(1)], [NoDataOption], output, error, host)
?? Validate([.. args.Skip(1)], output, error),
_ => UnknownCommand(args[0], output, error, host),
};
Expand Down
Loading