hardening/sync-safety #4

Merged
jack merged 55 commits from hardening/sync-safety into main 2026-10-12 20:41:30 -04:00
Owner
No description provided.
jack self-assigned this 2026-10-12 13:53:12 -04:00
jack added 12 commits 2026-10-12 13:53:12 -04:00
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
vault/{mod,index,links,mutate,names,state,undo,tests}.rs. No behaviour change:
352 tests pass, clippy clean. Public paths are re-exported unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
max_width=140 and use_small_heuristics=Max match the existing style, so the
CI fmt check from L-002 can pass. No behaviour change: 352 tests, clippy clean.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
fs::rename replaces silently, so exists()-then-rename lost notes whenever a sync
client created the destination in between. rename_no_replace (renameat2
NOREPLACE / renamex_np EXCL / MoveFileExW without REPLACE_EXISTING) and
atomic_create_new make check-and-act one operation. Used by move, undo-move,
trash, restore and create. Failure injection moved into the primitive.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A note written into an excluded, hidden or symlinked folder, or outside the
vault, vanished from the app and left links to it unresolved or unrewritten.
check_indexable is the single predicate; reload_path shares its symlink test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A sync delivering hundreds of files caused one synchronous read plus a full
reindex per event on the UI thread. Events now go through FsBatch: the first
after a quiet period applies at once, a following stream is held until it
pauses (150 ms) or 1 s passes, then applied with a single reindex via
Vault::reload_paths.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
One definition of ignored names (dot-entries and ~syncthing~*) shared by scan,
reload and the watcher, so .git churn and sync temp files neither enter the
index nor wake the UI. Syncthing/Dropbox conflict copies stay ordinary notes
but are drawn in the warning colour with an explanatory hint.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A momentary read error (sync client or antivirus holding the file, flaky
mount) removed the note from the index, closing its tab and breaking links.
The last good entry now stays, the failure shows in the skipped list, and
the path is retried every 3 s until it reads or is genuinely gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
workspace.json inside <vault>/.notebook/ was carried between devices by the
sync tool, so two devices fought over it and produced conflict copies. It now
lives in the config directory keyed by the vault path (name + FNV-1a hash).
An older in-vault file is read once as the starting point and left untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Ledger: record status, next steps and commit hashes
All checks were successful
CI / test (pull_request) Successful in 4m30s
CI / deny (pull_request) Successful in 1m52s
f7db8d06d7
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A note whose name was near the 255-byte limit could not be saved because its
temporary name was longer than the note's. The temp name is now
.robsidian-tmp-<pid>-<n>. D4 decided: tag rules match Obsidian, pinned by test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
trash() used unwrap_or_default on the read, so an unreadable note was deleted
with an empty undo record. Permanent and system trash now refuse; the vault
.trash moves the file itself and records no text. restore_trashed errors when
neither the trashed copy nor the text exists.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Antivirus, indexers and sync clients briefly hold files on Windows, which
surfaced as a failed save. The final ReplaceFileW/MoveFileExW is retried five
times over about 0.5 s on access-denied, sharing and lock violations. The retry
logic is OS independent and unit tested with an injected sleep.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A crash or power cut mid-save left a hidden .robsidian-tmp-* file that a sync
tool would copy to every device. The scan now remembers such files it walks
past and finish_open deletes those older than an hour. Other hidden files,
including a sync tool's own temp files, are never touched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
undo_move refused because the original name 'already existed': on macOS and
Windows it is the file being moved. Move and undo now share rename_file, which
takes the temp-name route for case-only renames.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A failed vault-wide replace whose rollback also failed left files in their
replaced state with no undo entry. The batch write now reports which changes
are still applied, and they are recorded as an undo entry.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
walker.flatten() dropped walk errors, so an unreadable folder (permissions, a
flaky mount) quietly produced a partial vault. They are now recorded and shown
in the skipped-items notice; the notice says 'items' since it is no longer only
files.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
L-117: README section on using a vault with Syncthing; ledger status
All checks were successful
CI / deny (pull_request) Successful in 1m43s
CI / test (pull_request) Successful in 4m33s
a1ed973e35
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Measured before changing anything. Editor layout of a 1 MB note took 1.7 s and
100 KB took 17 ms: slice_job cloned the whole LayoutJob (and scanned every
section) for each wrapped list item, so cost was items x document. It now cuts
each piece from the shared text and sections: 1 MB is 17 ms, 100 KB 1.7 ms.

