diff --git a/CHANGELOG.md b/CHANGELOG.md index 85eb37f1..dc1d1eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,14 @@ This project uses Semantic Versioning as **interpreted for MemNet**: package `a. ## [Unreleased] +### Added +- **Session ACL in snapshots (MN-REQ-06.16)** — session save, expire save, checkpoint, and safe-upgrade snapshots carry the CapsPolicy ACL: enabled flag, per-caller grants (pin_map, mutate, WorkerWriteScope), and optional bind. The ACL is one `#@ACL:` comment line after `@SNAP`. `session load` restores it, both from `--file` and on the `--keep-id` reload path. `@SNAP` is now **version 2**, and version 2 **requires** the `#@ACL:` line: a version 2 snapshot without it fails `bad_snapshot`. Version 1 (written before the ACL was persisted) still loads, with ACL off, and prints `@ACL: absent|off`. A loaded ACL prints `@ACL: loaded|enabled=<0|1>|callers=|bind=<0|1>`. A malformed ACL line fails the load instead of silently turning ACL off. The snapshot and the upgrade passport share one serialiser (`memnet.acl.acl_to_dict` / `acl_from_dict`). Readers built before this change reject `@SNAP` version 2 with `bad_snapshot` (unsupported snapshot version 2) rather than skipping the ACL line, and an upgrade manifest with `snapshot_format` 2 is refused by an older serve (`upgrade_snapshot_format`, exit 3). Verify: MN-VER-06-S14 (`tests/test_acl_snapshot.py`). + +- **One-time v1 backup before the first v2 write (MN-REQ-06.16)** — the first snapshot write over a format 1 file (session save, expire save, checkpoint, safe-upgrade) copies the v1 bytes to `.v1.bak` in the same directory, at mode `0600`, through `memnet/atomic_file.py`. An existing backup is never overwritten, and v2-over-v2 writes make no backup. There is no dual write: the snapshot itself is always v2. **Rollback to 0.19.23** means renaming the `.v1.bak` files over their snapshots (see [`docs/operations/safe-upgrade.md`](docs/operations/safe-upgrade.md)). That loses writes made after the upgrade. Old builds refuse v2 loudly (`bad_snapshot`, `upgrade_snapshot_format`) by design. ### Changed +- **Snapshot and manifest files are private and atomic (MN-REQ-06.16)** — every snapshot (session save, expire save, checkpoint, safe-upgrade), `upgrade-manifest.json`, `upgrade-blocked.json`, `upgrade-restore.json`, and `checkpoint-manifest.json` is written through one helper (`memnet/atomic_file.py`): a temp file created `0600` (`O_EXCL`), fsynced, then renamed. Files are `0600` even when they replace an older file with a looser mode. The upgrade passport is written in the same snapshot write, not appended afterwards. +- **Bind stores a lease digest, not the lease (MN-REQ-06.16)** — `session acl-bind` keeps the SHA-256 of the lease (`memnet.acl.lease_digest`). `check_bind` hashes the presented `--lease` (or `MEMNET_LEASE`) and compares digests in constant time. The snapshot, the passport, and the in-memory session never hold the raw lease. Consequence: a lost lease cannot be read back from a snapshot; re-run `session acl-bind` with the lease. The lease must be a high-entropy secret (SHA-256 is not a password KDF). A pre-digest snapshot with a raw `lease` loads and is hashed on load. +- **Upgrade restore keeps the ACL enabled flag as written** — a passport ACL with `enabled: false` but grants no longer comes back enabled. - **Invent only — ClusterRoute vs SliceHandCarry (#191 / #47 cousin)** — `MemNetTwoMoves` outside `MemNetSystem` (`MN-REQ-06.9` + `MN-REQ-06.10` / `MN-VER-06-S08`). ClusterRoute = where the session lives (`MemNetLanMcpFront`; one owner; `pin_map` / `find` SHALL NOT span backends). SliceHandCarry = explicit copy into another session (`export_pin_map` or `session_save` → LAN file copy → dest import/`session_load`; `import_slice` same-serve only). Not a live hop. `import_slice(from_url)` not shipped. tip≠face. `inventOnly=true`; `implemented=false`; no engine code; no SemVer bump. Wire: [`docs/operations/cluster-route-vs-slice-hand-carry.md`](docs/operations/cluster-route-vs-slice-hand-carry.md). - **Invent only — LAN MCP front over several serves (#191)** — `MemNetLanMcpFront` outside `MemNetSystem` (`MN-REQ-06.9` / `MN-VER-06-S07`). One MCP catalogue, N LAN `memnet serve` backends; `SessionOwnerRegistry` is owner (explicit pin allowed; silent hash is not sole routing). One owner per session; `pin_map` / `find` SHALL NOT span backends. Cousin of #47 (peer sid handoff), not the same invent. tip≠face. `inventOnly=true`; `implemented=false`; no engine code; no SemVer bump. Wire: [`docs/operations/memnet-lan-mcp-front.md`](docs/operations/memnet-lan-mcp-front.md). diff --git a/docs/operations/one-session-per-document.md b/docs/operations/one-session-per-document.md index 4c2cfd2c..b1f6f365 100644 --- a/docs/operations/one-session-per-document.md +++ b/docs/operations/one-session-per-document.md @@ -47,7 +47,7 @@ Client helper: `memnet.serve.send_command(args, stdin=…, host=…, port=…)`. ## Snapshot -`session save --file` writes `# memnet-snapshot-v1` via `Path.write_text` (overwrite). MemNet does **not** make that file write-once (no `O_EXCL`, no `chmod`, no immutable flag). The caller or the filesystem can. +`session save --file` writes `# memnet-snapshot-v1` (`@SNAP` version 2) through a temp file created `0600`, fsynced, then renamed over the target (overwrite, atomic). MemNet does **not** make that file write-once (no `O_EXCL` on the target, no immutable flag). The caller or the filesystem can. Anything mutate accepts must save and reload as the same property values. Snapshot emit escapes every Python `str.splitlines()` separator (LF, CR, VT, FF, FS/GS/RS, NEL, LS, PS) plus `\` and `|`; load splits records on LF only. A 16 KiB string is refused at GQL CREATE/SET with `@ERR: limit_exceeded|value_bytes 16384/4096` (one decoded cap with leftover pipe and snapshot load). Unicode, `|`, quotes, CR, and newlines on a value under the cap round-trip. Undeclared RAM keys persist by widening the snapshot SCHEMA; extras over `max_fields` refuse `snapshot_unsaveable`. diff --git a/docs/operations/safe-upgrade.md b/docs/operations/safe-upgrade.md index ef5a59d7..ecc73081 100644 --- a/docs/operations/safe-upgrade.md +++ b/docs/operations/safe-upgrade.md @@ -35,6 +35,23 @@ A clean stat retires the manifest inside that process. A later restart does not Point the unit `ExecStart` back at the old venv and start it when the restore stat is not clean. After a clean stat the manifest is retired, so a later start does not reload those snapshots. +From the build after 0.19.23 the first write of each snapshot in format 2 keeps the old bytes once. A format 1 file that is overwritten by a format 2 write is copied to `.v1.bak` (mode `0600`, same directory) before the replace. A later save never overwrites that backup. + +Rolling back to 0.19.23 (which reads format 1 only) means restoring those backups: + +1. Stop the new serve. It must not write during the rollback. +2. For each snapshot that has a `.v1.bak`, rename the backup over the snapshot, for example `mv -f upgrade-snapshots/.snap.v1.bak upgrade-snapshots/.snap`. Do the same under `checkpoint/` and for any expire or session save file. +3. Start the old venv. + +**This loses every write made after the upgrade.** The session state returns to the bytes saved before the upgrade, and nothing else is merged back. + +Two things an old build does loudly by design: + +- A format 2 snapshot with no `.v1.bak` (a file first written by the new build) is refused with `bad_snapshot` (`unsupported snapshot version 2`). It is not loaded as an empty or partial session. +- A format 2 upgrade manifest is refused by the old serve (`upgrade_snapshot_format`, exit `3`). + +Keep the `.v1.bak` files until the new build has run cleanly. They are not removed by the new build. + ## Hosts On rpi5-syson the serve listens on `18765` (`memnet-serve.service`). `memnet-mcp` on `18766` is not part of this procedure. The Endleaf engine serve is the backend named in the gateway registry; use that unit's port and state directory. The droplet gateway is unchanged: do not edit its version pin as part of this restart. diff --git a/parts/common/memnet/memnet/acl.py b/parts/common/memnet/memnet/acl.py index 594692f9..f2df0dcb 100644 --- a/parts/common/memnet/memnet/acl.py +++ b/parts/common/memnet/memnet/acl.py @@ -24,11 +24,13 @@ from __future__ import annotations +import hashlib +import hmac import os import re from collections.abc import Iterable from dataclasses import dataclass, field -from typing import Literal +from typing import Any, Literal from memnet.exceptions import MemNetError @@ -40,6 +42,7 @@ r"^(anchors|ids|labels|relations)=(.+)$", re.IGNORECASE, ) +_HEX64 = re.compile(r"[0-9a-f]{64}") def _env_truthy(name: str) -> bool: @@ -57,6 +60,15 @@ def in_process_trusted() -> bool: return _env_truthy("MEMNET_SERVE_INTERNAL") or _env_truthy("MEMNET_TEST_INLINE") +def lease_digest(lease: str) -> str: + """SHA-256 hex of the stripped lease. The raw lease is never stored (MN-REQ-06.16). + + SHA-256 is not a password KDF, so the lease MUST be a high-entropy secret + (for example 128 random bits or more). + """ + return hashlib.sha256(lease.strip().encode("utf-8")).hexdigest() + + def skip_bind_allowed() -> bool: """In-process MAY skip bind; explicit MEMNET_ACL_SKIP_BIND also opts in.""" if _env_truthy("MEMNET_ACL_SKIP_BIND"): @@ -100,11 +112,13 @@ def to_wire(self) -> str: class SessionBind: """Optional missionId + lease bind; when set, mutate must match. - MemNet-only (not a Neo4j/AgensGraph privilege). + Holds ``lease_digest(lease)``, not the lease. The caller re-presents the + lease on each mutate and it is hashed before comparison. MemNet-only (not a + Neo4j/AgensGraph privilege). """ mission_id: str - lease: str + lease_sha256: str @dataclass(frozen=True) @@ -155,13 +169,64 @@ def set_bind(self, mission_id: str, lease: str) -> None: "acl_bad_bind", "mission_id and lease required together", ) - self.bind = SessionBind(mission_id=mid, lease=lid) + self.bind = SessionBind(mission_id=mid, lease_sha256=lease_digest(lid)) self.enabled = True def clear_bind(self) -> None: self.bind = None +def acl_to_dict(acl: SessionAcl | None) -> dict[str, Any]: + """Serialise session ACL for snapshot ``#@ACL:`` and upgrade passport (MN-REQ-06.16).""" + if acl is None: + return {"enabled": False, "callers": [], "bind": None} + callers = [] + for grant in acl.callers.values(): + scope = grant.write_scope.to_wire() if grant.write_scope else None + callers.append( + { + "caller": grant.caller, + "can_pin_map": grant.can_pin_map, + "can_mutate": grant.can_mutate, + "write_scope": scope, + } + ) + bind = None + if acl.bind is not None: + bind = {"mission_id": acl.bind.mission_id, "lease_sha256": acl.bind.lease_sha256} + return {"enabled": acl.enabled, "callers": callers, "bind": bind} + + +def acl_from_dict(data: dict[str, Any]) -> SessionAcl: + """Rebuild a session ACL from ``acl_to_dict`` output; the enabled flag is kept as written.""" + acl = SessionAcl() + for row in data.get("callers") or []: + acl.grant( + str(row.get("caller") or ""), + can_pin_map=bool(row.get("can_pin_map", True)), + can_mutate=bool(row.get("can_mutate", True)), + write_scope=parse_write_scope(row.get("write_scope")), + ) + bind = data.get("bind") + if bind is not None: + if not isinstance(bind, dict) or not str(bind.get("mission_id") or "").strip(): + raise MemNetError("bad_snapshot", "malformed ACL bind") + digest = str(bind.get("lease_sha256") or "") + if digest: + if not _HEX64.fullmatch(digest): + raise MemNetError("bad_snapshot", "malformed ACL bind digest") + acl.bind = SessionBind(mission_id=str(bind["mission_id"]).strip(), lease_sha256=digest) + elif bind.get("lease"): + # Written before the digest: hash the raw lease on load. + acl.set_bind(str(bind["mission_id"]), str(bind["lease"])) + else: + # Dropping the bind would silently loosen the ACL. + raise MemNetError("bad_snapshot", "ACL bind has no lease digest") + # grant() and set_bind() both force enabled; the stored flag wins. + acl.enabled = bool(data.get("enabled")) + return acl + + def parse_write_scope(raw: str | None) -> WorkerWriteScope | None: """Parse ``anchors=a,b;ids=x;labels=TSK;relations=about,owns``.""" if raw is None: @@ -280,7 +345,11 @@ def check_bind( mid = (mission_id or os.environ.get("MEMNET_MISSION_ID") or "").strip() lid = (lease or os.environ.get("MEMNET_LEASE") or "").strip() - if mid == acl.bind.mission_id and lid == acl.bind.lease: + if ( + mid == acl.bind.mission_id + and lid + and hmac.compare_digest(lease_digest(lid), acl.bind.lease_sha256) + ): return if not require and skip_bind_allowed(): diff --git a/parts/common/memnet/memnet/atomic_file.py b/parts/common/memnet/memnet/atomic_file.py new file mode 100644 index 00000000..b7730a77 --- /dev/null +++ b/parts/common/memnet/memnet/atomic_file.py @@ -0,0 +1,54 @@ +"""Atomic private file writes for snapshots and manifests (MN-REQ-06.16). + +The temp file is created with mode 0600 (``mkstemp`` uses ``O_CREAT|O_EXCL`` +and 0600), written, fsynced, then renamed over the destination. The +destination is never a partial write, and it is never readable by other +users, even when it replaces an older file created with a looser mode. +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + + +def _fsync_fd(fd: int) -> None: + os.fsync(fd) + + +def atomic_write_bytes(path: Path, data: bytes) -> None: + """Write ``data`` via a 0600 temp file, fsync, and rename. + + A failure before rename leaves the destination untouched and removes + the temp file. + """ + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + dir=path.parent, + prefix=f"{path.name}.", + suffix=".tmp", + ) + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + handle.flush() + _fsync_fd(handle.fileno()) + os.replace(tmp, path) + except OSError: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + raise + try: + dir_fd = os.open(path.parent, os.O_RDONLY) + except OSError: + return + try: + _fsync_fd(dir_fd) + except OSError: + pass + finally: + os.close(dir_fd) diff --git a/parts/common/memnet/memnet/checkpoint.py b/parts/common/memnet/memnet/checkpoint.py index 48e228fe..ed0055bb 100644 --- a/parts/common/memnet/memnet/checkpoint.py +++ b/parts/common/memnet/memnet/checkpoint.py @@ -9,7 +9,6 @@ import hashlib import json -import os import threading from dataclasses import dataclass from pathlib import Path @@ -17,9 +16,11 @@ from memnet import __version__ from memnet.admin_usage import authenticate +from memnet.atomic_file import atomic_write_bytes from memnet.exceptions import MemNetError from memnet.registry import list_entries from memnet.session import iso_timestamp +from memnet.snapshot import backup_v1_before_v2 from memnet.upgrade import snapshot_format_version, state_dir CHECKPOINT_DIRNAME = "checkpoint" @@ -29,43 +30,6 @@ _checkpoint_lock = threading.Lock() -def _fsync_fd(fd: int) -> None: - os.fsync(fd) - - -def atomic_write_bytes(path: Path, data: bytes) -> None: - """Write ``data`` via a temp file, fsync, and rename. - - A failure before rename leaves the destination untouched and removes - the temp file. The destination is never a partial write. - """ - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_name(path.name + ".tmp") - try: - with tmp.open("wb") as handle: - handle.write(data) - handle.flush() - _fsync_fd(handle.fileno()) - os.replace(tmp, path) - except OSError: - try: - tmp.unlink(missing_ok=True) - except OSError: - pass - raise - try: - dir_fd = os.open(path.parent, os.O_RDONLY) - except OSError: - return - try: - try: - _fsync_fd(dir_fd) - except OSError: - return - finally: - os.close(dir_fd) - - def _safe_name(session_id: str) -> str: cleaned = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in session_id) return cleaned or "session" @@ -180,6 +144,7 @@ def _checkpoint_locked(directory: Path | None) -> CheckpointResult: name = f"{_safe_name(sid)}.snap" path = dest / name try: + backup_v1_before_v2(path) atomic_write_bytes(path, payload) except OSError as exc: skipped.append( diff --git a/parts/common/memnet/memnet/cli.py b/parts/common/memnet/memnet/cli.py index 4cf2a5ad..2822d988 100644 --- a/parts/common/memnet/memnet/cli.py +++ b/parts/common/memnet/memnet/cli.py @@ -469,9 +469,11 @@ def session_load( _acl_check(SimpleNamespace(acl=acl), caller, "pin_map") purge_expired(caps) + from_snapshot = False try: if file is not None: ss = load_snapshot(file, caps=caps, ttl_minutes=ttl, keep_id=keep_id) + from_snapshot = True else: sid = session or os.environ.get("MEMNET_SESSION") if not sid: @@ -497,13 +499,28 @@ def session_load( keep_id=True, hide_path=True, ) + from_snapshot = True emit_session(ss.session_id, ss.meta.expires_at, str(ss.meta.ttl_minutes)) emit_stderr(f"MEMNET_SESSION={ss.session_id}") emit_stat("loaded", ss.store.row_count_non_law(), str(file) if file is not None else "-") + if from_snapshot: + _emit_loaded_acl(ss.session_id) except MemNetError as exc: _handle_error(exc) +def _emit_loaded_acl(session_id: str) -> None: + """Say what the snapshot's ACL restored. Absent means an older snapshot (ACL off).""" + entry = get_entry(session_id) + acl = entry.acl if entry is not None else None + if acl is None: + emit_stdout("@ACL: absent|off") + return + enabled = 1 if acl.enabled else 0 + bind = 1 if acl.bind is not None else 0 + emit_stdout(f"@ACL: loaded|enabled={enabled}|callers={len(acl.callers)}|bind={bind}") + + @admin_app.command("usage-report") def admin_usage_report( token: Annotated[ diff --git a/parts/common/memnet/memnet/snapshot.py b/parts/common/memnet/memnet/snapshot.py index d7c931b6..cd038faa 100644 --- a/parts/common/memnet/memnet/snapshot.py +++ b/parts/common/memnet/memnet/snapshot.py @@ -2,11 +2,14 @@ from __future__ import annotations +import json import os import secrets from datetime import timedelta from pathlib import Path +from memnet.acl import SessionAcl, acl_from_dict, acl_to_dict +from memnet.atomic_file import atomic_write_bytes from memnet.config import Caps from memnet.exceptions import MemNetError from memnet.fixed_tags import FIXED_TAGS @@ -25,6 +28,14 @@ from memnet.wire import split_snapshot_lines SNAPSHOT_MAGIC = "# memnet-snapshot-v1" +# @SNAP format version (MN-REQ-06.16). 2 = carries the #@ACL: line, which is required. +# 1 = written before the ACL was persisted; it loads with ACL off (@ACL: absent|off). +SNAPSHOT_FORMAT = 2 +SUPPORTED_SNAPSHOT_FORMATS = (1, 2) +# Copy of a format 1 file, kept once before the first format 2 write (MN-REQ-06.16). +V1_BACKUP_SUFFIX = ".v1.bak" +# Session ACL line. A ``#`` line, so readers that predate it skip it. +_ACL_PREFIX = "#@ACL:" _SECTION_MAP = "# map" _SECTION_REL = "# relations" _SECTION_REC = "# records" @@ -144,14 +155,21 @@ def snapshot_text(ss: SessionStore) -> str: return text +def _acl_line(acl: SessionAcl) -> str: + payload = json.dumps(acl_to_dict(acl), sort_keys=True, separators=(",", ":")) + return f"{_ACL_PREFIX}{payload}" + + def _format_snapshot(ss: SessionStore, rec_lines: list[str], emit_map: TagMap) -> str: lines = [SNAPSHOT_MAGIC] m = ss.meta hw = "1" if m.has_writes else "0" modified = m.modified_at or "-" lines.append( - f"@SNAP: 1|{m.session_id}|{m.created_at}|{m.expires_at}|{m.ttl_minutes}|{hw}|{modified}" + f"@SNAP: {SNAPSHOT_FORMAT}|{m.session_id}|{m.created_at}|{m.expires_at}|" + f"{m.ttl_minutes}|{hw}|{modified}" ) + lines.append(_acl_line(ss.acl)) lines.append(_SECTION_MAP) lines.extend(tag_map_to_lines(emit_map)) lines.append(_SECTION_REL) @@ -170,7 +188,11 @@ def _verify_emitted_snapshot( ) -> None: """Refuse save if the formatted blob would not load as the same values.""" used_nicks: set[str] = set() - _, map_lines, _, rec_lines = _parse_sections(split_snapshot_lines(text)) + lines = split_snapshot_lines(text) + reloaded_acl = _parse_acl(lines) + if reloaded_acl is None or acl_to_dict(reloaded_acl) != acl_to_dict(ss.acl): + raise MemNetError("snapshot_unsaveable", "acl not reloadable") + _, map_lines, _, rec_lines = _parse_sections(lines) try: loaded_map = load_persisted_map_from_lines(map_lines, ss.caps) except MemNetError as exc: @@ -256,13 +278,49 @@ def snapshot_locator_schema_warnings(ss: SessionStore) -> list[str]: return msgs -def write_snapshot(ss: SessionStore, path: str | Path) -> int: +def _snap_format_of(raw: bytes) -> int | None: + """@SNAP version of a snapshot blob, or None when there is no readable @SNAP line.""" + for line in raw.decode("utf-8", errors="replace").splitlines(): + if line.startswith("@SNAP:"): + head = line.removeprefix("@SNAP:").strip().split("|", 1)[0] + return int(head) if head.isdigit() else None + return None + + +def backup_v1_before_v2(path: Path) -> None: + """Keep the format 1 bytes once, before the first format 2 write over them (MN-REQ-06.16). + + Only a file whose @SNAP version is 1 is copied, to ``.v1.bak`` at + mode 0600. An existing backup is never overwritten. Nothing is dual-written: + the snapshot itself is always format 2. A failed backup raises, so the v1 + file is not replaced without its copy. + """ + if not path.is_file(): + return + bak = path.with_name(path.name + V1_BACKUP_SUFFIX) + if bak.exists(): + return + raw = path.read_bytes() + if _snap_format_of(raw) != 1: + return + atomic_write_bytes(bak, raw) + + +def write_snapshot(ss: SessionStore, path: str | Path, *, trailer: str = "") -> int: + """Write the snapshot atomically at mode 0600 (MN-REQ-06.16). + + ``trailer`` is appended after the verified body. The caller passes only + ``#`` lines (the upgrade passport), which readers skip. A format 1 file + at ``path`` is backed up once to ``.v1.bak`` before it is replaced. + """ for msg in snapshot_locator_schema_warnings(ss): emit_wrn("snapshot_schema_drop", msg) rec_lines, hid_to_nick, emit_map = _emit_record_lines(ss) text = _format_snapshot(ss, rec_lines, emit_map) _verify_emitted_snapshot(ss, text, hid_to_nick, emit_map) - Path(path).write_text(text, encoding="utf-8") + dest = Path(path) + backup_v1_before_v2(dest) + atomic_write_bytes(dest, (text + trailer).encode("utf-8")) return ss.store.row_count_non_law() @@ -294,20 +352,24 @@ def _parse_sections(lines: list[str]) -> tuple[SessionMeta, list[str], list[str] rec_lines.append(line) if not snap_line: raise MemNetError("bad_snapshot", "missing @SNAP line") - return _parse_snap(snap_line), map_lines, rel_lines, rec_lines + meta, version = _parse_snap(snap_line) + # Format 2 always carries the ACL line. Its absence is a corrupt file, not ACL off. + if version >= 2 and not any(raw.strip().startswith(_ACL_PREFIX) for raw in lines): + raise MemNetError("bad_snapshot", "format 2 snapshot is missing the #@ACL line") + return meta, map_lines, rel_lines, rec_lines -def _parse_snap(line: str) -> SessionMeta: +def _parse_snap(line: str) -> tuple[SessionMeta, int]: payload = line.removeprefix("@SNAP:").strip() parts = payload.split("|") if len(parts) < 5: raise MemNetError("bad_snapshot", "malformed @SNAP line") version = int(parts[0]) - if version != 1: + if version not in SUPPORTED_SNAPSHOT_FORMATS: raise MemNetError("bad_snapshot", f"unsupported snapshot version {version}") has_writes = len(parts) > 5 and parts[5] == "1" modified_at = parts[6] if len(parts) > 6 and parts[6] not in ("", "-") else None - return SessionMeta( + meta = SessionMeta( session_id=parts[1], created_at=parts[2], expires_at=parts[3], @@ -315,6 +377,26 @@ def _parse_snap(line: str) -> SessionMeta: has_writes=has_writes, modified_at=modified_at, ) + return meta, version + + +def _parse_acl(lines: list[str]) -> SessionAcl | None: + """Session ACL from the ``#@ACL:`` header line. None when the snapshot has none. + + A malformed line fails the load. Dropping it would silently turn ACL off. + """ + for raw in lines: + line = raw.strip() + if not line.startswith(_ACL_PREFIX): + continue + try: + data = json.loads(line.removeprefix(_ACL_PREFIX)) + except json.JSONDecodeError as exc: + raise MemNetError("bad_snapshot", "malformed ACL line") from exc + if not isinstance(data, dict): + raise MemNetError("bad_snapshot", "ACL line must be an object") + return acl_from_dict(data) + return None def _parse_relations(rel_lines: list[str]) -> set[str]: @@ -385,7 +467,9 @@ def load_snapshot_text( preserve_clocks: bool = False, ) -> SessionStore: caps = caps or Caps() - meta, map_lines, rel_lines, rec_lines = _parse_sections(split_snapshot_lines(text)) + lines = split_snapshot_lines(text) + meta, map_lines, rel_lines, rec_lines = _parse_sections(lines) + acl = _parse_acl(lines) tag_map = load_persisted_map_from_lines(map_lines, caps) relations = _parse_relations(rel_lines) if not relations: @@ -411,6 +495,7 @@ def load_snapshot_text( ttl_minutes=ttl, has_writes=meta.has_writes, modified_at=meta.modified_at, + acl_enabled=bool(acl and acl.enabled), ) store = MemStore(tag_map, caps) used_nicks: set[str] = set() @@ -422,6 +507,7 @@ def load_snapshot_text( tag_map=tag_map, store=store, relations=relations, + acl=acl, ) register(session_id, entry) return SessionStore(session_id, caps) @@ -434,7 +520,10 @@ def materialise_snapshot_text(text: str, *, caps: Caps | None = None) -> Session slide clocks, or touch the live registry. """ caps = caps or Caps() - meta, map_lines, rel_lines, rec_lines = _parse_sections(split_snapshot_lines(text)) + lines = split_snapshot_lines(text) + meta, map_lines, rel_lines, rec_lines = _parse_sections(lines) + # Parsed so checkpoint-verify fails on a corrupt ACL line; the throwaway entry keeps it. + acl = _parse_acl(lines) tag_map = load_persisted_map_from_lines(map_lines, caps) relations = _parse_relations(rel_lines) if not relations: @@ -451,4 +540,5 @@ def materialise_snapshot_text(text: str, *, caps: Caps | None = None) -> Session tag_map=tag_map, store=store, relations=relations, + acl=acl, ) diff --git a/parts/common/memnet/memnet/upgrade.py b/parts/common/memnet/memnet/upgrade.py index 87ffb8e8..dca0b8bc 100644 --- a/parts/common/memnet/memnet/upgrade.py +++ b/parts/common/memnet/memnet/upgrade.py @@ -18,13 +18,19 @@ from typing import Any from memnet import __version__ -from memnet.acl import SessionAcl, parse_write_scope +from memnet.acl import acl_from_dict, acl_to_dict from memnet.admin_usage import authenticate +from memnet.atomic_file import atomic_write_bytes from memnet.exceptions import MemNetError from memnet.registry import count, get_entry, list_entries -from memnet.snapshot import SNAPSHOT_MAGIC, load_snapshot_text, write_snapshot +from memnet.snapshot import ( + SNAPSHOT_FORMAT, + SNAPSHOT_MAGIC, + SUPPORTED_SNAPSHOT_FORMATS, + load_snapshot_text, + write_snapshot, +) -SUPPORTED_SNAPSHOT_FORMATS = (1,) MANIFEST_FORMAT = 1 MANIFEST_NAME = "upgrade-manifest.json" RESTORE_NAME = "upgrade-restore.json" @@ -43,10 +49,8 @@ def snapshot_format_version() -> int: - """Format written by this patch. Older v1 snapshots still load.""" - if not SNAPSHOT_MAGIC.endswith("v1"): - raise MemNetError("bad_snapshot", "snapshot magic is not v1") - return 1 + """Format written by this patch (@SNAP version 2). Format 1 files still load, ACL off.""" + return SNAPSHOT_FORMAT def state_dir() -> Path: @@ -198,44 +202,11 @@ def _house_tags(tag_map: Any) -> list[str]: return sorted(tag_map.tags.keys()) -def _acl_to_dict(acl: SessionAcl | None) -> dict[str, Any]: - if acl is None: - return {"enabled": False, "callers": [], "bind": None} - callers = [] - for grant in acl.callers.values(): - scope = grant.write_scope.to_wire() if grant.write_scope else None - callers.append( - { - "caller": grant.caller, - "can_pin_map": grant.can_pin_map, - "can_mutate": grant.can_mutate, - "write_scope": scope, - } - ) - bind = None - if acl.bind is not None: - bind = {"mission_id": acl.bind.mission_id, "lease": acl.bind.lease} - return {"enabled": acl.enabled, "callers": callers, "bind": bind} - - def _apply_acl(entry: Any, data: dict[str, Any] | None) -> None: if not data: return - acl = SessionAcl(enabled=bool(data.get("enabled"))) - for row in data.get("callers") or []: - acl.grant( - str(row.get("caller") or ""), - can_pin_map=bool(row.get("can_pin_map", True)), - can_mutate=bool(row.get("can_mutate", True)), - write_scope=parse_write_scope(row.get("write_scope")), - ) - bind = data.get("bind") - if isinstance(bind, dict) and bind.get("mission_id") and bind.get("lease"): - acl.set_bind(str(bind["mission_id"]), str(bind["lease"])) - if data.get("enabled"): - acl.enabled = True - entry.acl = acl - entry.meta.acl_enabled = acl.enabled + entry.acl = acl_from_dict(data) + entry.meta.acl_enabled = entry.acl.enabled def _sha256(path: Path) -> str: @@ -245,16 +216,14 @@ def _sha256(path: Path) -> str: def _atomic_write(path: Path, text: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(text, encoding="utf-8") - tmp.replace(path) + """Manifest and report writes: 0600 temp file, fsync, rename (MN-REQ-06.16).""" + atomic_write_bytes(path, text.encode("utf-8")) def _passport(entry: Any) -> dict[str, Any]: meta = entry.meta return { - "acl": _acl_to_dict(entry.acl), + "acl": acl_to_dict(entry.acl), "product": meta.product, "created_at": meta.created_at, "expires_at": meta.expires_at, @@ -263,10 +232,9 @@ def _passport(entry: Any) -> dict[str, Any]: } -def _append_passport(path: Path, payload: dict[str, Any]) -> None: - line = PASSPORT_PREFIX + json.dumps(payload, separators=(",", ":"), sort_keys=True) - with path.open("a", encoding="utf-8") as handle: - handle.write(line + "\n") +def _passport_line(payload: dict[str, Any]) -> str: + """Trailing ``#`` line written in the same atomic snapshot write.""" + return PASSPORT_PREFIX + json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n" def split_passport(text: str) -> tuple[str, dict[str, Any] | None]: @@ -312,8 +280,11 @@ def _snapshot_one(entry: Any, snap_dir: Path) -> dict[str, Any]: edges = _edge_count(entry.store) house = _house_tags(entry.tag_map) passport = _passport(entry) - write_snapshot(SessionStore(sid, entry.store.caps), path) - _append_passport(path, passport) + write_snapshot( + SessionStore(sid, entry.store.caps), + path, + trailer=_passport_line(passport), + ) checksum = _sha256(path) return { "session_id": sid, @@ -517,7 +488,7 @@ def _restore_one(root: Path, row: dict[str, Any], report: RestoreReport) -> None _fail_session(report, sid, exc.code, exc.message) finally: if before is not None and path.is_file() and path.read_bytes() != before: - path.write_bytes(before) + atomic_write_bytes(path, before) def restore_manifest(directory: Path | None = None) -> RestoreReport: diff --git a/sysml-models/models/deploy.sysml b/sysml-models/models/deploy.sysml index fa5f6e7d..03dc4143 100644 --- a/sysml-models/models/deploy.sysml +++ b/sysml-models/models/deploy.sysml @@ -1070,6 +1070,48 @@ package MemNet { satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_12_HousekeepEndpointIdentity; } + part def SnapshotAcl { + doc /* + Session ACL in the snapshot (MN-REQ-06.16). @SNAP version 2 + carries one comment line #@ACL: after @SNAP. Version 2 without + the line fails bad_snapshot. Version 1 loads ACL off and prints + @ACL: absent|off. A malformed line fails the load. The bind + stores the SHA-256 of the lease only; the lease is never printed. + Snapshot and manifest files are written atomically at mode 0600. + The first format 2 write over a format 1 file first copies the v1 + bytes once to .v1.bak (mode 0600, atomic); an existing backup + is never overwritten; no dual write (the file itself is always v2). + Rollback to 0.19.23 restores the .v1.bak files over the snapshots; + writes made after the upgrade are lost; a v2 file with no backup is + refused by an old build. No SemVer bump. + */ + attribute aclLinePersisted : Boolean = true; + attribute aclLineCommentPrefix : String = "#@ACL:"; + attribute snapshotFormatVersion : Integer = 2; + attribute snapshotFormatV1Loads : Boolean = true; + attribute aclRestoredOnLoad : Boolean = true; + attribute aclRestoredOnKeepIdLoad : Boolean = true; + attribute aclAbsentLoadsOff : Boolean = true; + attribute aclAbsentStat : String = "@ACL: absent|off"; + attribute v2MissingAclFailsBadSnapshot : Boolean = true; + attribute aclMalformedFailsClosed : Boolean = true; + attribute aclLeaseNotPrinted : Boolean = true; + attribute bindStoresLeaseDigestOnly : Boolean = true; + attribute leaseDigestAlgorithm : String = "sha256"; + attribute snapshotFileMode : String = "0600"; + attribute atomicPrivateWrite : Boolean = true; + attribute v1BackupBeforeFirstV2Write : Boolean = true; + attribute v1BackupOnlyWhenFormat1 : Boolean = true; + attribute v1BackupName : String = ".v1.bak"; + attribute v1BackupMode : String = "0600"; + attribute v1BackupNeverOverwritten : Boolean = true; + attribute dualWriteV1 : Boolean = false; + attribute rollbackRestoresV1Backup : Boolean = true; + attribute rollbackLosesPostUpgradeWrites : Boolean = true; + attribute v2RefusedByOldBuild : Boolean = true; + attribute noSemVerBump : Boolean = true; + } + part def SnapshotStore { doc /* Named-session durable file: save/load schema + records + relations @@ -1124,6 +1166,8 @@ package MemNet { satisfy MN_REQ_00_MissionBridge::MN_REQ_01_SessionLifecycle::MN_REQ_01_9_SnapshotLosslessRoundTrip; satisfy MN_REQ_00_MissionBridge::MN_REQ_05_HardCaps::MN_REQ_05_3_ConsistentValueByteCap; satisfy MN_REQ_00_MissionBridge::MN_REQ_11_SnapshotInterop::MN_REQ_11_4_DistinctFromSessionSnapshot; + + part snapshotAcl : SnapshotAcl; } // ===================================================================== diff --git a/sysml-models/models/implementation.sysml b/sysml-models/models/implementation.sysml index a74d235d..5b0edfbb 100644 --- a/sysml-models/models/implementation.sysml +++ b/sysml-models/models/implementation.sysml @@ -132,6 +132,19 @@ package MemNetImplementation { attribute path : String = "parts/common/memnet/memnet/checkpoint.py"; attribute pyName : String = "memnet.checkpoint"; } + part def SnapshotAclMod { + doc /* + Session ACL in session save / load (MN-REQ-06.16): the #@ACL: + line and @SNAP version 2 in snapshot.py. The shared ACL serialiser + is acl.py (also used by the upgrade passport); the bind stores a + SHA-256 lease digest there. Snapshot and manifest writes go through + atomic_file.py (0600 temp, fsync, rename). + */ + attribute path : String = "parts/common/memnet/memnet/snapshot.py"; + attribute pyName : String = "memnet.snapshot"; + + satisfy MN_REQ_00_MissionBridge::MN_REQ_06_ProcessBoundary::MN_REQ_06_16_SnapshotAcl; + } part def CliFacadeMod { attribute path : String = "parts/common/memnet/memnet/cli.py"; attribute pyName : String = "memnet.cli"; @@ -285,6 +298,7 @@ package MemNetImplementation { part adminUsageMod : AdminUsageReportMod; part safeUpgradeMod : SafeUpgradeMod; part serveCheckpointMod : ServeCheckpointMod; + part snapAclMod : SnapshotAclMod; part cliMod : CliFacadeMod; part sessionsMod : SessionLifecycleMod; part storeMod : GraphStoreMod; @@ -376,6 +390,11 @@ package MemNetImplementation { end logical ::> memNetSystem.core.transport.tcp.serveCheckpoint; end code ::> serveCheckpointMod; } + allocation snapshotAclToMod : SoftwareAllocate { + doc /* Session ACL in snapshot save / load (MN-REQ-06.16). */ + end logical ::> memNetSystem.core.transport.inProcess.memory.sessions.snap; + end code ::> snapAclMod; + } allocation cliToMod : SoftwareAllocate { end logical ::> memNetSystem.core.cli; end code ::> cliMod; diff --git a/sysml-models/models/requirements.sysml b/sysml-models/models/requirements.sysml index db7a4b4b..98580e7c 100644 --- a/sysml-models/models/requirements.sysml +++ b/sysml-models/models/requirements.sysml @@ -38,6 +38,7 @@ MN-REQ-06.13 Serve request isolation: each command's stdout, stderr, and exit_code MN-REQ-06.14 Safe serve upgrade inside memnet serve: drain, manifest, restore, retire; no silent session loss MN-REQ-06.15 Admin checkpoint of every loaded session: lossless snaps, no drain, no traffic refuse + MN-REQ-06.16 Session snapshot carries the ACL (enabled, grants, digest-only bind); format 2 SHALL carry #@ACL:; format 1 loads ACL off (@ACL: absent|off); files 0600; first v2 write over v1 keeps .v1.bak once (0600); no dual write MN-REQ-11.17 Catalog Snap / model Snap — session strata (0.15) MN-REQ-11.17.1 Cross-cut satisfy locators on the catalog; SysMLEdge pin_map is a different tool (not this catalog) @@ -1241,6 +1242,39 @@ package MemNetRequirements { */ attribute requirementId : String = "MN-REQ-06.15"; } + + requirement def MN_REQ_06_16_SnapshotAcl { + doc /* + SHALL persist the session CapsPolicy ACL in every session + snapshot written by session save, expire save, checkpoint, + and safe upgrade: enabled flag, per-caller grants (pin_map, + mutate, WorkerWriteScope), and optional bind. session_load + SHALL restore it, including the keep_id reload path. + The ACL SHALL be one comment line (#@ACL:) after @SNAP. + @SNAP is version 2 for every snapshot this patch writes. + A version 2 snapshot with no ACL line SHALL fail the load + with bad_snapshot. Only a version 1 snapshot (written before + the ACL was persisted) SHALL load with ACL off, and the load + output SHALL say so (@ACL: absent|off). Loaders SHALL still + accept version 1. A malformed ACL line SHALL fail the load; + it SHALL NOT silently load with ACL off. The load output + SHALL NOT print the bind lease. The bind SHALL store only the + SHA-256 of the lease, and the caller re-presents the lease to + mutate; the snapshot and the upgrade passport SHALL NOT hold + the raw lease. Snapshot files and the upgrade and checkpoint + manifests SHALL be written atomically (0600 temp file, fsync, + rename) at mode 0600. The snapshot and the upgrade passport + share one ACL serialiser. A save SHALL refuse when the ACL + does not reload to the same grants. Malformed bind (no lease + digest, not a dict, or no mission_id) SHALL fail bad_snapshot. + The first format 2 write over a format 1 file SHALL first copy + the v1 bytes once to .v1.bak at mode 0600; an existing + backup SHALL NOT be overwritten; no dual write. Rollback to + 0.19.23 restores the .v1.bak files; writes after the upgrade + are lost, and an old build refuses v2 loudly. No SemVer bump. + */ + attribute requirementId : String = "MN-REQ-06.16"; + } } // ----- MCP agent boundary ----- @@ -2277,6 +2311,8 @@ package MemNetRequirements { : MN_REQ_00_MissionBridge::MN_REQ_06_ProcessBoundary::MN_REQ_06_14_SafeServeUpgrade; requirement serveCheckpointReq : MN_REQ_00_MissionBridge::MN_REQ_06_ProcessBoundary::MN_REQ_06_15_ServeCheckpoint; + requirement snapshotAclReq + : MN_REQ_00_MissionBridge::MN_REQ_06_ProcessBoundary::MN_REQ_06_16_SnapshotAcl; requirement mcpAgentBoundaryReq : MN_REQ_00_MissionBridge::MN_REQ_07_McpAgentBoundary; diff --git a/sysml-models/models/verify.sysml b/sysml-models/models/verify.sysml index 3f2a7d18..37189041 100644 --- a/sysml-models/models/verify.sysml +++ b/sysml-models/models/verify.sysml @@ -58,6 +58,12 @@ TcpServeBridge; admin credential; per-session lock; lossless atomic snaps; skip unsaveable; no drain; no live load; no SemVer bump. + Snapshot ACL (MN-VER-06-S14): SnapshotStore / SnapshotAcl; #@ACL: + line after @SNAP version 2; version 2 without the line fails + bad_snapshot; version 1 loads ACL off with @ACL: absent|off; + malformed fails closed; bind stores a SHA-256 digest only; bind lease + not printed; snapshot and manifest files mode 0600; first v2 write + over v1 keeps .v1.bak once (0600), no dual write; no SemVer bump. Optional id nickname TARGET (MN-VER-02-S01): GraphElement identity; CREATE () legal; HiddenStoreHandle off-wire; 0.9 leftover_by_id store. TARGET command list cue/pattern only (MN-VER-07-S01): no required-id @@ -1733,6 +1739,72 @@ package MemNetVerification { } } + verification def MN_VER_06_S14_SnapshotAcl { + doc /* + MN-REQ-06.16 — the session ACL persists in the snapshot as one + comment line (#@ACL:) after @SNAP, and session_load restores it + (including keep_id). @SNAP is version 2. A version 2 snapshot with + no ACL line fails bad_snapshot. A version 1 snapshot loads ACL off + and says so (@ACL: absent|off). A malformed ACL line fails the load. + The bind stores a SHA-256 digest of the lease only, and the lease + is never printed. Snapshot and manifest files are mode 0600 and are + written atomically. A v1 file is backed up once to .v1.bak + (0600) before the first v2 write; the backup is never overwritten; + v2 over v2 and first writes make no backup. No dual write. Rollback + to 0.19.23 restores the .v1.bak files: writes after the upgrade are + lost. A malformed bind (no lease digest, non-dict, no mission_id) + fails bad_snapshot. No SemVer bump. + Tests: tests/test_acl_snapshot.py::test_round_trip_keeps_enable_flag_grants_and_bind; + ::test_format_1_snapshot_loads_with_acl_off; + ::test_format_2_missing_acl_line_fails_bad_snapshot; + ::test_snapshot_files_are_mode_0600_and_replace_loose_modes; + ::test_bind_stores_only_a_digest_of_the_lease; + ::test_reader_only_caller_is_refused_after_reload; + ::test_keep_id_reload_restores_acl_and_fresh_id_does_too; + ::test_malformed_acl_line_fails_closed; + ::test_v1_file_is_backed_up_once_before_first_v2_write; + ::test_v2_over_v2_and_first_write_create_no_backup; + ::test_malformed_bind_in_file_fails_closed_bad_snapshot; + tests/test_safe_upgrade.py::test_round_trip_keeps_id_acl_ttl_and_near_cap (manifest mode, no raw lease). + tests/test_safe_upgrade.py::test_rollback_restore_rewrites_original_bytes_atomically (restore writes 0600, original bytes). + Model: tests/test_sysml_acl_snapshot.py. + */ + attribute verificationId : String = "MN-VER-06-S14"; + + subject snap : SnapshotStore; + + objective snapshotAcl { + verify snapshotAclReq; + require constraint { + snap.snapshotAcl.aclLinePersisted == true + and snap.snapshotAcl.aclLineCommentPrefix == "#@ACL:" + and snap.snapshotAcl.snapshotFormatVersion == 2 + and snap.snapshotAcl.snapshotFormatV1Loads == true + and snap.snapshotAcl.aclRestoredOnLoad == true + and snap.snapshotAcl.aclRestoredOnKeepIdLoad == true + and snap.snapshotAcl.aclAbsentLoadsOff == true + and snap.snapshotAcl.aclAbsentStat == "@ACL: absent|off" + and snap.snapshotAcl.v2MissingAclFailsBadSnapshot == true + and snap.snapshotAcl.aclMalformedFailsClosed == true + and snap.snapshotAcl.aclLeaseNotPrinted == true + and snap.snapshotAcl.bindStoresLeaseDigestOnly == true + and snap.snapshotAcl.leaseDigestAlgorithm == "sha256" + and snap.snapshotAcl.snapshotFileMode == "0600" + and snap.snapshotAcl.atomicPrivateWrite == true + and snap.snapshotAcl.v1BackupBeforeFirstV2Write == true + and snap.snapshotAcl.v1BackupOnlyWhenFormat1 == true + and snap.snapshotAcl.v1BackupName == ".v1.bak" + and snap.snapshotAcl.v1BackupMode == "0600" + and snap.snapshotAcl.v1BackupNeverOverwritten == true + and snap.snapshotAcl.dualWriteV1 == false + and snap.snapshotAcl.rollbackRestoresV1Backup == true + and snap.snapshotAcl.rollbackLosesPostUpgradeWrites == true + and snap.snapshotAcl.v2RefusedByOldBuild == true + and snap.snapshotAcl.noSemVerBump == true + } + } + } + verification def MN_VER_04_S06_HousekeepEndpointIdentity { doc /* MN-REQ-04.12 — housekeep resolves edge endpoints by hid and @@ -2302,6 +2374,8 @@ package MemNetVerification { : MN_VER_06_S12_SafeServeUpgrade; verification serveCheckpointVerify : MN_VER_06_S13_ServeCheckpoint; + verification snapshotAclVerify + : MN_VER_06_S14_SnapshotAcl; verification housekeepEndpointIdentityVerify : MN_VER_04_S06_HousekeepEndpointIdentity; } diff --git a/sysml-models/outputs/ssot-to-code-allocate-map.md b/sysml-models/outputs/ssot-to-code-allocate-map.md index 6ca94aee..7c9d6f7b 100644 --- a/sysml-models/outputs/ssot-to-code-allocate-map.md +++ b/sysml-models/outputs/ssot-to-code-allocate-map.md @@ -32,6 +32,7 @@ | productGateToMod | `…gqlWire.productGate` | `gqlPyMod` | `parts/common/memnet/memnet/gql.py` | `memnet.gql` | true | | schemaToMod | `…sessions.schema` | `schemaMod` | `parts/common/memnet/memnet/schema_registry.py` | `memnet.schema_registry` | true | | snapToMod | `…sessions.snap` | `snapMod` | `parts/common/memnet/memnet/snapshot_store.py` | `memnet.snapshot_store` | true | +| snapshotAclToMod | `…sessions.snap` | `snapAclMod` | `parts/common/memnet/memnet/snapshot.py` | `memnet.snapshot` | true | | housekeepToMod | `…sessions.housekeep` | `housekeepMod` | `parts/common/memnet/memnet/housekeep_settle.py` | `memnet.housekeep_settle` | true | | mutateToMod | `…commit.mutate` | `mutateMod` | `parts/common/memnet/memnet/mutate_gate.py` | `memnet.mutate_gate` | true | | rsvToMod | `…commit.reserve` | `rsvMod` | `parts/common/memnet/memnet/neighbourhood_reserve.py` | `memnet.neighbourhood_reserve` | true | diff --git a/tests/doc_gate_lib.py b/tests/doc_gate_lib.py index 966e5478..ff372bb5 100644 --- a/tests/doc_gate_lib.py +++ b/tests/doc_gate_lib.py @@ -173,10 +173,17 @@ def canonical_snapshot(text: str) -> str: def snapshot_write_once_report() -> dict[str, Any]: - """What exists today. Do not invent write-once in the engine.""" + """What exists today. Do not invent write-once in the engine. + + Writes go through atomic_file (0600 temp via mkstemp, fsync, rename). The + destination is still overwritten, not write-once. + """ src = SNAPSHOT_PY.read_text(encoding="utf-8") + atomic = (_ENGINE / "atomic_file.py").read_text(encoding="utf-8") return { "write_snapshot_uses_path_write_text": "Path(path).write_text(" in src, + "write_snapshot_uses_atomic_private_write": "atomic_write_bytes(" in src, + "temp_file_mkstemp_0600": "mkstemp(" in atomic and "os.replace(" in atomic, "engine_o_excl": "O_EXCL" in src, "engine_chmod": "chmod" in src, "engine_immutable_flag": "immutable" in src.lower() or "chattr" in src, @@ -539,7 +546,7 @@ def snapshot_value_cap_report() -> dict[str, Any]: "max_fields_on_schema_register": "len(field_names) > caps.max_fields" in tag, "emit_record_schema_columns_only": "values = [record.fields.get(f, " in out_src, "snapshot_widens_undeclared_schema": "_snapshot_emit_tag_map" in snap, - "save_write_text_no_value_check": "Path(path).write_text" in snap, + "save_atomic_write_no_value_check": "atomic_write_bytes(" in snap, "config_value": '_env_int("MEMNET_MAX_VALUE_BYTES", 4096)' in cfg, "config_line": '_env_int("MEMNET_MAX_LINE_BYTES", 32768)' in cfg, "config_fields": '_env_int("MEMNET_MAX_FIELDS", 32)' in cfg, diff --git a/tests/test_acl_snapshot.py b/tests/test_acl_snapshot.py new file mode 100644 index 00000000..3795bfe7 --- /dev/null +++ b/tests/test_acl_snapshot.py @@ -0,0 +1,354 @@ +"""MN-REQ-06.16 — the session ACL persists in snapshots and restores on load.""" + +from __future__ import annotations + +import json +import stat + +import pytest + +from memnet.acl import acl_from_dict, acl_to_dict, check_bind, lease_digest +from memnet.exceptions import MemNetError +from memnet.in_process_engine import run_argv +from memnet.registry import get_entry, remove_entry +from memnet.session import open_session +from memnet.snapshot import load_snapshot, write_snapshot + +_TSK = "CREATE (:TSK {id: 'TSK_acl', goal: 'bound', status: 'open'})\n" +_TSK2 = "CREATE (:TSK {id: 'TSK_two', goal: 'refused', status: 'open'})\n" + + +def _acl_lines(text: str) -> list[str]: + return [ln for ln in text.splitlines() if ln.startswith("#@ACL:")] + + +def _without_acl(text: str) -> str: + """The same snapshot with the ACL line removed (format unchanged).""" + return "".join(ln + "\n" for ln in text.splitlines() if not ln.startswith("#@ACL:")) + + +def _as_format_1(text: str) -> str: + """The same snapshot as a format 1 writer produced it: @SNAP version 1, no ACL line.""" + return _without_acl(text).replace("\n@SNAP: 2|", "\n@SNAP: 1|", 1) + + +def _grants(ss) -> None: + ss.grant_caller("owner", can_pin_map=True, can_mutate=True) + ss.grant_caller("writer", can_pin_map=True, can_mutate=True, write_scope="labels=TSK") + ss.grant_caller("reader", can_pin_map=True, can_mutate=False) + ss.set_bind("mission-demo", "lease-demo") + + +def test_round_trip_keeps_enable_flag_grants_and_bind(memnet_temp, schema_file, tmp_path): + ss = open_session(map_file=str(schema_file)) + _grants(ss) + before = acl_to_dict(ss.acl) + path = tmp_path / "acl.snap" + write_snapshot(ss, path) + + text = path.read_text(encoding="utf-8") + assert "\n@SNAP: 2|" in text # format 2 carries the ACL line + lines = _acl_lines(text) + assert len(lines) == 1 + assert lines[0].startswith("#") # a comment line: readers that predate it skip it + + remove_entry(ss.session_id) + load_snapshot(path, keep_id=True) + entry = get_entry(ss.session_id) + assert entry is not None and entry.acl is not None + assert entry.acl.enabled is True + assert entry.meta.acl_enabled is True + assert acl_to_dict(entry.acl) == before + assert entry.acl.callers["writer"].write_scope.labels == frozenset({"TSK"}) + assert entry.acl.callers["reader"].can_mutate is False + + +@pytest.mark.parametrize("enable", [False, True], ids=["off", "on-no-grants"]) +def test_enable_flag_round_trips_without_grants(memnet_temp, schema_file, tmp_path, enable): + ss = open_session(map_file=str(schema_file)) + if enable: + ss.enable_acl() + path = tmp_path / "flag.snap" + write_snapshot(ss, path) + remove_entry(ss.session_id) + load_snapshot(path, keep_id=True) + entry = get_entry(ss.session_id) + assert entry is not None and entry.acl is not None + assert entry.acl.enabled is enable + assert entry.acl.callers == {} + + +def test_cli_save_and_load_report_restored_acl(memnet_temp, schema_file, tmp_path): + ss = open_session(map_file=str(schema_file)) + _grants(ss) + path = tmp_path / "cli.snap" + saved = run_argv( + ["session", "save", "--file", str(path), "--session", ss.session_id, "--caller", "owner"] + ) + assert saved["exit_code"] == 0, saved["stderr"] + remove_entry(ss.session_id) + loaded = run_argv(["session", "load", "--file", str(path), "--keep-id"]) + assert loaded["exit_code"] == 0, loaded["stderr"] + assert "@ACL: loaded|enabled=1|callers=3|bind=1" in loaded["stdout"] + assert "mission-demo" not in loaded["stdout"] # the bind lease is never printed + + +def test_format_1_snapshot_loads_with_acl_off(memnet_temp, schema_file, tmp_path): + ss = open_session(map_file=str(schema_file)) + assert run_argv(["mutate", "--stdin", "--session", ss.session_id], stdin=_TSK)["exit_code"] == 0 + path = tmp_path / "old.snap" + write_snapshot(ss, path) + old = _as_format_1(path.read_text(encoding="utf-8")) + assert "#@ACL:" not in old + assert "\n@SNAP: 1|" in old + path.write_text(old, encoding="utf-8") + + remove_entry(ss.session_id) + loaded = run_argv(["session", "load", "--file", str(path), "--keep-id"]) + assert loaded["exit_code"] == 0, loaded["stderr"] + assert "@ACL: absent|off" in loaded["stdout"] + assert "@ACL: loaded" not in loaded["stdout"] + entry = get_entry(ss.session_id) + assert entry is not None + assert entry.acl is None + assert entry.meta.acl_enabled is False + # ACL off: a mutate with no caller is allowed, as it was before MN-REQ-06.16. + assert ( + run_argv(["mutate", "--stdin", "--session", ss.session_id], stdin=_TSK2)["exit_code"] == 0 + ) + + +def test_format_2_missing_acl_line_fails_bad_snapshot(memnet_temp, schema_file, tmp_path): + ss = open_session(map_file=str(schema_file)) + _grants(ss) + path = tmp_path / "v2-no-acl.snap" + write_snapshot(ss, path) + text = _without_acl(path.read_text(encoding="utf-8")) + assert "\n@SNAP: 2|" in text + path.write_text(text, encoding="utf-8") + + remove_entry(ss.session_id) + with pytest.raises(MemNetError) as exc: + load_snapshot(path, keep_id=True) + assert exc.value.code == "bad_snapshot" + assert get_entry(ss.session_id) is None # never loaded with ACL silently off + + loaded = run_argv(["session", "load", "--file", str(path), "--keep-id"]) + assert loaded["exit_code"] != 0 + assert "bad_snapshot" in loaded["stdout"] + loaded["stderr"] + assert "@ACL: absent|off" not in loaded["stdout"] + assert get_entry(ss.session_id) is None + + +def test_snapshot_files_are_mode_0600_and_replace_loose_modes(memnet_temp, schema_file, tmp_path): + ss = open_session(map_file=str(schema_file)) + _grants(ss) + path = tmp_path / "private.snap" + path.write_text("stale", encoding="utf-8") + path.chmod(0o644) + write_snapshot(ss, path) + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert list(tmp_path.glob("*.tmp")) == [] # the temp file was renamed, not left behind + + cli_path = tmp_path / "cli-private.snap" + saved = run_argv( + [ + "session", + "save", + "--file", + str(cli_path), + "--session", + ss.session_id, + "--caller", + "owner", + ] + ) + assert saved["exit_code"] == 0, saved["stderr"] + assert stat.S_IMODE(cli_path.stat().st_mode) == 0o600 + + +def test_bind_stores_only_a_digest_of_the_lease(memnet_temp, schema_file, tmp_path): + ss = open_session(map_file=str(schema_file)) + _grants(ss) + path = tmp_path / "bind.snap" + write_snapshot(ss, path) + text = path.read_text(encoding="utf-8") + assert "lease-demo" not in text + assert lease_digest("lease-demo") in text + + remove_entry(ss.session_id) + load_snapshot(path, keep_id=True) + acl = get_entry(ss.session_id).acl + check_bind(acl, mission_id="mission-demo", lease="lease-demo", require=True) + with pytest.raises(MemNetError) as exc: + check_bind(acl, mission_id="mission-demo", lease="lease-other", require=True) + assert exc.value.code == "acl_bind" + + +def test_pre_digest_raw_lease_still_binds_on_load(): + acl = acl_from_dict( + { + "enabled": True, + "callers": [], + "bind": {"mission_id": "mission-demo", "lease": "lease-demo"}, + } + ) + assert acl.bind is not None and acl.bind.lease_sha256 == lease_digest("lease-demo") + check_bind(acl, mission_id="mission-demo", lease="lease-demo", require=True) + + +def test_malformed_bind_digest_fails_closed(): + with pytest.raises(MemNetError) as exc: + acl_from_dict( + {"enabled": True, "callers": [], "bind": {"mission_id": "m", "lease_sha256": "nope"}} + ) + assert exc.value.code == "bad_snapshot" + + +def test_reader_only_caller_is_refused_after_reload(memnet_temp, schema_file, tmp_path): + ss = open_session(map_file=str(schema_file)) + _grants(ss) + sid = ss.session_id + assert ( + run_argv(["mutate", "--stdin", "--session", sid, "--caller", "owner"], stdin=_TSK)[ + "exit_code" + ] + == 0 + ) + path = tmp_path / "reader.snap" + assert ( + run_argv(["session", "save", "--file", str(path), "--session", sid, "--caller", "owner"])[ + "exit_code" + ] + == 0 + ) + + remove_entry(sid) + assert run_argv(["session", "load", "--file", str(path), "--keep-id"])["exit_code"] == 0 + entry = get_entry(sid) + assert entry is not None and entry.acl is not None and entry.acl.enabled is True + rows = entry.store.row_count_non_law() + + refused = run_argv(["mutate", "--stdin", "--session", sid, "--caller", "reader"], stdin=_TSK2) + assert refused["exit_code"] != 0 + assert "acl_forbidden" in refused["stdout"] + refused["stderr"] + assert get_entry(sid).store.row_count_non_law() == rows # the write did not land + + allowed = run_argv(["mutate", "--stdin", "--session", sid, "--caller", "writer"], stdin=_TSK2) + assert allowed["exit_code"] == 0, allowed["stderr"] + + +def test_keep_id_reload_restores_acl_and_fresh_id_does_too(memnet_temp, schema_file, tmp_path): + ss = open_session(map_file=str(schema_file)) + _grants(ss) + before = acl_to_dict(ss.acl) + sid = ss.session_id + path = tmp_path / "keep.snap" + write_snapshot(ss, path) + + remove_entry(sid) + kept = run_argv(["session", "load", "--file", str(path), "--keep-id"]) + assert kept["exit_code"] == 0, kept["stderr"] + assert f"@SESSION: {sid}" in kept["stdout"] + kept_entry = get_entry(sid) + assert kept_entry is not None and acl_to_dict(kept_entry.acl) == before + + fresh = load_snapshot(path) + assert fresh.session_id != sid + fresh_entry = get_entry(fresh.session_id) + assert fresh_entry is not None and acl_to_dict(fresh_entry.acl) == before + + +def test_malformed_acl_line_fails_closed(memnet_temp, schema_file, tmp_path): + ss = open_session(map_file=str(schema_file)) + _grants(ss) + path = tmp_path / "bad.snap" + write_snapshot(ss, path) + text = path.read_text(encoding="utf-8") + (line,) = _acl_lines(text) + path.write_text(text.replace(line, "#@ACL: {not json"), encoding="utf-8") + + remove_entry(ss.session_id) + with pytest.raises(MemNetError) as exc: + load_snapshot(path, keep_id=True) + assert exc.value.code == "bad_snapshot" + assert get_entry(ss.session_id) is None # never loaded with ACL silently off + + +def _write_as_v1(ss, path) -> bytes: + """Leave a format 1 file at ``path`` (the same body, no ACL line, @SNAP version 1).""" + write_snapshot(ss, path) + v1 = _as_format_1(path.read_text(encoding="utf-8")).encode("utf-8") + path.write_bytes(v1) + return v1 + + +def test_v1_file_is_backed_up_once_before_first_v2_write(memnet_temp, schema_file, tmp_path): + ss = open_session(map_file=str(schema_file)) + _grants(ss) + path = tmp_path / "old.snap" + bak = tmp_path / "old.snap.v1.bak" + v1_bytes = _write_as_v1(ss, path) + path.chmod(0o644) + + write_snapshot(ss, path) # first v2 write over a v1 file + + assert bak.read_bytes() == v1_bytes + assert stat.S_IMODE(bak.stat().st_mode) == 0o600 + assert "\n@SNAP: 2|" in path.read_text(encoding="utf-8") # no dual write: the file is v2 + assert list(tmp_path.glob("*.tmp")) == [] + + # A later v1 file at the same path must not replace the first backup. + ss.grant_caller("late", can_pin_map=True, can_mutate=False) + _write_as_v1(ss, path) + write_snapshot(ss, path) + assert bak.read_bytes() == v1_bytes + + +def test_v2_over_v2_and_first_write_create_no_backup(memnet_temp, schema_file, tmp_path): + ss = open_session(map_file=str(schema_file)) + _grants(ss) + path = tmp_path / "new.snap" + write_snapshot(ss, path) # no file yet + assert not (tmp_path / "new.snap.v1.bak").exists() + write_snapshot(ss, path) # v2 over v2 + assert not (tmp_path / "new.snap.v1.bak").exists() + + +@pytest.mark.parametrize( + "bind", + [ + {"mission_id": "mission-demo"}, # no lease digest and no lease + "mission-demo", # not a dict + {"lease_sha256": "a" * 64}, # missing mission_id + ], +) +def test_malformed_bind_in_file_fails_closed_bad_snapshot(memnet_temp, schema_file, tmp_path, bind): + ss = open_session(map_file=str(schema_file)) + _grants(ss) + path = tmp_path / "bind.snap" + write_snapshot(ss, path) + acl = acl_to_dict(ss.acl) | {"bind": bind} + text = path.read_text(encoding="utf-8") + payload = json.dumps(acl, sort_keys=True, separators=(",", ":")) + lines = [f"#@ACL:{payload}" if ln.startswith("#@ACL:") else ln for ln in text.splitlines()] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + remove_entry(ss.session_id) + with pytest.raises(MemNetError) as exc: + load_snapshot(path, keep_id=True) + assert exc.value.code == "bad_snapshot" + assert get_entry(ss.session_id) is None # nothing restored: fail closed + + +def test_checkpoint_backs_up_v1_file_once(memnet_temp, schema_file, tmp_path, monkeypatch): + from memnet.checkpoint import run_checkpoint + + monkeypatch.setenv("MEMNET_ADMIN_TOKEN", "tok") + ss = open_session(map_file=str(schema_file)) + old = tmp_path / "checkpoint" / f"{ss.session_id}.snap" + write_snapshot(ss, old) + v1 = _as_format_1(old.read_text(encoding="utf-8")).encode("utf-8") + old.write_bytes(v1) + run_checkpoint("tok", directory=tmp_path) + assert (old.parent / f"{old.name}.v1.bak").read_bytes() == v1 diff --git a/tests/test_checkpoint.py b/tests/test_checkpoint.py index b0931957..8f478c01 100644 --- a/tests/test_checkpoint.py +++ b/tests/test_checkpoint.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import stat import threading from pathlib import Path @@ -119,7 +120,10 @@ def _run() -> None: assert get_entry(other.session_id).meta.expires_at == other_expires manifest = json.loads((tmp_path / "checkpoint" / MANIFEST_NAME).read_text(encoding="utf-8")) assert manifest["manifest_format"] == 1 - assert manifest["snapshot_format"] == 1 + assert manifest["snapshot_format"] == 2 + assert stat.S_IMODE((tmp_path / "checkpoint" / MANIFEST_NAME).stat().st_mode) == 0o600 + for row in manifest["sessions"]: + assert stat.S_IMODE((tmp_path / "checkpoint" / row["file"]).stat().st_mode) == 0o600 assert manifest["serve_version"] assert manifest["timestamp"].endswith("Z") assert manifest["unsaved"] == [] @@ -188,7 +192,7 @@ def test_atomic_write_never_leaves_a_partial_file(tmp_path, monkeypatch): def _boom(_fd: int) -> None: raise OSError("fsync failed") - monkeypatch.setattr("memnet.checkpoint._fsync_fd", _boom) + monkeypatch.setattr("memnet.atomic_file._fsync_fd", _boom) with pytest.raises(OSError): atomic_write_bytes(dest, b"PARTIAL-NEW-BYTES") assert dest.read_bytes() == original diff --git a/tests/test_doc_gate_readiness.py b/tests/test_doc_gate_readiness.py index d485567b..76c81f1f 100644 --- a/tests/test_doc_gate_readiness.py +++ b/tests/test_doc_gate_readiness.py @@ -86,7 +86,9 @@ def test_canonical_snapshot_drops_snap_meta_and_sids(tmp_path: Path): def test_write_snapshot_is_not_write_once(): report = snapshot_write_once_report() - assert report["write_snapshot_uses_path_write_text"] is True + assert report["write_snapshot_uses_path_write_text"] is False + assert report["write_snapshot_uses_atomic_private_write"] is True + assert report["temp_file_mkstemp_0600"] is True assert report["engine_o_excl"] is False assert report["engine_chmod"] is False assert report["caller_or_filesystem_only"] is True diff --git a/tests/test_safe_upgrade.py b/tests/test_safe_upgrade.py index b4e12226..c15892fb 100644 --- a/tests/test_safe_upgrade.py +++ b/tests/test_safe_upgrade.py @@ -5,6 +5,7 @@ import json import os import socket +import stat import subprocess import sys import time @@ -12,6 +13,7 @@ import pytest +from memnet.acl import lease_digest from memnet.exceptions import MemNetError from memnet.in_process_engine import run_argv from memnet.registry import get_entry @@ -96,7 +98,7 @@ def test_round_trip_keeps_id_acl_ttl_and_near_cap(memnet_temp, monkeypatch, tmp_ assert acl_back.acl is not None and acl_back.acl.enabled is True assert acl_back.acl.bind is not None assert acl_back.acl.bind.mission_id == "mission-demo" - assert acl_back.acl.bind.lease == "lease-demo" + assert acl_back.acl.bind.lease_sha256 == lease_digest("lease-demo") grant = acl_back.acl.callers["caller-demo"] assert grant.can_pin_map is True assert grant.can_mutate is False @@ -105,6 +107,12 @@ def test_round_trip_keeps_id_acl_ttl_and_near_cap(memnet_temp, monkeypatch, tmp_ assert wide_back.store.row_count_non_law() == wide_rows snaps = list((tmp_path / "upgrade-snapshots").glob("*.snap")) assert len(snaps) == 3 + assert stat.S_IMODE((tmp_path / MANIFEST_NAME).stat().st_mode) == 0o600 + for snap in snaps: + assert stat.S_IMODE(snap.stat().st_mode) == 0o600 + for written in tmp_path.rglob("*"): + if written.is_file(): + assert b"lease-demo" not in written.read_bytes() # the digest only, never the lease def test_unsaveable_blocks_ready_unless_override(memnet_temp, monkeypatch, tmp_path, schema_file): @@ -454,3 +462,41 @@ def test_upgrade_doc_has_no_session_id(): assert "MEMNET_UPGRADE_RETRY_S" not in text assert "upgrade-prepare" in text assert "ready_to_stop" in text or "ready-to-stop" in text + + +def test_rollback_restore_rewrites_original_bytes_atomically( + memnet_temp, monkeypatch, tmp_path, schema_file +): + import memnet.upgrade as upgrade_mod + + monkeypatch.setenv("MEMNET_ADMIN_TOKEN", TOKEN) + ss = open_session(map_file=str(schema_file)) + _mutate(ss.session_id, "CREATE (:TSK {id: 'TSK_r', goal: 'r', status: 'open'})\n") + assert prepare_upgrade(TOKEN, directory=tmp_path).ready_to_stop is True + snap = next((tmp_path / "upgrade-snapshots").glob("*.snap")) + original = snap.read_bytes() + reset_registry() + reset_drain_gate() + + writes: list[Path] = [] + real_write = upgrade_mod.atomic_write_bytes + + def spy_write(path, data): + writes.append(Path(path)) + real_write(path, data) + + def tamper_then_fail(text, **kwargs): + # A loose-mode file changes under the restore; the restore must put back the bytes. + snap.write_bytes(b"tampered") + snap.chmod(0o644) + raise MemNetError("bad_snapshot", "forced after tamper") + + monkeypatch.setattr(upgrade_mod, "atomic_write_bytes", spy_write) + monkeypatch.setattr(upgrade_mod, "load_snapshot_text", tamper_then_fail) + report = restore_manifest(tmp_path) + + assert report.failed == 1 + assert snap in writes # the rollback put the bytes back through the atomic writer + assert snap.read_bytes() == original + assert stat.S_IMODE(snap.stat().st_mode) == 0o600 + assert list(snap.parent.glob("*.tmp")) == [] diff --git a/tests/test_sysml_acl_snapshot.py b/tests/test_sysml_acl_snapshot.py new file mode 100644 index 00000000..ea0e7556 --- /dev/null +++ b/tests/test_sysml_acl_snapshot.py @@ -0,0 +1,80 @@ +"""MN-REQ-06.16 session ACL in snapshots is modelled in SysML before and beside the code.""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MODELS = ROOT / "sysml-models" / "models" +DEPLOY = MODELS / "deploy.sysml" +REQUIREMENTS = MODELS / "requirements.sysml" +VERIFY = MODELS / "verify.sysml" +IMPLEMENTATION = MODELS / "implementation.sysml" +MAP = ROOT / "sysml-models" / "outputs" / "ssot-to-code-allocate-map.md" + + +def test_requirement_and_index(): + req = REQUIREMENTS.read_text(encoding="utf-8") + assert "requirement def MN_REQ_06_16_SnapshotAcl" in req + assert 'attribute requirementId : String = "MN-REQ-06.16"' in req + assert "MN-REQ-06.16 Session snapshot carries the ACL" in req + assert "requirement snapshotAclReq" in req + assert "MN_REQ_06_16_SnapshotAcl;" in req + + +def test_deploy_attributes(): + text = DEPLOY.read_text(encoding="utf-8") + assert "part def SnapshotAcl" in text + assert "part snapshotAcl : SnapshotAcl" in text + assert 'attribute aclLineCommentPrefix : String = "#@ACL:"' in text + assert "attribute snapshotFormatVersion : Integer = 2" in text + assert "attribute v2MissingAclFailsBadSnapshot : Boolean = true" in text + assert "attribute bindStoresLeaseDigestOnly : Boolean = true" in text + assert 'attribute snapshotFileMode : String = "0600"' in text + assert "oldReaderSkipsAclLine" not in text + assert 'attribute aclAbsentStat : String = "@ACL: absent|off"' in text + assert "attribute v1BackupBeforeFirstV2Write : Boolean = true" in text + assert 'attribute v1BackupName : String = ".v1.bak"' in text + assert 'attribute v1BackupMode : String = "0600"' in text + assert "attribute dualWriteV1 : Boolean = false" in text + assert "attribute rollbackLosesPostUpgradeWrites : Boolean = true" in text + + +def test_verify_case_names_real_tests(): + ver = VERIFY.read_text(encoding="utf-8") + assert "verification def MN_VER_06_S14_SnapshotAcl" in ver + assert 'attribute verificationId : String = "MN-VER-06-S14"' in ver + assert "verify snapshotAclReq" in ver + assert "snap.snapshotAcl.aclMalformedFailsClosed == true" in ver + assert "snap.snapshotAcl.v2MissingAclFailsBadSnapshot == true" in ver + assert 'snap.snapshotAcl.snapshotFileMode == "0600"' in ver + assert "snap.snapshotAcl.dualWriteV1 == false" in ver + assert 'snap.snapshotAcl.v1BackupMode == "0600"' in ver + assert "verification snapshotAclVerify" in ver + assert "tests/test_acl_snapshot.py" in ver + assert "::test_format_2_missing_acl_line_fails_bad_snapshot" in ver + assert "::test_snapshot_files_are_mode_0600_and_replace_loose_modes" in ver + rollback_test = "test_rollback_restore_rewrites_original_bytes_atomically" + assert f"tests/test_safe_upgrade.py::{rollback_test}" in ver + # Every test name the verify case cites must exist in its file. + current = "" + for file_part, name in re.findall(r"(tests/[\w/]+\.py)|::(test_\w+)", ver): + if file_part: + current = file_part + elif current: + src = (ROOT / current).read_text(encoding="utf-8") + assert f"def {name}(" in src, f"{current}::{name} is not defined" + + +def test_implementation_satisfy_and_allocation(): + impl = IMPLEMENTATION.read_text(encoding="utf-8") + assert "part def SnapshotAclMod" in impl + assert ( + "satisfy MN_REQ_00_MissionBridge::MN_REQ_06_ProcessBoundary::MN_REQ_06_16_SnapshotAcl;" + in impl + ) + assert "allocation snapshotAclToMod" in impl + assert "memnet.snapshot" in impl + ledger = MAP.read_text(encoding="utf-8") + assert "| snapshotAclToMod |" in ledger