2b22a23d60
Campaign 10's R-156 found papra writing its database into the container's writable layer while the volume the template preserves stayed empty — a backup that completes, verifies, and contains nothing. papra was never the point: nothing anywhere checked that the folder a template preserves is the folder the app writes to. All 53 templates have now been measured live. 43 CLEAN / 3 BROKEN / 7 UNDETERMINED. UNDETERMINED is counted separately, each with its reason, and never folded into CLEAN. FIXED (neither app is deployed anywhere, so nothing was stranded): - gramps-web mounted /app/data, /app/media, /tmp — and /app/data is a path the application never writes. Its accounts database and ITS FAMILY TREE both landed in the writable layer while gramps_data was tarred nightly as an empty directory. Now persists the eight paths the image's own environment names, matching upstream's reference compose. Proven: users.sqlite and the family-tree files survive a redeploy byte-identical, same inode. - wishlist mounted wishlist_data:/data, another path the app never writes; prod.db landed in the ANONYMOUS volume from the image's VOLUME directive — absent from ResolveDockerVolumeNames, so never backed up, and orphaned by a redeploy. Now mounts /usr/src/app/data + /usr/src/app/uploads. Proven: prod.db byte-identical, same inode, across a redeploy. Every corrected path confirmed by two independent sources — the shipped image's own environment/Config.Volumes and upstream's reference compose — never inferred from a directory name. papra is NOT fixed. It is live on one box, and changing the mount target makes the next compose up recreate the container and destroy the writable layer its documents live in. The fix is prepared and proven in the scratch guest (current: db.sqlite differs after a redeploy, so a real account created via the API is lost; fixed: byte-identical, it survives). Referred to the operator with the two options; no migration written. NEW GATE scripts/check-volume-persistence.py — the third catalog gate and the only RUNTIME one. This class is invisible to static analysis, measured not assumed: a static audit of all 53 composes reports the catalog clean AND reports papra clean. Exit 0 clean / 1 REFUSED / 2 undecided. It refuses to report at all unless it has just re-proven itself in both directions against two canary templates that differ only in which path the volume mounts at, so every run carries a live demonstration of R-156 and of its fix. No docker exec anywhere (Campaign 7 §1.1). 44 fixture tests driving check(), the function __main__ calls; every rule red-proofed. Enforcement is convention, not CI — this repo has no CI. Stated plainly in the report; raising it is proposed as R-160. Report, per-app evidence, proofs and proposed register entries (R-158..R-161, NOT filed — felhom.eu is fenced this session): audits/persistence-sweep-2026-08-02/
166 lines
7.9 KiB
Python
166 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
|
"""R-156 fix proof for papra — the BEFORE/AFTER pair, on real user data.
|
|
|
|
Not "the volume exists afterwards" — that is exactly the confusion R-156 is made of. The claim
|
|
under test is: a document a real user uploaded is still there after `docker compose down` + `up -d`.
|
|
|
|
Leg A CURRENT template (`papra_data:/app/data`) — expected: the data is DESTROYED
|
|
Leg B FIXED template (`papra_data:/app/app-data`) — expected: the data SURVIVES, byte-identical
|
|
|
|
The account and the document are created through papra's own HTTP API, never by writing into a
|
|
volume by hand: R-156's own evidence shows a root-written canary making an empty volume read as
|
|
populated. Data is read back with `docker cp`, which works identically whether it sits in a volume
|
|
or in the writable layer, so both legs are measured the same way.
|
|
|
|
No password or session token is ever printed or written to the evidence file.
|
|
"""
|
|
import hashlib, importlib.util, json, os, re, secrets, shutil, sys, tempfile, time
|
|
from pathlib import Path
|
|
|
|
_spec = importlib.util.spec_from_file_location("cvp", "/opt/sweep/scripts/check-volume-persistence.py")
|
|
cvp = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(cvp)
|
|
|
|
TPL = Path("/opt/sweep/templates/papra")
|
|
EV = Path("/opt/sweep/evidence/_papra-fix-proof")
|
|
DOC = b"Felhom R-156 fix proof - ennek a dokumentumnak tul kell elnie az ujratelepitest.\n"
|
|
DOC_SHA = hashlib.sha256(DOC).hexdigest()
|
|
|
|
|
|
def curl(url, *args):
|
|
return cvp._sh(["curl", "-sS", "--max-time", "25", *args, url], timeout=45)
|
|
|
|
|
|
def wait_healthy(base, tries=60):
|
|
for _ in range(tries):
|
|
cid = (cvp._sh(base + ["ps", "-q"], timeout=60).stdout or "").split()
|
|
if cid:
|
|
st = (cvp._inspect(cid[0]) or {}).get("State") or {}
|
|
if (st.get("Health") or {}).get("Status") == "healthy":
|
|
return cid[0]
|
|
time.sleep(5)
|
|
return (cvp._sh(base + ["ps", "-q"], timeout=60).stdout or "").split()[0]
|
|
|
|
|
|
def seed(ip, jar, log):
|
|
"""Create an account and upload a document through papra's own API."""
|
|
email = f"proof-{secrets.token_hex(4)}@felhom.invalid"
|
|
pw = "Proof-" + secrets.token_hex(10) # never recorded
|
|
r = curl(f"http://{ip}:1221/api/auth/sign-up/email", "-X", "POST",
|
|
"-H", "Content-Type: application/json", "-c", jar, "-i",
|
|
"-d", json.dumps({"email": email, "password": pw, "name": "Proof"}))
|
|
head = (r.stdout or "").splitlines()[0].strip() if r.stdout else "?"
|
|
log.append(f"sign-up -> {head}")
|
|
orgs = curl(f"http://{ip}:1221/api/organizations", "-b", jar).stdout or ""
|
|
m = re.search(r'"id"\s*:\s*"([^"]+)"', orgs)
|
|
if not m:
|
|
log.append(f"organizations -> no id in {orgs[:160]}")
|
|
return {"account": head, "document_uploaded": False}
|
|
p = f"/tmp/proofdoc-{secrets.token_hex(3)}.txt"
|
|
open(p, "wb").write(DOC)
|
|
u = curl(f"http://{ip}:1221/api/organizations/{m.group(1)}/documents", "-X", "POST",
|
|
"-b", jar, "-F", f"file=@{p}", "-i")
|
|
os.remove(p)
|
|
ok = bool(re.search(r"HTTP/1\.[01] 2\d\d", u.stdout or ""))
|
|
log.append(f"upload -> {(u.stdout or '?').splitlines()[0].strip()}")
|
|
return {"account": head, "document_uploaded": ok}
|
|
|
|
|
|
def snapshot(cid, log, tag):
|
|
"""Read the app's data OUT of the container, wherever it lives."""
|
|
d = tempfile.mkdtemp(prefix="snap-")
|
|
out = {}
|
|
try:
|
|
for path in ("/app/app-data/db/db.sqlite", "/app/data"):
|
|
dst = os.path.join(d, path.strip("/").replace("/", "_"))
|
|
if cvp._sh(["docker", "cp", f"{cid}:{path}", dst], timeout=300).returncode != 0:
|
|
out[path] = {"present": False}
|
|
continue
|
|
if os.path.isfile(dst):
|
|
b = open(dst, "rb").read()
|
|
out[path] = {"present": True, "bytes": len(b),
|
|
"sha256": hashlib.sha256(b).hexdigest()}
|
|
else:
|
|
out[path] = {"present": True, "dir": True,
|
|
"files": sorted(os.path.relpath(os.path.join(r, f), dst)
|
|
for r, _, fs in os.walk(dst) for f in fs)}
|
|
docs = []
|
|
if cvp._sh(["docker", "cp", f"{cid}:/app/app-data/documents",
|
|
os.path.join(d, "documents")], timeout=300).returncode == 0:
|
|
for r, _, fs in os.walk(os.path.join(d, "documents")):
|
|
for f in fs:
|
|
b = open(os.path.join(r, f), "rb").read()
|
|
docs.append({"size": len(b), "sha256": hashlib.sha256(b).hexdigest(),
|
|
"is_the_proof_document": hashlib.sha256(b).hexdigest() == DOC_SHA})
|
|
out["documents"] = docs
|
|
finally:
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
log.append(f"[{tag}] " + json.dumps(out)[:260])
|
|
return out
|
|
|
|
|
|
def leg(tag, compose_body):
|
|
log = []
|
|
work = Path(tempfile.mkdtemp(prefix=f"papra-{tag}-"))
|
|
(work / "docker-compose.yml").write_text(compose_body)
|
|
env = cvp.build_env("papra", (TPL / ".felhom.yml").read_text(), compose_body)
|
|
(work / ".env").write_text("".join(f"{k}={v}\n" for k, v in sorted(env.items())))
|
|
project = "pp" + tag
|
|
base = ["docker", "compose", "-p", project, "--project-directory", str(work),
|
|
"-f", str(work / "docker-compose.yml")]
|
|
cvp._sh(["docker", "network", "create", "traefik-public"], timeout=60)
|
|
jar = f"/tmp/pj-{tag}"
|
|
try:
|
|
cvp._sh(base + ["up", "-d"], timeout=1200)
|
|
cid = wait_healthy(base)
|
|
ip = [n["IPAddress"] for n in
|
|
((cvp._inspect(cid) or {}).get("NetworkSettings") or {}).get("Networks", {}).values()
|
|
if n.get("IPAddress")][0]
|
|
time.sleep(15)
|
|
seeded = seed(ip, jar, log)
|
|
time.sleep(20)
|
|
before = snapshot(cid, log, "before")
|
|
|
|
cvp._sh(base + ["down"], timeout=900) # a REDEPLOY: no -v
|
|
cvp._sh(base + ["up", "-d"], timeout=1200)
|
|
cid2 = wait_healthy(base)
|
|
time.sleep(25)
|
|
after = snapshot(cid2, log, "after")
|
|
|
|
db = "/app/app-data/db/db.sqlite"
|
|
db_same = bool(before[db].get("sha256")
|
|
and before[db].get("sha256") == after[db].get("sha256"))
|
|
doc_b = any(x["is_the_proof_document"] for x in before["documents"])
|
|
doc_a = any(x["is_the_proof_document"] for x in after["documents"])
|
|
return {"leg": tag, "seeded": seeded, "before": before, "after": after,
|
|
"db_identical_after_redeploy": db_same,
|
|
"uploaded_document_present_before": doc_b,
|
|
"uploaded_document_present_after": doc_a,
|
|
"verdict": ("USER DATA SURVIVES" if (db_same and doc_b and doc_a)
|
|
else "USER DATA LOST" if doc_b and not doc_a
|
|
else "INCONCLUSIVE - the document was never uploaded"
|
|
if not doc_b else "DATA LOST"),
|
|
"log": log}
|
|
finally:
|
|
cvp._sh(base + ["down", "-v", "--remove-orphans"], timeout=900)
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
if os.path.exists(jar):
|
|
os.remove(jar)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
EV.mkdir(parents=True, exist_ok=True)
|
|
cur = (TPL / "docker-compose.yml").read_text()
|
|
fixed = cur.replace("- papra_data:/app/data", "- papra_data:/app/app-data")
|
|
assert fixed != cur, "the mount line did not match — refusing to 'prove' an unchanged template"
|
|
(EV / "template.diff").write_text(
|
|
"--- current\n+++ fixed\n- - papra_data:/app/data\n+ - papra_data:/app/app-data\n")
|
|
out = {}
|
|
for tag, body in (("current", cur), ("fixed", fixed)):
|
|
out[tag] = leg(tag, body)
|
|
r = out[tag]
|
|
print(f"{tag:<8} {r['verdict']:<34} db_identical={r['db_identical_after_redeploy']} "
|
|
f"doc_before={r['uploaded_document_present_before']} "
|
|
f"doc_after={r['uploaded_document_present_after']}", flush=True)
|
|
(EV / "papra-fix-proof.json").write_text(json.dumps(out, indent=2, sort_keys=True))
|