The same harness showed the other suspected per-frame costs are negligible
(preview parse 35 ms/MB, find 0.3 ms, 5k-note scan 115 ms, 3k-row tree 3.5 ms),
so L-202 to L-205 are dropped on evidence. New: 'perf' Cargo profile and
ignored perf tests with ceilings and a linear-scaling guard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Ledger: Phase 2 outcome, corrected commit hashes, next steps
All checks were successful
CI / deny (pull_request) Successful in 1m56s
CI / test (pull_request) Successful in 3m51s
b87b1a17ad
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Written as failing tests first. Enter, Tab and Ctrl+Space no longer act on list
syntax inside fenced code; new lines use the note's own line ending (also for
Shift+Enter); an empty checkbox before a CRLF is recognised; lists continue
inside block quotes keeping the quote prefix; '* * *' and '- - -' are rules.

Introduces mdscan, the single definition of fenced lines (CommonMark rules),
extracted from tables.rs, which now uses it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The highlighter toggled code styling on any line starting with three backticks,
so tilde fences, longer fences holding shorter runs, indented fences and
backtick info strings were all styled wrongly. Failing tests first; it now asks
mdscan, which also knows delimiter lines and that frontmatter never opens a fence.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The audit claimed Enter/Tab during IME composition would be taken by list
handling. Reproduction attempts show the app is already safe in composition and
in a commit+Enter single frame (egui selects the preedit and list handling is
skipped with a selection). A guard was written, then removed when disabling it
did not change any test result. Three GUI tests pin the behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Tab with a selection replaced the selected text with a tab character. It now
indents every line the selection touches (Shift+Tab outdents) and keeps the
same text selected. Failing test first.

Selecting makes egui's IME preedit a selection, so the new handler would have
taken Tab during composition; a failing test showed that, and composing_after
(tracked from Ime events) keeps the handler out of the way.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Failing test first: Ctrl+Plus (Key::Plus, no Shift) did nothing because only
Key::Equals was bound. Shortcut now accepts the Equals/Plus alias, for any
binding of either. The AltGr and Ctrl+1..9 items are dropped with reasons in
the ledger; the Ctrl+Space default is left as open decision D5.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Failing tests first. A BOM hid the block; a flow list over several lines was
read-only Raw (its tags unindexed); \t and \uXXXX in double quotes lost their
meaning; Text("true") was written unquoted and came back as a boolean; control
characters were written raw, breaking one-line-per-property. One quote()
replaces three copies of the escaping.

Numbers are deliberately not quoted: the parser reads 'num: 42' as text, so
quoting would turn an edited number into a string.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Failing test first: after Tab completion an extra cursor stayed at char index 9,
which in the completed text is a newline, so typing would be mirrored there.
Completion edits at the main cursor only; both paths (Tab and the popup pick)
now clear the extras.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Ledger: Phase 3 status and commit hashes
All checks were successful
CI / deny (pull_request) Successful in 1m56s
CI / test (pull_request) Successful in 3m52s
925aadcd50
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
18 properties (ranges on char boundaries, cursors inside results, round trips,
idempotence) over mdscan, tables, multicursor, complete, frontmatter, tags,
find, links, embeds, prose and block indent.

