From c1be7571973c3d0b671ed4db2da41266ae3099d8 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 3 Sep 2026 15:20:40 +0200 Subject: refactor!: one file_chunk and index_sync encoder for every transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `file_chunk` and `index_sync` were each built twice, once per transport, and the two copies did not agree. WebRTC sent binary, unsigned chunks carrying a `file_id`; QUIC sent base64 fields, two BLAKE3 hashes, a per-chunk Ed25519 signature and no `file_id`. `index_sync` was plain entries on one transport and a `GroupIndex.serialize()` envelope on the other. One message type, two shapes, one consumer each, and nothing that failed when they drifted — finding C6 one size down, in the two places the handshake unification did not reach. Phase 9.15 moved WebRTC to the binary format and dropped the per-chunk signature; the QUIC encoder was never brought along. It is dropped here rather than reintroduced: the AES-GCM tag authenticates the ciphertext under a GEK-derived key, and since C3 the node authenticates itself once in the handshake instead of once per megabyte. `meshbay_common.protocol` now owns the chunk codec (`chunk_ciphertext`, `file_chunk_wire`, `file_chunk_plaintext`) and `meshbay_node/transport/wire.py` the index builder, which also absorbs the delta the daemon used to hand-build. `test_transport_wire_parity.py` fails if either server grows its own copy back. `ChunkRequest`/`ChunkResponse` are deleted. `ChunkResponse` described the QUIC half while reading like the contract for both, which is what made the fork hard to see at all. BREAKING CHANGE: MNP 0.15 changes the encoding of `file_chunk` and `index_sync` on the QUIC transport. The WebRTC shapes are byte for byte unchanged and no QUIC client ships, which is why this is a MINOR bump; a deployed QUIC peer would have made it MAJOR. Also fixes a test fixture that put a `Path` where the daemon puts a `RootSet`. Nothing caught it: the old QUIC index handler never touched `roots`, and `entry_abs_path` fell through `Path.resolve(strict=...)`, reading the virtual path as a truthy flag and returning the right file by accident. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3 --- .../src/meshbay_node/transport/wire.py | 76 ++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 packages/meshbay-node/src/meshbay_node/transport/wire.py (limited to 'packages/meshbay-node/src/meshbay_node/transport/wire.py') diff --git a/packages/meshbay-node/src/meshbay_node/transport/wire.py b/packages/meshbay-node/src/meshbay_node/transport/wire.py new file mode 100644 index 0000000..4e09167 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/transport/wire.py @@ -0,0 +1,76 @@ +""" +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. +""" + +from __future__ import annotations + +from meshbay_common import MNP_VERSION +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 here 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". + """ + return { + "type": MNP.INDEX_SYNC, + "v": MNP_VERSION, + "group_id": index.group_id, + "version": index.version, + "entries": [index_entry_wire(e) for e in index.entries], + "dirs": list_dirs(roots), + "roots": roots.describe() if roots else [], + } -- cgit v1.2.3