summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_security_regressions.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 02:15:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 02:15:02 +0200
commite9d5e979fdab9a1cc3c729d602e6f27207b9480c (patch)
treeb5993f2c81b760ba56f251457edf84dd91ad63dc /packages/meshbay-node/tests/test_security_regressions.py
parent50ebb4f2e620dad8e1fbca8307b97c5e10e7e6c0 (diff)
downloadmeshbay-e9d5e979fdab9a1cc3c729d602e6f27207b9480c.tar.gz
feat(node): several named roots per group, and one implementation per operation
Stage A — a group's content is a set of named roots --------------------------------------------------- `shared_dir` becomes a list of {name, path, kind}. The name is the directory's basename, derived once at add time and *stored*: recomputing it would re-identify a whole library the day someone renames a folder on disk. Duplicate names are refused case-insensitively and no root may contain another — both compared with NFC folding, because most of these directories live on exFAT or NTFS where `Films` and `films` are one directory. Every index path carries its root name, in a one-root group as much as in a five-root one. One path shape has to be got right once; two have to be kept right for ever. **A root that goes away freezes; it never empties.** Unmounting a volume makes watchdog report every file under it as deleted, or presents an empty directory to the next scan. Acting on either propagates deletions for a whole library to every member, as though the owner had erased it. So a deletion is acted on only once its root is confirmed readable, and availability is tracked per root — one unplugged drive leaves the others serving. 12 tests, verified to fail against an indexer without the check. Events are not trusted to be complete either: ReadDirectoryChangesW drops them under load and inotify on a FUSE mount misses changes made outside it. A periodic reconciliation sweep is the only thing that recovers a missed event. MNP 0.2 → 0.3 (additive). The hub needs no change: SwarmSource carries a content hash, a node id and an endpoint — no paths, no filenames — and private groups register nothing (H7). Stage B — one implementation behind every front door ---------------------------------------------------- C1 and C6 were both "a second path into the node with its own weaker handshake". Two implementations of `revoke` with two authorization checks is that shape one size down. `meshbay_node/ops.py` holds each operation once, takes the daemon state, and knows nothing about HTTP, argv or MNP. The loopback API is one `_op(...)` line per endpoint; the MNP handlers call the same functions. test_ops.py asserts the shape rather than trusting it. Phase 14 is finished on top of it — `group list`, `gek init|rotate`, `reload` (SIGHUP), `denylist show|clear`, `file list|rm`. **No operator action requires a browser any more.** Plus `gek_rotate` and `member_unpin` as operator-signed MNP operations: rotation is the half of revocation that revocation cannot do, since the ex-member holds the current key, and the node generates the replacement with its own CSPRNG — no key material crosses the wire, which is what the C5b rule is actually about. Two bugs found by running it rather than by testing it ------------------------------------------------------ GroupIndex is keyed by **content hash**, so the same bytes at two paths are one entry — which is also why a scan reports ten files and indexes nine. Reconciliation compared paths, so it decided the second path was a missed event every 60 s, rewrote the entry and pushed an index update to every connected peer. Seen in a live node's log. `meshbay-node reload` crashed on first use with `subprocess` unimported: the module compiles fine, which is the "syntax, not names" trap already recorded for the SPA. test_cli_dispatch.py now walks every verb and refuses to let one be added to the parser without an entry there. Also corrected: protocol.py declared a second MNP_VERSION of "0.1" while the wire carried "0.2" — harmless only because nothing imported it. And _do_dir_create/_do_dir_delete referenced an undefined `filename` on their error path. 740 tests pass; QE/deploy/e2e.py passes end to end against the live deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests/test_security_regressions.py')
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py32
1 files changed, 21 insertions, 11 deletions
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
index e13dec0..78a631a 100644
--- a/packages/meshbay-node/tests/test_security_regressions.py
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -18,6 +18,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common.protocol import IndexEntry
from meshbay_node.indexer.group_index import GroupIndex
+from conftest import one_root
from meshbay_node.transport.webrtc_server import WebRTCPeerSession
@@ -129,12 +130,24 @@ def test_the_node_never_generates_a_name_it_would_refuse(tmp_path):
f"the node picked {chosen!r} and would then reject it on the next upload")
+def _uploads_dir(session) -> Path:
+ """
+ Where this session's uploads land: uploads/ inside the group's upload root.
+
+ Asked of the root set rather than assembled by hand, so a test cannot pass
+ while agreeing with a wrong answer the code also produced.
+ """
+ root = session._ctx["roots"].upload_root
+ assert root is not None, "the fixture must designate an upload root"
+ return root.path / "uploads"
+
+
def _session(tmp_path: Path, user_id: str) -> WebRTCPeerSession:
"""A peer session wired to a real shared root, with sending stubbed out."""
shared_root = tmp_path / "shared"
shared_root.mkdir(exist_ok=True)
index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
- ctx = {"shared_root": shared_root, "index": index, "sk_node": index.sk_node}
+ ctx = {"roots": one_root(shared_root), "index": index, "sk_node": index.sk_node}
session = WebRTCPeerSession.__new__(WebRTCPeerSession)
session._ctx = ctx
@@ -161,9 +174,7 @@ def test_upload_cannot_overwrite_another_members_file(tmp_path):
this test now asserts — an existing file is never replaced.
"""
victim = _session(tmp_path, "victim-user")
- shared_root = victim._ctx["shared_root"]
-
- uploads = shared_root / "uploads"
+ uploads = _uploads_dir(victim)
uploads.mkdir()
original = uploads / "important.mp4"
original.write_bytes(b"operator's original content")
@@ -190,7 +201,7 @@ def test_upload_second_attempt_cannot_replace_own_completed_file(tmp_path):
session.sent.clear()
session._do_file_upload(dict(payload))
- uploads = session._ctx["shared_root"] / "uploads"
+ uploads = _uploads_dir(session)
assert (uploads / "movie.mp4").read_bytes() == b"first", (
"the first upload was replaced")
assert (uploads / "movie (2).mp4").read_bytes() == b"first"
@@ -221,7 +232,6 @@ def test_upload_ignores_any_directory_the_client_asks_for(tmp_path):
client-chosen destination would open does not exist on this path.
"""
session = _session(tmp_path, "user-1")
- shared_root = session._ctx["shared_root"]
session._do_file_upload({
"filename": "note.txt", "dir": "../../etc",
@@ -229,7 +239,7 @@ def test_upload_ignores_any_directory_the_client_asks_for(tmp_path):
"data": base64.b64encode(b"x").decode(),
})
- assert (shared_root / "uploads" / "note.txt").read_bytes() == b"x"
+ assert (_uploads_dir(session) / "note.txt").read_bytes() == b"x"
assert not (tmp_path / "etc").exists()
@@ -249,7 +259,7 @@ def test_two_members_can_send_the_same_filename(tmp_path):
"data": base64.b64encode(b"second").decode(),
})
- uploads = first._ctx["shared_root"] / "uploads"
+ uploads = _uploads_dir(first)
assert (uploads / "IMG_1234.jpg").read_bytes() == b"first"
assert (uploads / "IMG_1234 (2).jpg").read_bytes() == b"second"
@@ -270,8 +280,8 @@ def test_chat_store_and_peers_are_per_group(tmp_path):
index_a = GroupIndex(group_id="a" * 32, sk_node=Ed25519PrivateKey.generate())
index_b = GroupIndex(group_id="b" * 32, sk_node=Ed25519PrivateKey.generate())
groups = {
- "a" * 32: {"chat_store": "STORE_A", "index": index_a, "shared_root": tmp_path},
- "b" * 32: {"chat_store": "STORE_B", "index": index_b, "shared_root": tmp_path},
+ "a" * 32: {"chat_store": "STORE_A", "index": index_a, "roots": one_root(tmp_path / "a")},
+ "b" * 32: {"chat_store": "STORE_B", "index": index_b, "roots": one_root(tmp_path / "b")},
}
ctx = {"groups": groups}
@@ -663,7 +673,7 @@ def test_admin_ui_escapes_filenames(tmp_path):
html = _render_page({
"status": "running",
- "groups_ctx": {"g" * 32: {"index": index, "shared_root": tmp_path}},
+ "groups_ctx": {"g" * 32: {"index": index, "roots": one_root(tmp_path)}},
"indexes": {"g" * 32: index},
})