Skip to content

store: a plugin id that differs only in case is refused safely - #585

Merged
foxnne merged 2 commits into
mainfrom
store/case-clash
Oct 18, 2026
Merged

foxnne merged 2 commits into
mainfrom
store/case-clash

Conversation

@foxnne

@foxnne foxnne commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Part of fizzyedit/work#33

What changes

#560 landed a rule that refuses a plugin whose id matches another plugin's except for letter case. That code can lose data. This PR keeps the rule and fixes it, deciding every case by what the filesystem lists at the moment of the action, and taking the safe path whenever it can't tell.

  • Uninstall never deletes another installed plugin's folder, loaded or not.
    • The bug on main: an installed plugin in plugins/Graphl is not loaded, and a build of graphl is refused. Uninstalling the refused build from its card deleted plugins/graphl. On macOS and Windows that path is plugins/Graphl, so the installed plugin was deleted.
    • Now uninstallPlugin asks store.folders.deletable (new std-only app/store/plugin_folders.zig), which reads plugins/ fresh. It deletes only when an entry named exactly id exists and no other entry is id in another case. On a case-insensitive filesystem that second check is what protects the other plugin. It replaces App.sameFolder.
    • Anything else keeps the folder and only drops the card: no exact entry, a case variant beside it, a listing that can't be read, or an id that isn't a plain name.
    • A plugin folder that is a symlink (a dev setup) has the link removed, never what it points to.
    • No inode comparison. Zig fills directory-entry inodes on Windows from a FileIndex that NTFS leaves 0, so an inode rule would never delete there. The name rule doesn't depend on it.
  • Reads are fresh, and an unreadable listing refuses.
    • There is no session cache any more. Each user action that touches the disk reads plugins/ itself: install, update, uninstall, a live load.
    • The automatic update pass reads the listing once and passes it through every update in that pass (pass_folders in applyAllPendingUpdates).
    • If plugins/ can't be read, the install, update or load is refused as a possible clash, and uninstall keeps the folder. A plugins/ that doesn't exist yet simply holds nothing.
  • A refusal says why, and Uninstall stays reachable. The store refuses before anything is written (refusal), and builds the job through buildJob with a refused reason. That job is failed from the start and creates no folders.
    • On a card the job shows the reason, e.g. "its id differs only in case from the installed plugin 'Graphl'", with a Dismiss button. The plugin's own controls, Uninstall among them, stay below it. It doesn't offer a Retry that would only refuse again.
    • In the automatic pass it marks the update offer failed and logs the reason.
    • The PluginManager.refuseCaseClash seam from the first round is gone; the store decides this itself.
  • No truncation. Clash names are borrowed, or copied whole into a buffer of max_name_bytes. A message too long for its buffer is shown without the name, never cut short.
  • One card names every variant. Startup groups the folder names ignoring case in one pass (a case-insensitive hash map). Every name in a group of more than one is skipped, and one card lists them all.

SDK impact

  • None. App, the store and the loader are app-side, and the recorded fingerprint is unchanged.
  • Core-only or additive: reaches plugins at the next SDK release
  • Fingerprint moved: recorded in sdk/src/version.zig, sdk_version untouched, PR labelled sdk
  • SDK release: bumps sdk_version, lists the sdk PRs since the last sdk-v* tag

Verified

  • macOS (case-insensitive APFS): all eight gates (scripts/check-fmt.sh, zig build, test, test-integration, check-web, test-sdk-version, check-tests-reached, scripts/check-examples.sh).
    • New unit tests (fizzy-plugin-folders-tests):
      • a name in another case clashes and the same name doesn't;
      • uninstall deletes only an entry of exactly the name with no variant beside it;
      • a missing entry, a variant, .., a/b and an unreadable plugins/ all keep;
      • an exact-name symlink is .link;
      • a listing reads folders and links, and a plugins/ not made yet holds nothing.
    • The headless whole-editor test covers:
      • the case variant Text of the loaded text is refused before it is opened, and its card shows failed;
      • uninstalling Text leaves a file in plugins/text;
      • an installed, unloaded plugins/Graphl is found fresh by the clash check;
      • uninstalling the refused graphl card leaves plugins/Graphl/keep (the data-loss case on main, reproduced here);
      • plugins/solo is still deleted by uninstall.
    • Not testable here:
      • two folders differing only in case (needs a case-sensitive filesystem);
      • the store card's refusal and Dismiss: the store's download path has no test harness.
  • Windows: the name rule doesn't use inodes.
  • Linux:
  • Web: builds (check-web); there is no plugins/ folder on the web.

Follow-ups

🤖 Generated with Claude Code

foxnne and others added 2 commits October 18, 2026 15:57
Uninstall deletes plugins/<id> only when the filesystem lists a folder of
exactly that name that no other entry shares an inode with; anything else
(a case variant standing there, an alias, an unreadable listing, an id
that is not a plain name) keeps the folder and drops only the card. On
macOS this stopped a refused build's card from deleting an installed,
unloaded plugin's folder. A refused store install or update becomes a
failed job (Retry, or the update offer marked failed) instead of a row
left started; plugins/ is read once per session for the clash check and
again after an install or uninstall; the clash names come back borrowed,
never truncated; and one card names every case variant of a folder.

Part of #530

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the case-clash fix. Uninstall deletes plugins/<id> only when an
entry of exactly that name exists and no other entry is the id in
another case (store.folders.deletable, a new std-only file with unit
tests); an exact-name symlink loses only the link. No inode rule: Zig
fills Windows directory-entry inodes from a FileIndex NTFS leaves 0. The
session cache is gone: each install, update, uninstall and load reads
plugins/ fresh, the automatic update pass reads it once for the pass,
and an unreadable listing refuses the install and keeps the folder. A
refusal is built through buildJob with its reason, shown on the card with
a Dismiss beside the plugin's own controls; startup groups case variants
in one pass.

Part of #530

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne marked this pull request as ready for review October 18, 2026 21:51
@foxnne
foxnne merged commit 2227fdc into main Oct 18, 2026
11 checks passed
@foxnne
foxnne deleted the store/case-clash branch October 18, 2026 22:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant