From 11afc915893221c1bd658a80f8f949dab07620de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 02:38:10 +0000 Subject: [PATCH 1/2] Keep symbolic links and permissions when the editor saves [patch] The atomic save (#297) renamed a temporary file over the target, which replaces the directory entry: a symbolic link became a plain file and the shared schema it named kept the old content, and the saved file took the process's default mode. The save now follows the path to the file it finally names and replaces that, and copies the existing file's Unix mode onto the temporary file first. Fixes #324 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01L9FBXHS37WoAF87LDY2ZVV --- Schema.Editor.Test/SchemaFileTests.cs | 55 +++++++++++++++++++++++++++ Schema.Editor/SchemaFile.cs | 33 ++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/Schema.Editor.Test/SchemaFileTests.cs b/Schema.Editor.Test/SchemaFileTests.cs index f7112a3..cdd5f72 100644 --- a/Schema.Editor.Test/SchemaFileTests.cs +++ b/Schema.Editor.Test/SchemaFileTests.cs @@ -166,6 +166,61 @@ public void SavingOverAnExistingFileReplacesIt() Assert.HasCount(1, Directory.GetFiles(scratchDirectory)); } + /// + /// 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. + /// + [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()); + 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())); + Assert.HasCount(2, Directory.GetFileSystemEntries(scratchDirectory)); + } + + /// + /// 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. + /// + [TestMethod] + public void SavingOverAFileKeepsItsPermissions() + { + if (OperatingSystem.IsWindows()) + { + Assert.Inconclusive("Unix file modes do not apply on Windows."); + return; + } + + 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())); diff --git a/Schema.Editor/SchemaFile.cs b/Schema.Editor/SchemaFile.cs index 58e1204..971c331 100644 --- a/Schema.Editor/SchemaFile.cs +++ b/Schema.Editor/SchemaFile.cs @@ -161,12 +161,23 @@ internal static bool TrySave(Schema schema, AbsoluteFilePath filePath, Action /// + /// /// 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 finally is for: an /// exception the caller does not catch still must not leave it behind. + /// + /// + /// 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. + /// /// private static void WriteAtomically(string targetPath, string content, Action writeFile) { + targetPath = ResolveFinalTarget(targetPath); string temporaryPath = $"{targetPath}.tmp-{Guid.NewGuid():N}"; try @@ -175,6 +186,11 @@ private static void WriteAtomically(string targetPath, string content, Action + /// Follows through any symbolic links to the path of the file it finally + /// names, or returns it unchanged when it is not a link. + /// + /// + /// 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. + /// + 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; + } } From 592dbe24e6ff7afac8c5ceecf0b35cbc3971d732 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 02:42:26 +0000 Subject: [PATCH 2/2] Let validate read its own --no-data option Refusing unknown options (#310) and the --no-data flag landed separately, and validate's list of known options was left empty, so 'validate --no-data' exited 1 with the usage text and TestValidateSkipsDataFilesWhenAskedTo fails on main. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01L9FBXHS37WoAF87LDY2ZVV --- Schema/Cli/SchemaCommandLine.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Schema/Cli/SchemaCommandLine.cs b/Schema/Cli/SchemaCommandLine.cs index 1d9bb90..d240490 100644 --- a/Schema/Cli/SchemaCommandLine.cs +++ b/Schema/Cli/SchemaCommandLine.cs @@ -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), };