1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
|
"""
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, roots=None) -> 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.
`roots` rides along (MNP 1.1, additive — a 1.0 client ignores it). It used
to travel on `index_sync` alone, which is a *full* index and therefore only
ever sent on request. So a root added, removed, ejected or plugged left
every connected client's directory table stale until somebody reloaded the
page: the delta that told them something had changed was the one message
that could not say what. It is a handful of dicts, bounded by the number of
directories a group has, and it is sealed with the rest.
"""
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],
}
if roots is not None:
payload["roots"] = roots.describe()
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),
}
|