audit fixes: UTF-8 record corruption, byte-vs-char limits
- log_slice decoded the whole buffer then sliced at 320-CHARACTER offsets; one multi-byte char (memory #42: 'Gestão') shifted every later record and crashed recall on the live store. Records are bytes: slice, then decode. - limits counted characters while records are bytes, so 150 'ã's passed check() and died inside pad() mid-append. check/import/paginate now count UTF-8 bytes; config rejects an ENTRY_CHARS the records cannot fit. - tests were ASCII-only (why 239k assertions missed it): UTF-8 cases added.
This commit is contained in:
parent
e123c89317
commit
363ff72d8c
2 changed files with 40 additions and 11 deletions
28
memo
28
memo
|
|
@ -76,6 +76,9 @@ def config(d):
|
||||||
PART_CHARS = int(v)
|
PART_CHARS = int(v)
|
||||||
elif k == "PART_LINES":
|
elif k == "PART_LINES":
|
||||||
PART_LINES = int(v)
|
PART_LINES = int(v)
|
||||||
|
if ENTRY_CHARS > min(TREE_REC - 8, LOG_REC - 40):
|
||||||
|
die("config: ENTRY_CHARS=%d cannot fit the %d/%d-byte records."
|
||||||
|
% (ENTRY_CHARS, LOG_REC, TREE_REC))
|
||||||
|
|
||||||
|
|
||||||
def log_path(d):
|
def log_path(d):
|
||||||
|
|
@ -108,13 +111,15 @@ def log_get(d, i):
|
||||||
|
|
||||||
|
|
||||||
def log_slice(d, lo, hi):
|
def log_slice(d, lo, hi):
|
||||||
"""Memories [lo,hi) in one read."""
|
"""Memories [lo,hi) in one read. Records are sliced as BYTES and decoded
|
||||||
|
one by one -- slicing decoded text would shift every boundary after the
|
||||||
|
first multi-byte character."""
|
||||||
with open(log_path(d), "rb") as f:
|
with open(log_path(d), "rb") as f:
|
||||||
f.seek(lo * LOG_REC)
|
f.seek(lo * LOG_REC)
|
||||||
buf = f.read((hi - lo) * LOG_REC).decode()
|
buf = f.read((hi - lo) * LOG_REC)
|
||||||
out = []
|
out = []
|
||||||
for i in range(hi - lo):
|
for i in range(hi - lo):
|
||||||
line = buf[i * LOG_REC:(i + 1) * LOG_REC].rstrip()
|
line = buf[i * LOG_REC:(i + 1) * LOG_REC].decode().rstrip()
|
||||||
head, _, rest = line.partition(" ")
|
head, _, rest = line.partition(" ")
|
||||||
date, _, text = rest.partition(" ")
|
date, _, text = rest.partition(" ")
|
||||||
out.append((int(head[1:]), date, text))
|
out.append((int(head[1:]), date, text))
|
||||||
|
|
@ -206,9 +211,11 @@ def check(text):
|
||||||
if "\n" in text or "\r" in text:
|
if "\n" in text or "\r" in text:
|
||||||
die("REJECTED: %d lines. A memory is exactly one line." %
|
die("REJECTED: %d lines. A memory is exactly one line." %
|
||||||
(text.count("\n") + 1))
|
(text.count("\n") + 1))
|
||||||
if len(text) > ENTRY_CHARS:
|
n = len(text.encode())
|
||||||
die("REJECTED: %d chars, %d over the %d limit. Compress it further."
|
if n > ENTRY_CHARS:
|
||||||
% (len(text), len(text) - ENTRY_CHARS, ENTRY_CHARS))
|
die("REJECTED: %d bytes, %d over the %d limit (accents and symbols "
|
||||||
|
"cost more than one). Compress it further."
|
||||||
|
% (n, n - ENTRY_CHARS, ENTRY_CHARS))
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -270,11 +277,12 @@ def paginate(lines):
|
||||||
"""Split the document into parts that survive any harness's output cap."""
|
"""Split the document into parts that survive any harness's output cap."""
|
||||||
parts, cur, size = [], [], 0
|
parts, cur, size = [], [], 0
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if cur and (len(cur) >= PART_LINES or size + len(line) > PART_CHARS):
|
n = len(line.encode())
|
||||||
|
if cur and (len(cur) >= PART_LINES or size + n > PART_CHARS):
|
||||||
parts.append(cur)
|
parts.append(cur)
|
||||||
cur, size = [], 0
|
cur, size = [], 0
|
||||||
cur.append(line)
|
cur.append(line)
|
||||||
size += len(line) + 1
|
size += n + 1
|
||||||
if cur:
|
if cur:
|
||||||
parts.append(cur)
|
parts.append(cur)
|
||||||
return parts
|
return parts
|
||||||
|
|
@ -417,8 +425,8 @@ def cmd_import(d, args):
|
||||||
die("line %d: date %s is older than the previous memory (%s). "
|
die("line %d: date %s is older than the previous memory (%s). "
|
||||||
"Memories must be in order." % (i, date, last))
|
"Memories must be in order." % (i, date, last))
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
if not text or len(text) > ENTRY_CHARS:
|
if not text or len(text.encode()) > ENTRY_CHARS:
|
||||||
die("line %d: %d chars (limit %d)." % (i, len(text), ENTRY_CHARS))
|
die("line %d: %d bytes (limit %d)." % (i, len(text.encode()), ENTRY_CHARS))
|
||||||
out.append("#%d %s %s" % (T + len(out), date, text))
|
out.append("#%d %s %s" % (T + len(out), date, text))
|
||||||
last = date
|
last = date
|
||||||
log_append(d, out)
|
log_append(d, out)
|
||||||
|
|
|
||||||
23
test.py
23
test.py
|
|
@ -155,7 +155,6 @@ check("REJECTED" in run("sleep", "0-2", "attempted overwrite").stderr,
|
||||||
r = run("recall", "memory number 7,")
|
r = run("recall", "memory number 7,")
|
||||||
check(r.returncode == 0 and "#7 " in r.stdout, "recall missed a memory")
|
check(r.returncode == 0 and "#7 " in r.stdout, "recall missed a memory")
|
||||||
|
|
||||||
# a wrong summary can be dropped, with everything built on top of it
|
|
||||||
def treesize():
|
def treesize():
|
||||||
t = os.path.join(d, "TREE")
|
t = os.path.join(d, "TREE")
|
||||||
return sum(os.path.getsize(os.path.join(t, f)) for f in os.listdir(t))
|
return sum(os.path.getsize(os.path.join(t, f)) for f in os.listdir(t))
|
||||||
|
|
@ -180,6 +179,28 @@ check(treesize() == before, "tree did not return to its original size")
|
||||||
check(run("forget", "17-33").returncode == 1, "forgetting a non-block should fail")
|
check(run("forget", "17-33").returncode == 1, "forgetting a non-block should fail")
|
||||||
check(run("forget", "999998-1000000").returncode == 1, "forgetting a missing block should fail")
|
check(run("forget", "999998-1000000").returncode == 1, "forgetting a missing block should fail")
|
||||||
|
|
||||||
|
# UTF-8: multi-byte characters must not shift record boundaries or dodge limits
|
||||||
|
run("note", "reunião com João em São Paulo: ação aprovada, coração tranquilo")
|
||||||
|
run("note", "a plain ascii memory right after the accented one")
|
||||||
|
r = run("recall", "coração")
|
||||||
|
check("João" in r.stdout, "recall lost the accented memory: " + r.stdout + r.stderr)
|
||||||
|
r = run("recall", "plain ascii memory right after")
|
||||||
|
check("#%d " % (N + 2) in r.stdout, "record after a multi-byte one reads shifted")
|
||||||
|
r = run("note", "ã" * 150)
|
||||||
|
check(r.returncode == 1 and "300 bytes" in r.stderr,
|
||||||
|
"multi-byte note dodged the byte limit: " + r.stderr)
|
||||||
|
|
||||||
|
# a wrong summary can be dropped, with everything built on top of it
|
||||||
|
|
||||||
|
# note landed -> its blocks are pending; settle before the final wake check
|
||||||
|
while True:
|
||||||
|
r = run("sleep")
|
||||||
|
if "You woke up" in r.stdout:
|
||||||
|
break
|
||||||
|
bid = [l for l in r.stdout.splitlines() if l.strip().startswith("memo sleep ")][0].split()[2]
|
||||||
|
run("sleep", bid, "settled")
|
||||||
|
check(run("wake").returncode == 0, "wake refuses at the very end")
|
||||||
|
|
||||||
shutil.rmtree(d)
|
shutil.rmtree(d)
|
||||||
print("\n%d passed, %d failed" % (ok, fail))
|
print("\n%d passed, %d failed" % (ok, fail))
|
||||||
sys.exit(1 if fail else 0)
|
sys.exit(1 if fail else 0)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue