feat: harden stale-file change detection with mtime+size fingerprint (#17) #22

Closed
the_og wants to merge 0 commits from feat/17-stale-size-hardening into main
Owner

Closes #17

Problem

PR #15 added stale-file protection, but the check compares bare disk_mtime != self._loaded_mtime. This misses a concurrent external write when:

  • the write lands within the filesystem's mtime resolution (same-second writes), or
  • the writer restores the original mtime after writing.

A missed detection means BCC silently overwrites an external edit without ever showing StaleDialog.

Fix

Pair mtime with file size. bcc_core.py gets a new ConfigStat (NamedTuple[mtime, size]) and config_fingerprint(path) helper alongside the existing config_mtime() (kept, still used/tested independently). bcc.py's MainWindow._loaded_mtime becomes _loaded_stat: core.ConfigStat | None, populated via config_fingerprint() at load and at both save-completion paths (normal save + merge). The save-path stale check now triggers on disk_stat != self._loaded_stat, which is true when either mtime or size differs.

A full content hash is out of scope per the issue — size+mtime is the intended middle ground.

Files changed

  • bcc_core.py — add ConfigStat NamedTuple + config_fingerprint()
  • bcc.py_loaded_mtime_loaded_stat; stale check now compares the full fingerprint
  • tests/test_core.py — 3 new tests, including the required regression case: write a file, snapshot it, rewrite with different (larger) content, force the original mtime back via os.utime, and assert the change is still detected because size differs

Verification

  • ruff check . — all checks passed
  • ruff format --check . — clean
  • pytest — 95 passed (was 92 on main; +3 new tests), including the hardening regression test

Note: one incidental fix included — bcc_core.py's existing non-breaking-space normalization (_normalize_unicode) used a literal NBSP character in .replace(...); rewritten as chr(0xA0) for robustness. No behavior change.

Closes #17 ## Problem PR #15 added stale-file protection, but the check compares bare `disk_mtime != self._loaded_mtime`. This misses a concurrent external write when: - the write lands within the filesystem's mtime resolution (same-second writes), or - the writer restores the original mtime after writing. A missed detection means BCC silently overwrites an external edit without ever showing `StaleDialog`. ## Fix Pair mtime with file **size**. `bcc_core.py` gets a new `ConfigStat` (`NamedTuple[mtime, size]`) and `config_fingerprint(path)` helper alongside the existing `config_mtime()` (kept, still used/tested independently). `bcc.py`'s `MainWindow._loaded_mtime` becomes `_loaded_stat: core.ConfigStat | None`, populated via `config_fingerprint()` at load and at both save-completion paths (normal save + merge). The save-path stale check now triggers on `disk_stat != self._loaded_stat`, which is true when **either** mtime or size differs. A full content hash is out of scope per the issue — size+mtime is the intended middle ground. ## Files changed - `bcc_core.py` — add `ConfigStat` NamedTuple + `config_fingerprint()` - `bcc.py` — `_loaded_mtime` → `_loaded_stat`; stale check now compares the full fingerprint - `tests/test_core.py` — 3 new tests, including the required regression case: write a file, snapshot it, rewrite with different (larger) content, force the original mtime back via `os.utime`, and assert the change is still detected because size differs ## Verification - `ruff check .` — all checks passed - `ruff format --check .` — clean - `pytest` — 95 passed (was 92 on `main`; +3 new tests), including the hardening regression test Note: one incidental fix included — `bcc_core.py`'s existing non-breaking-space normalization (`_normalize_unicode`) used a literal NBSP character in `.replace(...)`; rewritten as `chr(0xA0)` for robustness. No behavior change.
the_og added the P2 label 2026-07-07 20:35:47 -04:00
the_og added 7 commits 2026-07-07 20:35:47 -04:00
Bare mtime equality can miss a concurrent external write that lands
within the filesystem's mtime resolution (same-second writes), or
where the writer restores the original mtime. Add config_fingerprint()
returning a (mtime, size) ConfigStat pair; the stale-file check in
bcc.py now compares both fields instead of mtime alone. config_mtime()
is kept as-is (still used/tested independently).
The previous commit accidentally normalized the non-breaking space
(U+00A0) in _normalize_unicode's replace() call to a regular space
during a copy/paste, turning that replace() into a no-op. Use an
explicit   escape instead of the literal character so it can't
be silently corrupted again.
The previous commit message said this was fixed but the content still
had the literal (unescaped) line. Apply the   escape for real
this time.
Two prior attempts to fix this line via a literal or  -escaped
non-breaking space both silently reverted back to a no-op regular-space
replace during transcription. Using chr(0xA0) instead removes any
non-ASCII or backslash-escape character from the source line entirely.
MainWindow._loaded_mtime -> _loaded_stat (core.ConfigStat), populated
via core.config_fingerprint() at load/save time. The save-path stale
check now compares the full fingerprint (mtime AND size) instead of
bare mtime equality.
test: add coverage for config_fingerprint mtime+size hardening (#17)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 11s
CI / Tests (py3.12) (pull_request) Successful in 7s
f9752211a2
Includes the required regression case: rewrite a file with different
content, force the original mtime back via os.utime, and assert the
fingerprint still differs (because size changed).
Author
Owner

Supervisor review (Cowork): approve. CI run #157 green; isolated diff. ConfigStat(mtime,size) + config_fingerprint correctly catches the same-mtime-different-size case, and the regression test forces the original mtime via os.utime to prove it. Minor: this PR also rewrites the _normalize_unicode NBSP line (behavior-identical) — it'll conflict with #23/#24/#25 which did the same; resolve by taking that line from any one branch at merge. Ready for v1.2.0.

Supervisor review (Cowork): **approve.** CI run #157 green; isolated diff. `ConfigStat`(mtime,size) + `config_fingerprint` correctly catches the same-mtime-different-size case, and the regression test forces the original mtime via `os.utime` to prove it. Minor: this PR also rewrites the `_normalize_unicode` NBSP line (behavior-identical) — it'll conflict with #23/#24/#25 which did the same; resolve by taking that line from any one branch at merge. Ready for v1.2.0.
Author
Owner

Superseded by #26 (release: v1.2.0), which merged this change into main as part of the integrated release. Closing.

Superseded by #26 (release: v1.2.0), which merged this change into `main` as part of the integrated release. Closing.
the_og closed this pull request 2026-07-08 11:35:40 -04:00
Some checks are pending
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 11s
CI / Tests (py3.12) (pull_request) Successful in 7s

Pull request closed

Sign in to join this conversation.