From 8c444ad9918f05fe5c07243e31348728afd82a5a Mon Sep 17 00:00:00 2001 From: victortaelin Date: Sun, 26 Jul 2026 17:01:41 -0300 Subject: [PATCH] audit: no dead end survives a corrupt record, no lie survives a filesystem error - a blank summary record bricked wake forever: it refused with 'Do the 0 compressions below' + a literal None, while nap said nothing was left. Both sites (wake's cover, nap's half) now name the one exit: forget. - tree_get/count/repair swallowed every OSError as 'not built yet', so an unreadable level was reported as pending work that could not be done. Only FileNotFoundError means absent; real failures surface with the path. - import accepted 2026-99-99, poisoning the store's date order forever. - the wake T error now says 'the log holds N memories', not 'entries'. --- memo | 50 +++++++++++++++++++++++++++++++++----------------- test.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/memo b/memo index 59b1173..2a80e1b 100755 --- a/memo +++ b/memo @@ -205,7 +205,7 @@ def tree_path(d, size): def count(path, rec): try: return os.path.getsize(path) // rec - except OSError: + except FileNotFoundError: # any other failure is real and must surface return 0 @@ -219,7 +219,7 @@ def repair(path, rec): every later record is misaligned. Callers hold the lock.""" try: n = os.path.getsize(path) - except OSError: + except FileNotFoundError: return if n % rec: with open(path, "r+b") as f: @@ -271,7 +271,7 @@ def tree_get(d, lo, hi): with open(tree_path(d, size), "rb") as f: f.seek((lo // size) * TREE_REC) rec = f.read(TREE_REC) - except OSError: + except FileNotFoundError: # not built yet; any other failure is real return None return rec.decode().rstrip() or None @@ -425,7 +425,11 @@ def nap_prompt(d, lo, hi, left): for a, b in ((lo, mid), (mid, hi)): s = tree_get(d, a, b) if s is None: - die("Summary %d-%d is missing. Run: %s nap" % (a, b - 1, ME)) + # pending() lists a block only after its halves settled, so + # a missing half is a blank record -- a corrupt write. Drop + # it and the next nap rebuilds it. + die("The summary of #%d-%d is blank. Run: %s forget %d-%d" + % (a, b - 1, ME, a, b - 1)) halves.append(" #%d-%d %s" % (a, b - 1, s)) body = "\n".join(halves) tail = "" if not left else "\n%s after this one." % ( @@ -537,8 +541,8 @@ def cmd_wake(d, args): if len(args) == 2: T = int(args[1]) if T > now: - die("T=%d, but the memory holds %s. Run: %s wake" - % (T, plural(now, "entry"), ME)) + die("T=%d, but the log holds %s. Run: %s wake" + % (T, plural(now, "memory"), ME)) # A part is rendered as of T, so a note landing between two parts cannot # shift a boundary and drop a line. if not T: @@ -553,17 +557,25 @@ def cmd_wake(d, args): else: s = tree_get(d, lo, hi) if s is None: - # The ONLY reason to refuse: this document cannot be written - # without that summary. Work that the document does not need - # is handed over after the read instead, costing no round - # trip. - print("Cannot wake: the memory context needs #%d-%d, which is " - "not compressed yet.\nDo the %s below, then run %s " - "wake again.\n" - % (lo, hi - 1, - plural(pending_count(d, T), "compression"), ME)) - print(next_nap(d, T)) - sys.exit(1) + nap = next_nap(d, T) + if nap: + # The ONLY reason to refuse: this document cannot be + # written without that summary. Work that the document + # does not need is handed over after the read instead, + # costing no round trip. + print("Cannot wake: the memory context needs #%d-%d, " + "which is not compressed yet.\nDo the %s below, " + "then run %s wake again.\n" + % (lo, hi - 1, + plural(pending_count(d, T), "compression"), ME)) + print(nap) + sys.exit(1) + s = tree_get(d, lo, hi) # a parallel session may have paid it + if s is None: + # Nothing is pending, so the record exists but is blank -- a + # corrupt write. Drop it and the next nap rebuilds it. + die("The summary of #%d-%d is blank. Run: %s forget %d-%d" + % (lo, hi - 1, ME, lo, hi - 1)) lines.append("#%d-%d %s" % (lo, hi - 1, s)) parts = paginate(lines) if not 1 <= k <= len(parts): @@ -712,6 +724,10 @@ def cmd_import(d, args): date, _, text = line.partition(" ") if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date): die("line %d: expected 'YYYY-MM-DD ', got: %s" % (i, line)) + try: + datetime.datetime.strptime(date, "%Y-%m-%d") + except ValueError: + die("line %d: %s is not a real date." % (i, date)) if date < last: die("line %d: date %s precedes the previous memory (%s)." % (i, date, last)) diff --git a/test.py b/test.py index e181e7a..cd7f35f 100755 --- a/test.py +++ b/test.py @@ -431,6 +431,57 @@ r = run("wake", store=d2) check(r.stdout.rstrip().endswith("You are awake."), "a one-part wake never says `You are awake.`:\n" + r.stdout) +# a blank summary record (a corrupt write) is work nap cannot see: wake must +# name the one exit, `forget`, instead of refusing forever +d3 = tempfile.mkdtemp(prefix="optmem-blank-") +for i in range(4): + run("note", "corrupt store memory %d" % i, store=d3) +for bid, s in (("0-1", "one"), ("2-3", "two"), ("0-3", "all")): + run("nap", bid, s, store=d3) +with open(os.path.join(d3, "config"), "w") as f: + f.write("WAKE_LINES = 2\n") +with open(os.path.join(d3, "TREE", "2"), "r+b") as f: + f.write(b" " * 287 + b"\n") +r = run("wake", store=d3) +check(r.returncode == 1 and "forget 0-1" in r.stderr + and "None" not in r.stdout, + "a blank summary must point at forget:\n" + r.stdout + r.stderr) + +# an unreadable level is a filesystem failure and must surface as one -- +# reading it as "not compressed yet" offers work that cannot be done +os.chmod(os.path.join(d3, "TREE", "2"), 0) +r_ = subprocess.run(memo + ["wake"], capture_output=True, text=True, + env=dict(os.environ, MEMORY_DIR=d3)) +check(r_.returncode == 1 and "Permission denied" in r_.stderr + and "not compressed" not in r_.stdout, + "an unreadable level was read as pending work: " + r_.stdout + r_.stderr) +os.chmod(os.path.join(d3, "TREE", "2"), 0o644) + +# an impossible calendar date would poison every later import: the store's +# order check compares against it forever +with open(os.path.join(d3, "bad.txt"), "w") as f: + f.write("2027-99-99 an impossible date\n") +r = run("import", os.path.join(d3, "bad.txt"), store=d3) +check(r.returncode == 1 and "not a real date" in r.stderr, + "import accepted an impossible date: " + r.stdout + r.stderr) +shutil.rmtree(d3) + +# the same blank-record dead end at the other site: a big block's half +d4 = tempfile.mkdtemp(prefix="optmem-half-") +for i in range(32): + run("note", "half probe memory %d" % i, store=d4) +while True: + r = run("nap", store=d4) + if "Compress memories #0-31 " in r.stdout: + break + run("nap", nap_id(r.stdout), "settled", store=d4) +with open(os.path.join(d4, "TREE", "16"), "r+b") as f: + f.write(b" " * 287 + b"\n") +r = run("nap", store=d4) +check(r.returncode == 1 and "forget 0-15" in r.stderr, + "a blank half summary must point at forget: " + r.stdout + r.stderr) +shutil.rmtree(d4) + # re-running init on a lived-in store must not touch one byte of it: the # whole setup is idempotent, so it is safe on every provision. `init` only # ever makedirs(exist_ok), opens LOG.txt for APPEND, and writes `config`