From 422647e7898b8df67cd11f8fb060aec23dec84aa Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 14 Oct 2026 22:20:15 +0200 Subject: feat(cli): meshbay-cli, a command-line client for members Signs in to the hub, lists the account's groups and their folders, and downloads and uploads files directly with each group's node, end-to-end encrypted. With no argument it opens a two-pane interactive mode (Textual). Session and device keys are kept encrypted under the system's key storage, or a 0600 key file where there is none; node keys are pinned at first contact; a new machine links as a device. Packaged on its own on Linux (deb and rpm, on meshbay-common's venv, with a man page generated from the parser) and frozen next to meshbay-node in the full Windows installer, not the light one, with a self-test the build runs. client/keys.py ports keyderive.js and is LGPL like it. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-cli/tests/test_cli_transfers.py | 206 +++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 packages/meshbay-cli/tests/test_cli_transfers.py (limited to 'packages/meshbay-cli/tests/test_cli_transfers.py') diff --git a/packages/meshbay-cli/tests/test_cli_transfers.py b/packages/meshbay-cli/tests/test_cli_transfers.py new file mode 100644 index 0000000..c240896 --- /dev/null +++ b/packages/meshbay-cli/tests/test_cli_transfers.py @@ -0,0 +1,206 @@ +"""Downloads and uploads against a node in memory (`fake_node.py`).""" + +import io +import json +import secrets + +import pytest +from fake_node import CHUNK, FakeNode +from meshbay_cli.client.names import UnsafeName, local_path, upload_name_problem +from meshbay_cli.client.transfers import ( + PART_SUFFIX, + UPLOAD_CHUNK, + TransferError, + create_dir, + download, + upload, +) + + +def _reader(data: bytes): + buf = io.BytesIO(data) + + def read(offset, length): + buf.seek(offset) + return buf.read(length) + return read + + +# ── Download ───────────────────────────────────────────────────────────────── + +async def test_a_file_of_several_chunks_arrives_whole(tmp_path): + node = FakeNode() + data = secrets.token_bytes(2 * CHUNK + 12345) + entry = node.add("share", "big.bin", data) + target = tmp_path / "big.bin" + seen = [] + assert await download(node.session(), entry, target, progress=seen.append) == len(data) + assert target.read_bytes() == data + assert sum(seen) == len(data) + assert not list(tmp_path.glob(f"*{PART_SUFFIX}*")) + assert node.leases == {}, "the lease is closed at the end" + + +async def test_an_interrupted_download_resumes_from_its_last_whole_chunk(tmp_path): + node = FakeNode() + data = secrets.token_bytes(3 * CHUNK + 7) + entry = node.add("share", "big.bin", data) + target = tmp_path / "big.bin" + part = tmp_path / f"big.bin{PART_SUFFIX}" + # A chunk and a half on disk: only the whole chunk counts. + part.write_bytes(data[:CHUNK + CHUNK // 2]) + (tmp_path / f"big.bin{PART_SUFFIX}.json").write_text( + json.dumps({"id": entry["id"], "size": entry["size"]})) + await download(node.session(), entry, target) + assert target.read_bytes() == data + assert node.chunk_requests == 3 + + +async def test_a_part_of_another_file_is_started_over(tmp_path): + node = FakeNode() + data = secrets.token_bytes(2 * CHUNK) + entry = node.add("share", "big.bin", data) + (tmp_path / f"big.bin{PART_SUFFIX}").write_bytes(b"x" * CHUNK) + (tmp_path / f"big.bin{PART_SUFFIX}.json").write_text( + json.dumps({"id": "0" * 64, "size": entry["size"]})) + await download(node.session(), entry, tmp_path / "big.bin") + assert (tmp_path / "big.bin").read_bytes() == data + assert node.chunk_requests == 2 + + +async def test_a_refused_chunk_fails_the_file_and_keeps_the_part(tmp_path): + node = FakeNode() + entry = node.add("share", "big.bin", secrets.token_bytes(3 * CHUNK)) + node.fail_chunk = (entry["id"], 2) + with pytest.raises(TransferError) as e: + await download(node.session(), entry, tmp_path / "big.bin") + assert "chunk 2" in e.value.message and "stopped answering" in e.value.message + assert not (tmp_path / "big.bin").exists() + assert (tmp_path / f"big.bin{PART_SUFFIX}").stat().st_size == 2 * CHUNK + assert node.leases == {} + + +async def test_a_file_that_appeared_meanwhile_is_not_replaced(tmp_path): + node = FakeNode() + entry = node.add("share", "a.txt", b"from the group") + (tmp_path / "a.txt").write_bytes(b"mine") + with pytest.raises(TransferError): + await download(node.session(), entry, tmp_path / "a.txt") + assert (tmp_path / "a.txt").read_bytes() == b"mine" + await download(node.session(), entry, tmp_path / "a.txt", overwrite=True) + assert (tmp_path / "a.txt").read_bytes() == b"from the group" + + +async def test_a_queued_lease_waits_for_its_grant(tmp_path): + node = FakeNode() + node.queue_first = 1 + entry = node.add("share", "a.txt", b"hello") + queued = [] + await download(node.session(), entry, tmp_path / "a.txt", on_queued=queued.append) + assert queued == [1] and (tmp_path / "a.txt").read_bytes() == b"hello" + + +async def test_an_interrupted_download_keeps_its_part_and_closes_its_lease(tmp_path): + import asyncio + node = FakeNode() + node.chunk_delay = 0.05 + data = secrets.token_bytes(20 * CHUNK) + entry = node.add("share", "big.bin", data) + task = asyncio.ensure_future(download(node.session(), entry, tmp_path / "big.bin")) + while node.chunk_requests < 12: + await asyncio.sleep(0.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert node.leases == {} + assert not (tmp_path / "big.bin").exists() + kept = (tmp_path / f"big.bin{PART_SUFFIX}").stat().st_size + assert kept > 0 and kept % CHUNK == 0 + node.chunk_delay = 0 + await download(node.session(), entry, tmp_path / "big.bin") + assert (tmp_path / "big.bin").read_bytes() == data + + +# ── Upload ─────────────────────────────────────────────────────────────────── + +async def test_an_upload_of_several_chunks_is_stored_whole(): + node = FakeNode() + data = secrets.token_bytes(5 * UPLOAD_CHUNK + 99) + stored = await upload(node.session(), _reader(data), len(data), "scan.pdf", "share") + assert stored == "scan.pdf" + assert node.stored("share/scan.pdf") == data + assert node.leases == {} + + +async def test_an_interrupted_upload_resumes_where_the_node_stopped(): + node = FakeNode() + data = secrets.token_bytes(4 * UPLOAD_CHUNK) + node.uploads[("share", "scan.pdf")] = {"next": 2, "data": data[:2 * UPLOAD_CHUNK]} + sent = [] + await upload(node.session(), _reader(data), len(data), "scan.pdf", "share", + progress=sent.append) + assert node.stored("share/scan.pdf") == data + assert sum(sent) == len(data) + + +async def test_a_name_taken_meanwhile_is_reported(): + node = FakeNode() + node.add("share", "scan.pdf", b"theirs") + stored = await upload(node.session(), _reader(b"mine"), 4, "scan.pdf", "share") + assert stored == "scan (1).pdf" + assert node.stored("share/scan.pdf") == b"theirs" + + +async def test_a_full_disk_is_fatal(): + node = FakeNode() + node.refuse_upload_code = "disk_full" + with pytest.raises(TransferError) as e: + await upload(node.session(), _reader(b"x"), 1, "a.txt", "share") + assert e.value.fatal + + +async def test_folders_are_created_where_asked(): + node = FakeNode() + assert await create_dir(node.session(), "share", "Scans") == "share/Scans" + assert "share/Scans" in node.dirs + + +# ── Names ──────────────────────────────────────────────────────────────────── + +NAMES = [ + "photo.jpg", "Été 2024", "東京", "track (1).flac", "[live] set", "a_b-c", + "rock & roll", "l’été", "x+y#z@w", "9lives", + ".DS_Store", "_private", "-dash", " space", "trailing ", "trailing.", + "a/b", "a\\b", "semi;colon", "x" * 128, "x" * 129, "", "tab\tname", +] + + +def test_the_cli_refuses_exactly_the_names_the_node_refuses(): + roots = pytest.importorskip("meshbay_node.roots") + differ = [n for n in NAMES + if bool(roots.SAFE_UPLOAD_NAME.match(n)) != (upload_name_problem(n, is_dir=True) + is None)] + assert not differ, f"the CLI and the node disagree on: {differ}" + for n in ["desktop.ini", "x.LNK", "a.url", "b.scf", "c.library-ms", "d.searchConnector-ms"]: + assert roots.shell_active(n) and upload_name_problem(n), n + + +def test_a_downloaded_name_is_made_writable_and_stays_inside(tmp_path): + path, renamed = local_path(tmp_path, ["evil\x1b[31mred.txt"]) + assert renamed and path.parent == tmp_path and "\x1b" not in path.name + path, renamed = local_path(tmp_path, ["CON.txt"]) + assert renamed and path.name != "CON.txt" + path, renamed = local_path(tmp_path, ["été", "plage de l'ouest.jpg"]) + assert not renamed and path == tmp_path / "été" / "plage de l'ouest.jpg" + path, _ = local_path(tmp_path, [".."]) + assert path.parent == tmp_path + + +def test_a_link_out_of_the_destination_is_refused(tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + dest = tmp_path / "dest" + dest.mkdir() + (dest / "sub").symlink_to(outside) + with pytest.raises(UnsafeName): + local_path(dest, ["sub", "x.txt"]) -- cgit v1.2.3