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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
|
"""
Downloads and uploads: one transfer lease each, then the chunks.
**The unit is the lease** (docs/MESHBAY_DESIGN.md §5.5): the node's record that
this connection is transferring something, opened before the first chunk and
closed after the last, on every path out. A lease may be granted at once or
queued behind the member's other transfers; the grant then arrives as a push.
Downloads are 1 MiB chunks, each encrypted under its own key derived from the
group key and the file's hash, and each independently decryptable — which is
what makes resuming a download cost nothing in the protocol. Uploads are 48 KB
chunks sealed under the group key, name and folder inside the seal; the node
keeps its own position, and a probe chunk asks for it, which is what resumes
an upload.
"""
import asyncio
import contextlib
import json
import logging
import math
import os
import secrets
import sys
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from meshbay_common import MNP_VERSION
from meshbay_common.groupbox import PURPOSE_UPLOAD, unseal
from meshbay_common.paths import long_path
from meshbay_common.protocol import UPLOAD_PROBE_INDEX, file_upload_wire
from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes
from ..errors import NODE, CliError
from .session import GroupSession, NodeRefusal
log = logging.getLogger(__name__)
DOWNLOAD_CHUNK = 1024 * 1024 # the node serves files in 1 MiB chunks
UPLOAD_CHUNK = 48 * 1024 # what fits one DataChannel message after sealing
DOWNLOAD_WINDOW = 8 # chunk requests in flight per file
UPLOAD_WINDOW = 32 # chunks sent ahead of the node's acks
CHUNK_TIMEOUT = 60
# A queued lease waits behind the member's own transfers and other members';
# a queue that has said nothing for this long is not moving.
QUEUE_SILENCE = 600
PART_SUFFIX = ".meshbay-part"
Progress = Callable[[int], None] # bytes just completed
class TransferError(CliError):
"""One file failed; the run goes on unless `fatal`."""
def __init__(self, message: str, *, fatal: bool = False, code: str = ""):
super().__init__(NODE, message)
self.fatal = fatal
self.refusal_code = code
# ── Leases ───────────────────────────────────────────────────────────────────
class Lease:
def __init__(self, session: GroupSession, kind: str):
self.session = session
self.kind = kind
self.tr = secrets.token_hex(16)
self.closed_by_node = ""
self._state: asyncio.Queue = asyncio.Queue()
def _on_state(self, msg: dict) -> None:
if msg.get("tr") != self.tr:
return
if msg.get("state") == "closed":
self.closed_by_node = str(msg.get("reason") or "closed by the node")
self._state.put_nowait(msg)
async def open(self, size: int, chunks: int,
on_queued: Callable[[int], None] | None = None) -> None:
ch = self.session.ch
ch.on_push("transfer_state", self._on_state)
reply = await ch.request({"type": "transfer_open", "v": MNP_VERSION, "tr": self.tr,
"kind": self.kind, "bytes": size, "chunks": chunks},
"transfer_state")
state = reply.get("state")
while state == "queued":
if on_queued:
on_queued(int(reply.get("ahead") or 0))
try:
reply = await asyncio.wait_for(self._state.get(), QUEUE_SILENCE)
except TimeoutError:
raise TransferError("The node's transfer queue did not move for "
f"{QUEUE_SILENCE // 60} minutes.") from None
state = reply.get("state")
if state != "granted":
raise TransferError(f"The node did not grant the transfer "
f"({reply.get('reason') or state}).")
def check(self) -> None:
if self.closed_by_node:
raise TransferError(f"The node ended the transfer ({self.closed_by_node}).")
async def close(self, reason: str) -> None:
ch = self.session.ch
ch.off_push("transfer_state", self._on_state)
if self.closed_by_node or ch.closed.is_set():
return
with contextlib.suppress(CliError):
await ch.request({"type": "transfer_close", "v": MNP_VERSION, "tr": self.tr,
"reason": reason}, "transfer_state", timeout=10)
# ── Download ─────────────────────────────────────────────────────────────────
@dataclass
class Resume:
"""What a `.meshbay-part` beside the target already holds, if it is this file."""
part: Path
sidecar: Path
chunks_done: int
@classmethod
def find(cls, target: Path, entry: dict) -> "Resume":
part = target.with_name(target.name + PART_SUFFIX)
sidecar = target.with_name(target.name + PART_SUFFIX + ".json")
done = 0
try:
meta = json.loads(sidecar.read_text(encoding="utf-8"))
if meta.get("id") == entry["id"] and meta.get("size") == entry["size"]:
# Every chunk but the last is whole, so the complete ones are
# what the part's length says.
done = os.path.getsize(long_path(part)) // DOWNLOAD_CHUNK
except (OSError, ValueError):
done = 0
return cls(part, sidecar, done)
async def download(session: GroupSession, entry: dict, target: Path | None, *,
overwrite: bool = False, progress: Progress | None = None,
on_queued: Callable[[int], None] | None = None) -> int:
"""
Fetch `entry` into `target` (None: stdout). Returns the bytes written.
Written to `<name>.meshbay-part` and renamed at the end, so an interrupted run
never leaves a truncated file under the real name; the part is resumed from
its last whole chunk when the sidecar says it is this file.
"""
size = int(entry["size"])
chunks = max(1, math.ceil(size / DOWNLOAD_CHUNK))
file_hash = bytes.fromhex(entry["id"])
resume = None
if target is not None:
resume = Resume.find(target, entry)
start = min(resume.chunks_done, chunks)
await asyncio.to_thread(_prepare_part, resume, entry, start)
out = open(long_path(resume.part), "r+b")
out.seek(start * DOWNLOAD_CHUNK)
else:
start = 0
out = sys.stdout.buffer
if progress and start:
progress(min(size, start * DOWNLOAD_CHUNK))
lease = Lease(session, "download")
reason = "error"
try:
await lease.open(size, chunks, on_queued)
written = await _fetch_chunks(session, lease, entry["id"], file_hash, start, chunks,
out, progress, streaming=target is None)
if target is not None:
await asyncio.to_thread(out.flush)
await asyncio.to_thread(os.fsync, out.fileno())
total = start * DOWNLOAD_CHUNK + written
if total != size:
raise TransferError(f"The node sent {total} bytes of a {size}-byte file.")
reason = "done"
except asyncio.CancelledError:
reason = "cancelled"
raise
finally:
if target is not None:
out.close()
await lease.close(reason)
if target is not None:
await asyncio.to_thread(_publish, resume, target, overwrite)
return size if target is not None else written
def _prepare_part(resume: Resume, entry: dict, start: int) -> None:
part = long_path(resume.part)
if start == 0:
with open(part, "wb"):
pass
else:
os.truncate(part, start * DOWNLOAD_CHUNK)
resume.sidecar.write_text(json.dumps({"id": entry["id"], "size": entry["size"]}),
encoding="utf-8")
def _publish(resume: Resume, target: Path, overwrite: bool) -> None:
part, dest = long_path(resume.part), long_path(target)
if overwrite:
os.replace(part, dest)
else:
# A file that appeared meanwhile is not replaced: a hard link refuses
# an existing target, where a rename would silently take its place.
try:
os.link(part, dest)
os.unlink(part)
except FileExistsError:
raise TransferError(f"{target} appeared while downloading; the download "
f"is kept as {resume.part.name}.") from None
except OSError:
if os.path.exists(dest):
raise TransferError(f"{target} already exists.") from None
os.replace(part, dest)
with contextlib.suppress(OSError):
resume.sidecar.unlink()
if sys.platform == "win32":
mark_of_the_web(target)
async def _fetch_chunks(session: GroupSession, lease: Lease, file_id: str, file_hash: bytes,
start: int, chunks: int, out, progress: Progress | None,
*, streaming: bool) -> int:
"""Chunk requests `DOWNLOAD_WINDOW` ahead, written in order as they land."""
ch, gek = session.ch, session.gek
inflight: dict[int, asyncio.Task] = {}
nxt, written = start, 0
def ask(i: int) -> asyncio.Task:
return asyncio.ensure_future(ch.request(
{"type": "file_req", "v": MNP_VERSION, "file_id": file_id, "chunk_index": i,
"tr": lease.tr}, "file_chunk", timeout=CHUNK_TIMEOUT))
try:
for i in range(start, chunks):
while nxt < chunks and nxt - i < DOWNLOAD_WINDOW:
inflight[nxt] = ask(nxt)
nxt += 1
try:
msg = await inflight.pop(i)
except NodeRefusal as e:
raise TransferError(f"chunk {i}: {e.detail}", code=e.refusal_code) from e
except CliError as e:
raise TransferError(f"chunk {i}: {e.message}") from e
lease.check()
data = decrypt_chunk_aes(chunk_key_aes(gek, file_hash, i), msg["nonce"], msg["ct"])
if streaming:
out.write(data)
out.flush()
else:
await asyncio.to_thread(out.write, data)
written += len(data)
if progress:
progress(len(data))
finally:
for task in inflight.values():
task.cancel()
for task in inflight.values():
with contextlib.suppress(BaseException):
await task
return written
def mark_of_the_web(path: Path) -> None:
"""ZoneId 3, as a browser marks a download, so SmartScreen and Protected View
apply when it is opened. FAT and exFAT have no alternate streams: nothing to do."""
with contextlib.suppress(OSError):
with open(long_path(path) + ":Zone.Identifier", "w", encoding="ascii") as f:
f.write("[ZoneTransfer]\r\nZoneId=3\r\n")
# ── Upload ───────────────────────────────────────────────────────────────────
async def create_dir(session: GroupSession, parent: str, name: str) -> str:
"""A folder `name` inside `parent` (root/folders). Returns its path."""
try:
ack = await session.ch.request({"type": "dir_create", "v": MNP_VERSION,
"dir": parent, "name": name}, "dir_create_ack")
except NodeRefusal as e:
raise TransferError(f"{parent}/{name}: {e.detail}", code=e.refusal_code,
fatal=e.refusal_code in FATAL_UPLOAD_CODES) from e
return str(ack.get("dir") or f"{parent}/{name}")
# Refusals that will not get better for the next file: the run stops.
FATAL_UPLOAD_CODES = frozenset({"disk_full", "root_unavailable", "root_read_only",
"no_writable_root", "no_roots", "no_group_key"})
async def upload(session: GroupSession, read: Callable[[int, int], bytes], size: int,
filename: str, folder: str, *, progress: Progress | None = None,
on_queued: Callable[[int], None] | None = None) -> str:
"""
Send `size` bytes, read by `read(offset, length)`, as `filename` in `folder`.
Returns the name the node stored it under — it never replaces a file, and
takes a free name when this one is taken by the time the upload ends.
"""
gek, gid, ch = session.gek, session.group_id, session.ch
chunks = max(1, math.ceil(size / UPLOAD_CHUNK))
upload_id = secrets.token_hex(16)
def chunk(i: int, data: bytes) -> dict:
return {**file_upload_wire(gek, gid, upload_id=upload_id, chunk_index=i,
total_chunks=chunks, filename=filename, data=data,
dir=folder), "tr": lease.tr}
def refused(e: NodeRefusal) -> TransferError:
return TransferError(f"{filename}: {e.detail}", code=e.refusal_code,
fatal=e.refusal_code in FATAL_UPLOAD_CODES)
lease = Lease(session, "upload")
reason = "error"
try:
await lease.open(size, chunks, on_queued)
# Where am I? Only the node knows how much of this file it already holds.
try:
probe = await ch.request(chunk(UPLOAD_PROBE_INDEX, b""), "file_upload_ack",
timeout=10)
start = int(_open_ack(gek, gid, probe).get("resume_from") or 0)
except NodeRefusal as e:
raise refused(e) from e
except CliError:
start = 0
if not 0 < start < chunks:
start = 0
if progress and start:
progress(min(size, start * UPLOAD_CHUNK))
pending: list[tuple[int, asyncio.Task]] = []
last: dict = {}
try:
for i in range(start, chunks):
while len(pending) >= UPLOAD_WINDOW:
last = await _ack(pending.pop(0), refused, progress, size)
lease.check()
data = await asyncio.to_thread(read, i * UPLOAD_CHUNK, UPLOAD_CHUNK)
pending.append((i, asyncio.ensure_future(
ch.request(chunk(i, data), "file_upload_ack", timeout=CHUNK_TIMEOUT))))
while pending:
last = await _ack(pending.pop(0), refused, progress, size)
finally:
for _, task in pending:
task.cancel()
for _, task in pending:
with contextlib.suppress(BaseException):
await task
reason = "done"
except asyncio.CancelledError:
reason = "cancelled"
raise
finally:
await lease.close(reason)
return str(_open_ack(gek, gid, last).get("stored_as") or filename)
async def _ack(item: tuple[int, asyncio.Task], refused, progress: Progress | None,
size: int) -> dict:
i, task = item
try:
msg = await task
except NodeRefusal as e:
raise refused(e) from e
except CliError as e:
raise TransferError(f"chunk {i}: {e.message}") from e
if progress:
progress(min(UPLOAD_CHUNK, size - i * UPLOAD_CHUNK))
return msg
def _open_ack(gek: bytes, group_id: str, msg: dict) -> dict:
try:
return unseal(gek, PURPOSE_UPLOAD, "file_upload_ack", group_id, msg)
except Exception as e:
raise TransferError("The node's acknowledgement does not open under the group "
"key.", fatal=True) from e
|