diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-15 12:19:22 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-15 12:19:22 +0200 |
| commit | 8cd7e467ebec987f66c4fe93a8d87dfbc57304d2 (patch) | |
| tree | 0ebd406d893b49ebdf9290ea1a4ac3474d01b7bd /packages/meshbay-node/tests | |
| parent | 0503682c0e2add135b88c2a1fadfe07455680a71 (diff) | |
| download | meshbay-8cd7e467ebec987f66c4fe93a8d87dfbc57304d2.tar.gz | |
feat(files): download a folder as a zip, and remove an empty one
Two things a Files panel needs and did not have.
**Removing a directory** is privileged, where creating one is not: it
acts on a name other members are using, on the operator's disk. It is
refused unless the directory is empty, and that rule is the safety
property — whatever the browser sends, this cannot destroy content. The
check runs twice, once before the challenge and once after the signature
comes back, because a file can land during the round trip. A file also
accepts its uploader's key; a directory has no uploader, so only the
operator's key will do.
**Downloading a folder** produces a zip built in the browser, written
straight to disk as the chunks arrive. An archive of a group folder is
routinely tens of gigabytes, so nothing is held: peak memory is one chunk
plus a small record per file. The node is not involved at all — it serves
the same encrypted chunks as any other download, holds no temporary
files, and cannot be asked to compress anything.
zipstream.js is store-only. Group content is video and images, already
compressed, so deflate would spend CPU on every byte to save nothing, in
the thread that is also decrypting. Sizes and CRCs go in a data
descriptor after each file because a stream cannot seek back to patch a
header, and zip64 kicks in per entry past 4 GiB and for the archive
itself. Because none of that can be checked from the Python side of the
house, test_zipstream.py runs the real module under Node and reads what
it produces with zipfile — CRCs, UTF-8 names, zip64 records and all. The
archives also pass `unzip -t`.
Firefox and Safari have no File System Access API, so there is nowhere to
stream to: the fallback builds the archive in memory and says so, with
the size, before starting rather than after failing.
One mistake worth recording: the first version of deleteDirectory passed
the node's own answer as the value to check the challenge against, which
turns the comparison into a tautology. It checks the path we asked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_roster_pairing.py | 109 |
1 files changed, 109 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index a5a48e4..88e426d 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -805,3 +805,112 @@ async def test_each_group_gets_its_own_key(tmp_path, roster): assert await roster.is_authorized("group-a", "member") is True assert await roster.is_authorized("group-b", "member") is False, ( "membership of one group must not admit anyone to another") + + +# ── Removing a directory ───────────────────────────────────────────────────── + +async def _dir_session(tmp_path, roster): + """A session with a shared root and an operator paired, ready for admin ops.""" + session = _session(tmp_path, roster, group_id="g1", gek=generate_gek()) + session._admin_ops = {} + session._ctx["has_admin_authority"] = True + return session + + +async def test_a_directory_with_anything_in_it_is_refused(tmp_path, roster): + session = await _dir_session(tmp_path, roster) + full = tmp_path / "shared" / "full" + full.mkdir() + (full / "keep.txt").write_text("still here") + + session._do_dir_delete({"dir": "full"}) + + assert _last(session).get("detail") == "Directory is not empty" + assert full.exists() and (full / "keep.txt").exists() + + +async def test_no_challenge_is_issued_without_an_operator(tmp_path, roster): + """Fails closed, and says so, rather than asking for a signature nobody can give.""" + session = await _dir_session(tmp_path, roster) + session._ctx["has_admin_authority"] = False + (tmp_path / "shared" / "empty").mkdir() + + session._do_dir_delete({"dir": "empty"}) + + assert _last(session).get("detail") == "No authorized key for deletion" + assert (tmp_path / "shared" / "empty").exists() + + +async def test_the_shared_root_itself_is_not_a_target(tmp_path, roster): + session = await _dir_session(tmp_path, roster) + for attempt in ("", ".", "/", "../shared"): + session._do_dir_delete({"dir": attempt}) + assert _last(session).get("type") == "error", f"{attempt!r} was accepted" + assert (tmp_path / "shared").is_dir() + + +async def test_escaping_the_shared_root_is_refused(tmp_path, roster): + session = await _dir_session(tmp_path, roster) + outside = tmp_path / "outside" + outside.mkdir() + + for attempt in ("../outside", "../../outside", "sub/../../outside"): + session._do_dir_delete({"dir": attempt}) + assert _last(session).get("type") == "error", f"{attempt!r} was accepted" + assert outside.is_dir(), "a path leaving the shared root removed a directory" + + +async def test_an_empty_directory_needs_a_signature_and_then_goes(tmp_path, roster): + """The whole round trip: challenge, operator signature, removal.""" + from meshbay_common.adminop import admin_transcript + + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code") + await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli") + + session = await _dir_session(tmp_path, roster) + (tmp_path / "shared" / "gone").mkdir() + + session._do_dir_delete({"dir": "gone"}) + challenge = _last(session) + assert challenge["type"] == "admin_challenge" + assert challenge["op"] == "dir_delete" + assert challenge["subject"] == "gone" + + transcript = admin_transcript( + op="dir_delete", node_pk_b64=session._node_pk_b64(), group_id="g1", + subject="gone", nonce=base64.b64decode(challenge["nonce"]), + ts=challenge["ts"]) + await session._admin_exec_dir_delete( + session._admin_ops.pop(challenge["op_id"]) if session._admin_ops + else {"op": "dir_delete", "subject": "gone"}, + transcript, sk_ed.sign(transcript)) + + assert _last(session)["type"] == "dir_delete_ack" + assert not (tmp_path / "shared" / "gone").exists() + + +async def test_someone_elses_signature_does_not_remove_it(tmp_path, roster): + from meshbay_common.adminop import admin_transcript + + sk_op, pk_op, pk_x = _keypair() + await roster.pin_identity("grenet", "grenet", pk_op, pk_x, "code") + await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli") + + sk_member, pk_member, pk_x_m = _keypair() + await roster.pin_identity("mallory", "mallory", pk_member, pk_x_m, "code") + await roster.set_member("g1", "mallory", ROLE_MEMBER, "active", "grenet") + + session = await _dir_session(tmp_path, roster) + (tmp_path / "shared" / "theirs").mkdir() + + transcript = admin_transcript( + op="dir_delete", node_pk_b64=session._node_pk_b64(), group_id="g1", + subject="theirs", nonce=b"\x22" * 32, ts=int(time.time())) + await session._admin_exec_dir_delete( + {"op": "dir_delete", "subject": "theirs"}, + transcript, sk_member.sign(transcript)) + + assert _last(session).get("detail") == "Signature verification failed" + assert (tmp_path / "shared" / "theirs").is_dir(), ( + "a member's signature removed a directory — only the operator may") |