Skip to content

Editor save replaces a symlinked .schema.json with a plain file (the link target is never updated) and resets the file's permissions, since the atomic save in #297 #324

Closed

Description

@matt-edmondson

What's wrong

The fix for #292 (commit a35b467, PR #297) changed SchemaFile.TrySave from File.WriteAllText(target) to writing <target>.tmp-<guid> and then swapping it in with File.Replace(temporaryPath, targetPath, null):

private static void WriteAtomically(string targetPath, string content, Action<string, string> writeFile)
{
string temporaryPath = $"{targetPath}.tmp-{Guid.NewGuid():N}";
try
{
writeFile(temporaryPath, content);
if (File.Exists(targetPath))
{
File.Replace(temporaryPath, targetPath, destinationBackupFileName: null);
}
else
{
File.Move(temporaryPath, targetPath);
}
}
finally
{
if (File.Exists(temporaryPath))
{
File.Delete(temporaryPath);
}
}
}

On Linux and macOS, File.Replace is a rename(2) onto the target path. That replaces the directory entry, so:

  1. A symlinked schema is cut loose from what it links to. If game.schema.json is a symlink to shared/game.schema.json (a schema shared between projects or kept in a sibling checkout), saving from the editor deletes the link and puts a regular file in its place. The real file the link pointed at keeps the old content, and every other project that uses it never sees the edit. Nothing reports this: the save succeeds, the editor title goes clean, and the next git status in the shared repository shows no change. File.WriteAllText, which the editor used until two days ago, followed the link and wrote the shared file.
  2. The file's mode is reset to the process default. The temporary file is created with the umask's mode, and it becomes the target, so a schema that was rw-rw-r-- (group-writable, for a team share) is rw-r--r-- after one save from the editor.

Repro

A scratch console app (net10.0, Linux) that does exactly what WriteAtomically does:

var real = Path.Combine(d, "shared", "game.schema.json");
File.WriteAllText(real, "OLD");
File.SetUnixFileMode(real, UserRead|UserWrite|GroupRead|GroupWrite|OtherRead);
var link = Path.Combine(d, "game.schema.json");
File.CreateSymbolicLink(link, real);

string tmp = $"{link}.tmp-{Guid.NewGuid():N}";
File.WriteAllText(tmp, "NEW");
File.Replace(tmp, link, null);           // SchemaFile.WriteAtomically

Console.WriteLine($"link still a symlink: {new FileInfo(link).LinkTarget != null}");
Console.WriteLine($"shared file content: {File.ReadAllText(real)}");
Console.WriteLine($"link path content: {File.ReadAllText(link)}");
// and the same on a plain group-writable file:
Console.WriteLine($"mode before: ...; after: {File.GetUnixFileMode(real2)}");

Output:

link still a symlink: False
shared file content: OLD
link path content: NEW
mode before: OtherRead, GroupWrite, GroupRead, UserWrite, UserRead; after: OtherRead, GroupRead, UserWrite, UserRead

Verified on Linux only. The roadmap makes the editor Windows-first but "defensively cross-platform", and Schema.Editor.Test exists because saving differs by operating system, which is why this is filed as a bug and not a platform gap. I did not test Windows ReplaceFile against a symbolic link.

Suggested fix

  • Before the swap, resolve the target through any links (new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true)). Write the temporary file beside that final target and replace that, so the link stays a link and the shared file is the one updated.
  • When replacing an existing file on Unix, copy its mode onto the temporary file (File.SetUnixFileMode(tmp, File.GetUnixFileMode(target))) before the swap.

Acceptance criteria

Activity

  1. matt-edmondson commented on Oct 20, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions