#!/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))