They found a real bug: with CRLF line endings a cursor between the CR and the
LF made tables::locate take the next line as the cursor's line and subtract
past zero (a panic; an abort in release). The cursor's line is now the last
line starting at or before it. Example test added.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A write fails at every position in turn for replace, tag rename, move (link
rewrite) and undo, and a double fault (a write plus its rollback). Invariants:
each file is exactly its before or after text, no temp file is left behind, and
the index equals a fresh scan. Checked to be effective: with the rollback
disabled, four of the six go red. No defects found; the Phase 1 logic holds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Twelve flows through the real UI that had no coverage: another program
creating, deleting and bursting files; a sync conflict copy; the quick
switcher; new note and daily note; F2 rename with link rewrite and undo; trash
from the context menu and undo; the tags pane; the settings window; and a file
dropped on the window. They found no defects and pin the behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
scheduled.yml (GitHub and Forgejo): cargo deny check advisories, and the
property tests at 20000 cases, every Monday and on demand. Every CI run uses
1000 property cases. README corrected: macOS and Windows already run on every
push and PR. Pinning actions by SHA is left, with the reason, in the ledger.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Ledger: Phase 4 status
All checks were successful
CI / deny (pull_request) Successful in 1m42s
CI / test (pull_request) Successful in 4m18s
2af4bc6841
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
[text](Note.md), relative paths, %20 encoding, <...> and #fragment now count as
backlinks and are rewritten when the target is renamed or moved, and when the
note holding them moves (its relative links and images keep reaching the same
files). Found with the CommonMark parser so code and HTML are left alone.

Five failing vault tests first. The property test then found two bugs in the new
code, both fixed with example tests: a byte slice through a multi-byte ending,
and a linked image whose destinations were out of text order.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Failing tests first. Raw HTML used to vanish from the preview (a <details>
block or <br> lost its text); it now shows its text, with <br> as a line break
and comments hidden. Obsidian callout headers become a bold title. Task boxes
in the preview can be clicked: toggle_task finds the Nth box by running the
same parser over the note, so what counts as a box cannot differ between
drawing and editing; embedded notes' boxes are inert.

map_outside_code, a fourth hand-written fence parser, now uses mdscan.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The built-in fonts have no CJK glyphs, so Chinese, Japanese and Korean text (and
file names) showed as boxes unless the user chose a font file. A few well-known
system fonts per platform are found at start-up (at most two, read once) and
appended after the built-in fonts in every family, so Latin text is unchanged.

