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-common/tests | |
| 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-common/tests')
| -rw-r--r-- | packages/meshbay-common/tests/test_paths.py | 123 |
1 files changed, 123 insertions, 0 deletions
diff --git a/packages/meshbay-common/tests/test_paths.py b/packages/meshbay-common/tests/test_paths.py new file mode 100644 index 0000000..b9052e3 --- /dev/null +++ b/packages/meshbay-common/tests/test_paths.py @@ -0,0 +1,123 @@ +""" +Name rules that have to give the same answer on every platform. + +Most people share from an external drive formatted exFAT or NTFS, on Windows, so +these are not compatibility niceties — they decide whether two index entries are +one file, and whether a member can save what they downloaded. +""" + +import pytest + +from meshbay_common.paths import ( + find_fold_collisions, + fold, + fold_path, + is_portable_name, + nfc, + portable_name_problem, + sanitize_for_download, +) + + +# ── Same file or not ───────────────────────────────────────────────────────── + +def test_case_differences_fold_together(): + assert fold("README.TXT") == fold("readme.txt") + + +def test_folding_is_not_lowercasing(): + """`casefold` handles what `lower()` misses, and those are the pairs that + arrive as a bug report rather than a test failure.""" + assert fold("STRASSE") == fold("straße") + assert "STRASSE".lower() != "straße".lower() + + +def test_the_two_unicode_spellings_of_an_accent_are_one_name(): + """ + `Café.mkv` written on macOS (NFD) and on Windows (NFC) are different byte + strings naming the same file. For French filenames this is routine. + """ + nfc_form = "Café.mkv" # é precomposed + nfd_form = "Café.mkv" # e + combining acute + assert nfc_form != nfd_form + assert nfc(nfd_form) == nfc_form + assert fold(nfd_form) == fold(nfc_form) + + +def test_distinct_names_stay_distinct(): + assert fold("film.mkv") != fold("film2.mkv") + + +def test_folding_a_path_keeps_its_separators(): + assert fold_path("Films/2024/A.MKV") == "films/2024/a.mkv" + + +def test_collisions_are_found_and_grouped(): + found = find_fold_collisions( + ["README.txt", "readme.TXT", "notes.md", "Café.mkv", "Café.mkv"]) + groups = {frozenset(v) for v in found.values()} + assert len(groups) == 2 + assert frozenset({"README.txt", "readme.TXT"}) in groups + # The other pair is the two Unicode spellings of the same accented name, + # which are different byte strings — so compare by size, not by literal. + assert any(len(g) == 2 and all(f.endswith('.mkv') for f in g) + for g in groups) + assert all("notes.md" not in v for v in found.values()) + + +def test_no_collision_means_no_report(): + assert find_fold_collisions(["a.txt", "b.txt"]) == {} + + +# ── What Windows refuses ───────────────────────────────────────────────────── + +@pytest.mark.parametrize("name", ["CON", "PRN", "AUX", "NUL", "COM1", "LPT9", + "aux", "Aux.txt", "COM3.tar.gz"]) +def test_windows_reserved_names_are_flagged(name): + """`AUX.txt` is as impossible as `AUX` — the extension does not help.""" + assert portable_name_problem(name) is not None + + +@pytest.mark.parametrize("name", ['a<b', 'a>b', 'a:b', 'a"b', "a/b", "a\\b", + "a|b", "a?b", "a*b", "a\x00b", "a\tb"]) +def test_reserved_characters_are_flagged(name): + assert portable_name_problem(name) is not None + + +@pytest.mark.parametrize("name", ["trailing ", "trailing.", " "]) +def test_a_trailing_space_or_dot_is_flagged(name): + """Windows strips them silently, so `file .` and `file` come back the same.""" + assert portable_name_problem(name) is not None + + +@pytest.mark.parametrize("name", ["film.mkv", "Café.mkv", "rapport (1).pdf", + "été.txt", "COMET.txt", "AUXILIARY.doc", + "日本語.txt"]) +def test_ordinary_names_are_portable(name): + assert is_portable_name(name), portable_name_problem(name) + + +def test_empty_and_dot_names_are_refused(): + assert portable_name_problem("") is not None + assert portable_name_problem(".") is not None + assert portable_name_problem("..") is not None + + +# ── Saving a file whose name the platform refuses ──────────────────────────── + +def test_a_portable_name_is_returned_unchanged(): + """Identity in the common case, so a caller can tell whether it renamed + anything by comparing.""" + assert sanitize_for_download("film.mkv") == "film.mkv" + assert sanitize_for_download("Café (2024).mkv") == "Café (2024).mkv" + + +def test_sanitizing_produces_something_writable(): + for hostile in ["a<b>c.txt", "AUX", "trailing .", "with/slash.txt", "COM1.log"]: + cleaned = sanitize_for_download(hostile) + assert is_portable_name(cleaned), f"{hostile!r} → {cleaned!r} still refused" + + +def test_sanitizing_never_returns_nothing(): + assert sanitize_for_download("...") not in ("", None) + assert sanitize_for_download("???") not in ("", None) |