diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-03 15:20:40 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-03 15:20:40 +0200 |
| commit | c1be7571973c3d0b671ed4db2da41266ae3099d8 (patch) | |
| tree | d2b98bf33727c5905669c9c3f40edba30db11ac3 /packages/meshbay-node/src/meshbay_node/transport/wire.py | |
| parent | 691c6ba4ef51085c89aeddbcabd5c733861eb56b (diff) | |
| download | meshbay-c1be7571973c3d0b671ed4db2da41266ae3099d8.tar.gz | |
refactor!: one file_chunk and index_sync encoder for every transport
`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/wire.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/wire.py | 76 |
1 files changed, 76 insertions, 0 deletions
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 [], + } |