""" Wire shapes shared by every node transport. `file_chunk` lives in `meshbay_common.protocol` — it is pure crypto and shape, so a client can use the same encoder. This module is for the messages that also need the node's own view of its disk, which `meshbay-common` cannot see. Why it exists at all: `index_sync` was built twice, and the two copies did not agree. WebRTC sent `{group_id, version, entries, dirs, roots}` — the shape the shipping client reads — while QUIC sent `{index_b64}`, a signed, compressed, GEK-encrypted envelope produced by `GroupIndex.serialize()`. Same message type, two encodings, one consumer each and nothing asserting they matched. Same failure mode as the two `file_chunk` encoders, and the same fix: one builder, used by both. `GroupIndex.serialize()`/`deserialize()` are unchanged and still tested — they remain a correct signed index envelope — but they no longer describe any MNP message. Read them as an at-rest/interchange format, not as a wire contract. It is also not a candidate for reuse below: it compresses with zstd, which no browser can decompress (`DecompressionStream` offers gzip and deflate only). Since MNP 1.0 both messages carry their payload **sealed under a GEK-derived subkey** (`meshbay_common.groupbox`). Only the routing fields — `type`, `v`, `group_id` — stay in clear: a receiver must route and version-check before it can decrypt, and `group_id` is the AAD and selects the key besides. `version`/`base_version` moved *inside* the payload; there is no reason to act on a version number carried by a message we have not yet authenticated. """ from __future__ import annotations from meshbay_common import MNP_VERSION from meshbay_common.groupbox import PURPOSE_INDEX, seal from meshbay_common.protocol import MNP, index_entry_wire from meshbay_node.roots import RootSet # A group with a deep tree can hold more directories than anyone will navigate in one # sitting, and the whole list rides on one message. MAX_DIRS = 2000 def list_dirs(roots: RootSet | None) -> list[str]: """ Every directory in the group, as members address them, sorted. Each root appears as a directory in its own right, so a root holding no files yet is still somewhere a member can navigate to and upload into. An unavailable root is listed too — its content is frozen, not gone, and hiding it would look exactly like deletion. """ if not roots: return [] out: list[str] = [] for root in roots: out.append(root.name) if not root.available: continue try: for path in sorted(root.path.rglob("*")): if path.is_dir() and not path.name.startswith("."): rel = path.relative_to(root.path) if not any(part.startswith(".") for part in rel.parts): out.append(f"{root.name}/{rel.as_posix()}") except OSError: continue return sorted(out)[:MAX_DIRS] def index_sync_message(index, roots: RootSet | None) -> dict: """ The full `index_sync` message for one group. `dirs` and `roots` are in the payload because directories are not index entries: without them a folder someone just created, or one they emptied, does not exist as far as a client is concerned, and a member cannot tell "the drive is unplugged" from "it is all still there". """ payload = { "version": index.version, "entries": [index_entry_wire(e) for e in index.entries], "dirs": list_dirs(roots), "roots": roots.describe() if roots else [], } return { "type": MNP.INDEX_SYNC, "v": MNP_VERSION, "group_id": index.group_id, **seal(index.gek, PURPOSE_INDEX, MNP.INDEX_SYNC, index.group_id, payload), } def index_delta_message(index, delta) -> dict: """ One `index_delta` — what changed since the last thing this node broadcast. Built here rather than inline in the daemon, which is where it lived and which made it the third place an index message was constructed: precisely the drift that produced two `index_sync` encodings and two `file_chunk` encodings before it. """ payload = { "base_version": delta.base_version, "version": delta.version, "additions": [index_entry_wire(e) for e in delta.additions], "deletions": list(delta.deletions), "updates": [index_entry_wire(e) for e in delta.updates], } return { "type": MNP.INDEX_DELTA, "v": MNP_VERSION, "group_id": index.group_id, **seal(index.gek, PURPOSE_INDEX, MNP.INDEX_DELTA, index.group_id, payload), }