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_roots.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_roots.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_roots.py | 242 |
1 files changed, 242 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_roots.py b/packages/meshbay-node/tests/test_roots.py new file mode 100644 index 0000000..ea4ba6a --- /dev/null +++ b/packages/meshbay-node/tests/test_roots.py @@ -0,0 +1,242 @@ +""" +Several named roots per group. + +Most of these are negative assertions — a root set that would be ambiguous is +refused rather than resolved, because every ambiguity here ends as either "my +file went to the wrong disk" or "the same film is listed twice and deleting one +copy breaks the other". +""" + +from pathlib import Path + +import pytest + +from meshbay_node.roots import Root, RootError, RootSet, entry_abs_path +from meshbay_common.protocol import IndexEntry + + +def _spec(path, **kw): + return {"path": str(path), **kw} + + +def _entry(path: str, name: str) -> IndexEntry: + return IndexEntry(id="f" * 64, name=name, path=path, size=1, + type="other", added_at=0) + + +# ── Naming ─────────────────────────────────────────────────────────────────── + +def test_the_name_is_the_directory_basename(tmp_path): + (tmp_path / "Films").mkdir() + roots = RootSet.build([_spec(tmp_path / "Films")]) + assert roots.names == ["Films"] + + +def test_an_explicit_name_wins_over_the_basename(tmp_path): + (tmp_path / "Films").mkdir() + roots = RootSet.build([_spec(tmp_path / "Films", name="Cinema")]) + assert roots.names == ["Cinema"] + + +def test_two_roots_cannot_share_a_name(tmp_path): + for parent in ("a", "b"): + (tmp_path / parent / "Films").mkdir(parents=True) + with pytest.raises(RootError, match="both be called"): + RootSet.build([_spec(tmp_path / "a" / "Films"), + _spec(tmp_path / "b" / "Films")]) + + +def test_names_clash_without_regard_to_case(tmp_path): + """ + `Films` and `films` are one directory on NTFS and exFAT, which is where most + of these live. A comparison that respected case would let the pair through + and produce two roots a Windows member cannot tell apart. + """ + (tmp_path / "a" / "Films").mkdir(parents=True) + (tmp_path / "b" / "films").mkdir(parents=True) + with pytest.raises(RootError, match="both be called"): + RootSet.build([_spec(tmp_path / "a" / "Films"), + _spec(tmp_path / "b" / "films")]) + + +def test_a_name_windows_cannot_write_is_refused(tmp_path): + """ + The root name is a folder every member sees, including on Windows, where + `AUX` cannot be created at all. + """ + (tmp_path / "AUX").mkdir() + with pytest.raises(RootError, match="reserved on Windows"): + RootSet.build([_spec(tmp_path / "AUX")]) + + +# ── Nesting ────────────────────────────────────────────────────────────────── + +def test_a_root_inside_another_is_refused(tmp_path): + """ + Both roots would index the same bytes under two identities, and deleting + through one would leave the other pointing at nothing. + """ + (tmp_path / "Media" / "Films").mkdir(parents=True) + with pytest.raises(RootError, match="is inside root"): + RootSet.build([_spec(tmp_path / "Media"), + _spec(tmp_path / "Media" / "Films")]) + + +def test_nesting_is_refused_in_either_order(tmp_path): + (tmp_path / "Media" / "Films").mkdir(parents=True) + with pytest.raises(RootError, match="is inside root"): + RootSet.build([_spec(tmp_path / "Media" / "Films"), + _spec(tmp_path / "Media")]) + + +def test_the_same_directory_twice_is_refused(tmp_path): + (tmp_path / "Media").mkdir() + with pytest.raises(RootError, match="same directory"): + RootSet.build([_spec(tmp_path / "Media"), + _spec(tmp_path / "Media", name="Other")]) + + +def test_a_sibling_with_a_shared_prefix_is_fine(tmp_path): + """`/data/Media` and `/data/Media2` are unrelated — a string prefix test + would wrongly call the second nested inside the first.""" + (tmp_path / "Media").mkdir() + (tmp_path / "Media2").mkdir() + roots = RootSet.build([_spec(tmp_path / "Media"), _spec(tmp_path / "Media2")]) + assert roots.names == ["Media", "Media2"] + + +# ── Uploads ────────────────────────────────────────────────────────────────── + +def test_a_single_root_receives_uploads_without_being_asked(tmp_path): + (tmp_path / "Media").mkdir() + roots = RootSet.build([_spec(tmp_path / "Media")]) + assert roots.upload_root is roots.roots[0] + + +def test_several_roots_and_no_designation_means_no_uploads(tmp_path): + """ + Refused, never guessed: picking one would send a member's file to a disk the + operator did not intend, and that is discovered weeks later. + """ + (tmp_path / "A").mkdir() + (tmp_path / "B").mkdir() + roots = RootSet.build([_spec(tmp_path / "A"), _spec(tmp_path / "B")]) + assert roots.upload_root is None + + +def test_two_upload_roots_are_refused(tmp_path): + (tmp_path / "A").mkdir() + (tmp_path / "B").mkdir() + with pytest.raises(RootError, match="exactly one"): + RootSet.build([_spec(tmp_path / "A", upload=True), + _spec(tmp_path / "B", upload=True)]) + + +# ── Resolution ─────────────────────────────────────────────────────────────── + +def test_resolution_finds_a_path_inside_its_root(tmp_path): + (tmp_path / "Media" / "2024").mkdir(parents=True) + roots = RootSet.build([_spec(tmp_path / "Media")]) + assert roots.resolve("Media/2024") == (tmp_path / "Media" / "2024").resolve() + + +def test_the_virtual_root_resolves_to_nothing(tmp_path): + """ + It is not a directory on anyone's disk — it belongs to no volume — so a file + cannot be written there and a directory cannot be created there. + """ + (tmp_path / "Media").mkdir() + roots = RootSet.build([_spec(tmp_path / "Media")]) + for attempt in ("", "/", ".", " "): + assert roots.resolve(attempt) is None, f"{attempt!r} resolved" + + +@pytest.mark.parametrize("attempt", [ + "Media/../..", "Media/../../etc", "Media/sub/../../../etc", + "../Media", "..", "Unknown/x", +]) +def test_escaping_a_root_is_refused(tmp_path, attempt): + (tmp_path / "Media" / "sub").mkdir(parents=True) + roots = RootSet.build([_spec(tmp_path / "Media")]) + assert roots.resolve(attempt) is None, f"{attempt!r} escaped its root" + + +def test_a_symlink_out_of_the_root_is_refused(tmp_path): + """Resolved before comparing, so a link is followed and then rejected — + checking the string would have accepted it.""" + (tmp_path / "Media").mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (tmp_path / "Media" / "escape").symlink_to(outside) + roots = RootSet.build([_spec(tmp_path / "Media")]) + assert roots.resolve("Media/escape") is None + + +def test_resolution_is_case_insensitive_on_the_root_name(tmp_path): + (tmp_path / "Media").mkdir() + roots = RootSet.build([_spec(tmp_path / "Media")]) + assert roots.resolve("media") == (tmp_path / "Media").resolve() + + +def test_an_unavailable_root_resolves_to_nothing(tmp_path): + (tmp_path / "Media").mkdir() + roots = RootSet.build([_spec(tmp_path / "Media")]) + roots.roots[0].available = False + assert roots.resolve("Media") is None + # …but the mapping is still known, so entries can still be listed as frozen + # rather than vanishing from the index. + assert roots.split("Media")[0].name == "Media" + + +def test_an_entry_under_a_missing_root_has_no_path(tmp_path): + """ + An unplugged drive must answer "nowhere", not open a file that happens to + share a relative path with another root. + """ + (tmp_path / "Media").mkdir() + roots = RootSet.build([_spec(tmp_path / "Media")]) + entry = _entry("Media", "film.mkv") + assert entry_abs_path(roots, entry) == (tmp_path / "Media" / "film.mkv").resolve() + roots.roots[0].available = False + assert entry_abs_path(roots, entry) is None + + +def test_virtual_path_round_trips(tmp_path): + (tmp_path / "Media" / "2024").mkdir(parents=True) + roots = RootSet.build([_spec(tmp_path / "Media")]) + real = roots.resolve("Media/2024") + assert roots.virtual_of(real) == "Media/2024" + assert roots.virtual_of(roots.resolve("Media")) == "Media" + + +# ── Availability ───────────────────────────────────────────────────────────── + +def test_availability_follows_the_directory(tmp_path): + target = tmp_path / "Media" + target.mkdir() + roots = RootSet.build([_spec(target)]) + assert roots.refresh_availability() == [] + + target.rmdir() # stands in for an unmounted volume + changed = roots.refresh_availability() + assert [(r.name, live) for r, live in changed] == [("Media", False)] + assert roots.roots[0].available is False + + target.mkdir() + changed = roots.refresh_availability() + assert [(r.name, live) for r, live in changed] == [("Media", True)] + + +def test_describe_reports_what_a_member_needs(tmp_path): + (tmp_path / "Media").mkdir() + (tmp_path / "Music").mkdir() + roots = RootSet.build([_spec(tmp_path / "Media", upload=True), + _spec(tmp_path / "Music", kind="audio")]) + described = roots.describe() + assert described == [ + {"name": "Media", "kind": "generic", "available": True, "upload": True}, + {"name": "Music", "kind": "audio", "available": True, "upload": False}, + ] + # Deliberately no paths: a member is told what exists and whether it is + # readable, not where on the operator's disk it lives. + assert not any("path" in d for d in described) |