Skip to content

Every SaveAsync resets profiles.json, commands.json and active-profile.json to 0644 and replaces symlinked data files with regular ones #221

Open

Description

@matt-edmondson

What's wrong

JsonKeybindingRepository.WriteFileUnlockedAsync (Keybinding/Services/JsonKeybindingRepository.cs, lines ~403–419 at 89f14b9) does this for each data file:

await File.WriteAllTextAsync(tempPath, contents);
File.Move(tempPath, path, overwrite: true);

The temp file is created fresh, so after the move:

  • On Unix: each data file has the umask default mode (usually 0644), whatever mode it had before. A user who restricts the keybinding data to 0600 gets world-readable files back on the next save.
  • When a data file is a symlink: the rename replaces the link with a regular file, and the link's target stops being updated.
  • On Windows (not tested): MoveFileEx with replace leaves the temp file's inherited ACL in place of any explicit ACL the original had. File.Replace would keep it.

Reproduction (Linux, .NET 10)

KeybindingManager initialise → bind file.save to Ctrl+S → SaveAsync → chmod 0600 all three files → rebind → SaveAsync:

active-profile.json: 0600 -> OtherRead, GroupRead, UserWrite, UserRead
commands.json:       0600 -> OtherRead, GroupRead, UserWrite, UserRead
profiles.json:       0600 -> OtherRead, GroupRead, UserWrite, UserRead

Suggested fix

  • When path exists on non-Windows, create the temp file with UnixCreateMode = File.GetUnixFileMode(path) (via FileStreamOptions), or call File.SetUnixFileMode on it before the move.
  • Use File.Replace(tempPath, path, null) when the destination exists, which keeps the Windows ACL. Keep File.Move for the first write.
  • Resolve a symlinked path to its final target and write the temp file next to that target.

Acceptance criteria:

  • After a save over existing 0600 files, they are still 0600.
  • A symlinked data file is still a symlink after a save, and its target holds the new content.
  • Saves stay atomic: the target is never truncated in place.

Navigation's JsonFilePersistenceProvider has the same problem and is filed separately.

Activity

  1. matt-edmondson commented on Nov 5, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    • Category: Bug
    • Priority: High. Every save quietly widens files the user restricted to 0600 back to world-readable, and turns a symlinked data file into a regular file, so the link's real target stops getting updates (silent data divergence for anyone keeping config in a dotfiles repo). It happens on the normal save path, with no unusual trigger.
    • Area / suggested assignment: JsonKeybindingRepository.WriteFileUnlockedAsync (Keybinding/Services/JsonKeybindingRepository.cs), the atomic temp-file save.
    • Duplicates / in progress: Same root cause as SaveStateAsync resets a chmod 600 state file to 0644 and replaces a symlinked state file with a regular file (regression from the temp-file save, #105) Navigation#107, which its body cross-references, filed at the same time. The two are separate repositories, so it is not a duplicate, but they should get the same fix (temp file created with the target's Unix mode, symlink resolved to its final target, File.Replace when the target exists). No open PR fixes it. Open PR Resolve a relative data directory once, for both the files and the lock [patch] #220 ("Resolve a relative data directory once, for both the files and the lock") touches path handling in the same repository, so land it first and resolve the symlink on top of the path it settles on.
    • Next step: Implement the three suggested changes. Add Unix-only tests for mode preservation and for a symlink surviving a save. Keep the atomicity guarantee from the acceptance criteria.

    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