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-hub/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-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_zipstream.py | 191 |
1 files changed, 191 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_zipstream.py b/packages/meshbay-hub/tests/test_zipstream.py new file mode 100644 index 0000000..51a42d0 --- /dev/null +++ b/packages/meshbay-hub/tests/test_zipstream.py @@ -0,0 +1,191 @@ +""" +The browser's ZIP writer, read back by Python's zipfile. + +A directory download is assembled in the browser: the node has no idea an +archive is being made, so nothing on this side would notice if the bytes were +malformed. These tests run the real module under Node, hand the output to +zipfile, and check that what comes out is what went in — including the CRCs, +which is the field a streaming writer is most likely to get wrong, since it is +written after the data it describes. +""" + +import io +import json +import shutil +import subprocess +import zipfile +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +ZIPSTREAM = STATIC / "zipstream.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not ZIPSTREAM.exists(), + reason="node or the SPA sources are not available") + + +def _build(files, force_zip64=False, tmp_path=None): + """Run zipstream.js over `files` ({name: bytes}) and return the archive.""" + # Copied with an .mjs suffix: the browser loads this file with + # <script type="module">, but Node reads a bare .js as CommonJS unless a + # package.json says otherwise, and there is none next to the SPA. + module = tmp_path / "zipstream.mjs" + module.write_text(ZIPSTREAM.read_text()) + script = tmp_path / "build.mjs" + out = tmp_path / "out.zip" + script.write_text(f""" +import {{ writeFileSync }} from 'node:fs'; +import {{ ZipStream }} from '{module.as_posix()}'; + +const files = {json.dumps({k: list(v) for k, v in files.items()})}; +const parts = []; +const zip = new ZipStream(b => {{ parts.push(Buffer.from(b)); }}); +for (const [name, bytes] of Object.entries(files)) {{ + const data = Uint8Array.from(bytes); + await zip.begin(name, data.length, new Date('2026-08-15T12:34:56Z'), + {{ forceZip64: {str(force_zip64).lower()} }}); + // Written in pieces on purpose: a running CRC that is only correct for a + // single call would pass a friendlier test than this one. + for (let i = 0; i < data.length; i += 7) {{ + await zip.write(data.subarray(i, i + 7)); + }} + await zip.end(); +}} +await zip.finish(); +writeFileSync('{out.as_posix()}', Buffer.concat(parts)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return out.read_bytes() + + +def test_a_plain_archive_reads_back(tmp_path): + files = { + "readme.txt": b"the quick brown fox\n", + "clip.mp4": bytes(range(256)) * 40, + "notes/deep.txt": b"", + } + data = _build(files, tmp_path=tmp_path) + + with zipfile.ZipFile(io.BytesIO(data)) as z: + assert z.testzip() is None, "zipfile found a bad CRC" + assert sorted(z.namelist()) == sorted(files) + for name, content in files.items(): + assert z.read(name) == content, f"{name} came back different" + + +def test_nothing_is_compressed(tmp_path): + """ + Store-only is a decision, not an accident: the payloads are already + compressed, and deflate would burn CPU in the thread doing the decryption. + """ + data = _build({"a.bin": b"x" * 5000}, tmp_path=tmp_path) + with zipfile.ZipFile(io.BytesIO(data)) as z: + info = z.getinfo("a.bin") + assert info.compress_type == zipfile.ZIP_STORED + assert info.compress_size == info.file_size == 5000 + + +def test_sizes_and_crcs_come_after_the_data(tmp_path): + """ + A stream cannot go back and patch a header, so the general purpose bit that + says "look for a data descriptor" has to be set — and the descriptor has to + be there. zipfile reads the central directory, so this checks the flag + explicitly rather than trusting a successful read. + """ + data = _build({"a.bin": b"payload"}, tmp_path=tmp_path) + with zipfile.ZipFile(io.BytesIO(data)) as z: + assert z.getinfo("a.bin").flag_bits & 0x08, "data descriptor bit is not set" + assert z.getinfo("a.bin").flag_bits & 0x800, "names must be marked UTF-8" + assert data.count((0x08074b50).to_bytes(4, "little")) >= 1 + + +def test_a_name_that_is_not_ascii_survives(tmp_path): + files = {"vidéos/été à la mer.txt": "déjà vu\n".encode()} + data = _build(files, tmp_path=tmp_path) + with zipfile.ZipFile(io.BytesIO(data)) as z: + assert z.namelist() == ["vidéos/été à la mer.txt"] + assert z.read("vidéos/été à la mer.txt") == "déjà vu\n".encode() + + +def test_the_zip64_records_are_right(tmp_path): + """ + Forced rather than fed 4 GiB: the branch is what needs testing, and the + archive it produces has to be readable by something that is not us. + """ + files = {"big.bin": b"a" * 1000, "second.bin": b"b" * 10} + data = _build(files, force_zip64=True, tmp_path=tmp_path) + + with zipfile.ZipFile(io.BytesIO(data)) as z: + assert z.testzip() is None + for name, content in files.items(): + assert z.read(name) == content + # "version needed to extract" is the field that announces zip64; + # create_version is who wrote it and says nothing about the format. + assert z.getinfo("big.bin").extract_version >= 45, "not marked as zip64" + # And the extra field really is being parsed, not skipped: zipfile takes + # the sizes from it when the 32-bit fields are 0xFFFFFFFF. + assert z.getinfo("big.bin").file_size == 1000 + + +def test_an_empty_archive_is_still_an_archive(tmp_path): + data = _build({}, tmp_path=tmp_path) + with zipfile.ZipFile(io.BytesIO(data)) as z: + assert z.namelist() == [] + + +# ── Which files go in, and under what names ───────────────────────────────── + +def _under(entries, dir_, tmp_path): + module = tmp_path / "zipstream.mjs" + module.write_text(ZIPSTREAM.read_text()) + script = tmp_path / "under.mjs" + script.write_text(f""" +import {{ entriesUnder }} from '{module.as_posix()}'; +const out = entriesUnder({json.dumps(entries)}, {json.dumps(dir_)}); +console.log(JSON.stringify(out.map(o => o.name))); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _e(name, path=""): + return {"id": name, "name": name, "path": path, "size": 1} + + +def test_the_archive_starts_at_the_folder_you_asked_for(tmp_path): + """ + Not at the shared root: an archive of "Holidays/2026" should open as + "2026/…", not as a chain of empty parent folders. + """ + entries = [ + _e("beach.jpg", "Holidays/2026"), + _e("hotel.pdf", "Holidays/2026/paperwork"), + _e("old.jpg", "Holidays/2019"), + _e("readme.txt", ""), + ] + assert sorted(_under(entries, "Holidays/2026", tmp_path)) == [ + "2026/beach.jpg", "2026/paperwork/hotel.pdf", + ] + + +def test_a_sibling_with_a_longer_name_is_not_swept_in(tmp_path): + """ + "Holidays2026" starts with "Holidays" as a string but is a different folder, + which is the mistake a prefix test invites. + """ + entries = [_e("a.txt", "Holidays"), _e("b.txt", "Holidays2026")] + assert _under(entries, "Holidays", tmp_path) == ["Holidays/a.txt"] + + +def test_the_root_is_wrapped_rather_than_exploded(tmp_path): + """ + The root has no name to give the archive, so entries get a "files/" wrapper. + An archive that unpacks straight into whatever directory it was opened in is + the kind that scatters a hundred files across someone's Downloads folder. + """ + entries = [_e("a.txt", ""), _e("b.txt", "sub")] + assert sorted(_under(entries, "", tmp_path)) == ["files/a.txt", "files/sub/b.txt"] |