Verified end to end with Fonts::has_glyphs where such a font exists.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The first screen was a bare 'open a folder' prompt with no memory. Recently
opened vaults (up to 8, most recent first, those that still exist) are listed,
and a typed path that does not exist can be created as a vault with a starter
note. Creation never overwrites an existing Welcome note or any file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
macOS writes file names decomposed (e + combining accent), other systems and
people use the composed form, so a Mac-synced note did not match [[Café]] typed
elsewhere. Names are compared through fold_name (lowercase + NFC): link keys,
name, alias and attachment indexes, and path comparison. Names themselves are
never rewritten. Failing tests first.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
eframe's accesskit feature was off, so the app exposed no accessibility tree to
screen readers outside the test harness. Checked for Linux and the Windows
target; not tested with a real screen reader, which the README says.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Failing tests first for: .MD extension case and backslash separators in link
targets; invalid folder names in move_to_dir. Attachments are written with
create-new semantics (atomic_copy_new for dropped files) and never replace a
file that appears between the name check and the write. rust-version is
declared (1.91, found by clippy's incompatible_msrv: floor_char_boundary), and
the README's glibc statement now matches the workflows.

L-606, L-607, L-609, L-610 and L-611 are dropped with their reasons in the
ledger; a regression test for search results across a rescan is added.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Ledger: Phase 5 status
All checks were successful
CI / deny (pull_request) Successful in 1m50s
CI / test (pull_request) Successful in 4m56s
37fdc3e754
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Failing tests first. Column widths and padding use display columns (CJK and
emoji are two wide) while cursors stay in characters. renamed() measured the
prefix to replace in the lowercased name; it is now measured in the tag itself
(İ lowercases to two characters).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jyo6zEVEywhk1rFnvy66K
Failing tests first. A second crash overwrote an unanswered recovery; it is now
moved aside and offered next. The recovery header escapes the path (newline,
backslash) under a distinct v1+esc marker. load_json reported an unreadable
file as missing and let a second corrupt file replace the first backup; it now
says it cannot read, treats non-UTF-8 as corrupt, and numbers its backups.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jyo6zEVEywhk1rFnvy66K
Progress messages are no longer drawn in the error colour. The highlighter
judged a #tag per span without the character before it, so one glued to bold or
code was coloured as a tag although the index does not count it (failing test
first); it now passes that character on.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jyo6zEVEywhk1rFnvy66K
With extra cursors only the main one moved on Ctrl+Arrow, leaving the others
behind. Word moves are now part of multicursor::move_all (end of the next word
or start of the previous, over runs of one character class) and are applied to
all cursors. A GUI test was checked to fail with the key mapping disabled.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jyo6zEVEywhk1rFnvy66K
A large PDF or video dropped on the window was copied on the UI thread, freezing
it. Placement is now a disk-only function (place_attachment_file) run by a
worker; the UI thread collects the result, tells the index and inserts the
links, and ignores it if another vault was opened meanwhile. A deterministic
test shows the job is pending right after the drop and nothing is linked until
it is collected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jyo6zEVEywhk1rFnvy66K
The candidate list only appeared after an ambiguous Tab. It now opens by itself
while a link (or a tag with something typed) is being written, closes with Esc
for that link until a new one is started, and a click or Tab completes it.
Failing GUI tests first.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jyo6zEVEywhk1rFnvy66K
An Outline tab lists the open note's headings (outside code and properties, via
mdscan, which now also owns heading_level) and jumps the editor to the one
clicked. In the two-column layout the preview follows the editor's scroll
position proportionally. The GUI test for the sync was checked to fail with the
link disabled. Preview -> editor is left out: the preview's heights are
estimates and a two-way link would ping-pong.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jyo6zEVEywhk1rFnvy66K
Ledger: close out the plan; L-114 decided, final status
All checks were successful
CI / deny (pull_request) Successful in 2m0s
CI / test (pull_request) Successful in 4m40s
568a24eaa7
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jyo6zEVEywhk1rFnvy66K
egui's dnd_drop_zone paints its area with the inactive-widget fill even when
given Frame::NONE, so one grey rectangle sat behind the whole row and the 12 px
spacer looked like part of a tab. The row is now a plain horizontal layout that
takes drops with Response::dnd_release_payload, which paints nothing.

A GUI test checks no fill covers the gap (red before); another drags a tab past
two others and checks the new order (red with the drop disabled).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jyo6zEVEywhk1rFnvy66K
Tab strip: give the close button its own slightly different background
All checks were successful
CI / deny (pull_request) Successful in 1m41s
CI / test (pull_request) Successful in 4m53s
d0e4919370
Unselected tabs have no fill and the selected one is highlighted; the x had no
background of its own, so it merged with whatever was behind it. It now has a
chip a little off the panel colour (towards the text colour: lighter in a dark
theme, darker in a light one). A GUI test checks it differs from the panel and
from the selection highlight, but only slightly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jyo6zEVEywhk1rFnvy66K
Release 0.3.0: version bump and changelog
All checks were successful
CI / deny (pull_request) Successful in 1m53s
CI / test (pull_request) Successful in 4m18s
b59a544c60
Cargo.toml and Cargo.lock to 0.3.0; CHANGELOG gets a complete 0.3.0 section
(upgrade notes, added, changed, fixed, for contributors) in place of the partial
Unreleased one. Checked: packaging/check-tag.sh v0.3.0 and release-notes.sh
0.3.0 succeed, the release build (fat LTO, panic=abort) compiles and reports
0.3.0, and packaging/package.sh builds the Linux tarball.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jyo6zEVEywhk1rFnvy66K
jack merged commit 55d62572a6 into main 2026-10-12 20:41:30 -04:00
jack deleted branch hardening/sync-safety 2026-10-12 20:41:30 -04:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
misc/robsidian!4
No description provided.