feat: harden stale-file detection with mtime+size fingerprint (#17)
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).
This commit is contained in:
+26
-1
@@ -29,6 +29,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
CONFIG_FILENAME = "claude_desktop_config.json"
|
CONFIG_FILENAME = "claude_desktop_config.json"
|
||||||
@@ -380,6 +381,30 @@ def config_mtime(path: Path | str) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigStat(NamedTuple):
|
||||||
|
"""A snapshot of a config file's mtime + size.
|
||||||
|
|
||||||
|
Pairing size with mtime hardens stale-file detection beyond bare mtime
|
||||||
|
equality: a concurrent external write can land within the filesystem's
|
||||||
|
mtime resolution (e.g. same-second writes on ext4/HFS+) or have its mtime
|
||||||
|
restored by the writing process, in which case mtime alone would miss the
|
||||||
|
change. Comparing both fields catches those cases without the cost of a
|
||||||
|
full content hash.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mtime: float
|
||||||
|
size: int
|
||||||
|
|
||||||
|
|
||||||
|
def config_fingerprint(path: Path | str) -> ConfigStat | None:
|
||||||
|
"""Return the file's (mtime, size) snapshot, or None if it does not exist."""
|
||||||
|
try:
|
||||||
|
st = Path(path).stat()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return ConfigStat(st.st_mtime, st.st_size)
|
||||||
|
|
||||||
|
|
||||||
def external_change_summary(original_cfg: dict, path: Path | str) -> tuple[list[str], str]:
|
def external_change_summary(original_cfg: dict, path: Path | str) -> tuple[list[str], str]:
|
||||||
"""
|
"""
|
||||||
Compare original_cfg (what BCC loaded) with the current on-disk state.
|
Compare original_cfg (what BCC loaded) with the current on-disk state.
|
||||||
@@ -474,7 +499,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str:
|
|||||||
out = text
|
out = text
|
||||||
for junk in _JUNK_CHARS:
|
for junk in _JUNK_CHARS:
|
||||||
out = out.replace(junk, "")
|
out = out.replace(junk, "")
|
||||||
out = out.replace(" ", " ") # non-breaking space
|
out = out.replace(" ", " ") # non-breaking space
|
||||||
for smart, ascii_q in _QUOTE_MAP.items():
|
for smart, ascii_q in _QUOTE_MAP.items():
|
||||||
out = out.replace(smart, ascii_q)
|
out = out.replace(smart, ascii_q)
|
||||||
if out != text:
|
if out != text:
|
||||||
|
|||||||
Reference in New Issue
Block a user