diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 02:15:02 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 02:15:02 +0200 |
| commit | e9d5e979fdab9a1cc3c729d602e6f27207b9480c (patch) | |
| tree | b5993f2c81b760ba56f251457edf84dd91ad63dc /packages/meshbay-node/tests/test_indexer.py | |
| parent | 50ebb4f2e620dad8e1fbca8307b97c5e10e7e6c0 (diff) | |
| download | meshbay-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_indexer.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_indexer.py | 11 |
1 files changed, 6 insertions, 5 deletions
diff --git a/packages/meshbay-node/tests/test_indexer.py b/packages/meshbay-node/tests/test_indexer.py index 380d752..c304361 100644 --- a/packages/meshbay-node/tests/test_indexer.py +++ b/packages/meshbay-node/tests/test_indexer.py @@ -10,6 +10,7 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from meshbay_common.crypto import generate_gek from meshbay_node.indexer import DirectoryIndexer, GroupIndex +from conftest import one_root from meshbay_node.keystore import NodeKeys @@ -115,7 +116,7 @@ def test_group_index_diff(sk_node, gek): @pytest.mark.asyncio async def test_initial_scan(shared_dir, sk_node, gek): indexer = DirectoryIndexer( - root=shared_dir, + roots=one_root(shared_dir), group_id="scan-test", sk_node=sk_node, gek=gek, @@ -134,7 +135,7 @@ async def test_initial_scan(shared_dir, sk_node, gek): @pytest.mark.asyncio async def test_type_detection(shared_dir, sk_node, gek): - indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id="g", sk_node=sk_node, gek=gek) await indexer.initial_scan() by_name = {e.name: e.type for e in indexer.index.entries} @@ -152,7 +153,7 @@ async def test_hidden_files_excluded(tmp_path, sk_node, gek): (d / "visible.txt").write_bytes(b"visible") (d / "file.tmp").write_bytes(b"tmp") - indexer = DirectoryIndexer(root=d, group_id="g", sk_node=sk_node, gek=gek) + indexer = DirectoryIndexer(roots=one_root(d), group_id="g", sk_node=sk_node, gek=gek) await indexer.initial_scan() names = {e.name for e in indexer.index.entries} @@ -169,7 +170,7 @@ async def test_on_change_callback(shared_dir, sk_node, gek): changes.append(idx.index.count) indexer = DirectoryIndexer( - root=shared_dir, group_id="g", sk_node=sk_node, gek=gek, + roots=one_root(shared_dir), group_id="g", sk_node=sk_node, gek=gek, on_change=on_change) await indexer.start() await asyncio.sleep(0.1) @@ -183,7 +184,7 @@ async def test_on_change_callback(shared_dir, sk_node, gek): @pytest.mark.asyncio async def test_index_roundtrip_after_scan(shared_dir, sk_node, gek): - indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id="g", sk_node=sk_node, gek=gek) await indexer.initial_scan() wire = indexer.index.serialize() |