diff options
Diffstat (limited to 'packages')
9 files changed, 786 insertions, 7 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index 2d90102..8b301db 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -34,6 +34,7 @@ ADMIN_TRANSCRIPT_PREFIX = b"meshbay:admin:v1" # Operations that require node-operator authority. OP_FILE_DELETE = "file_delete" +OP_DIR_DELETE = "dir_delete" OP_INVITE_CREATE = "invite_create" # OP_GEK_BUNDLE_STORE is gone. Members no longer hand the node key material at # all: the node holds the GEK and wraps it itself, for a key the recipient proved @@ -58,7 +59,8 @@ def admin_transcript( Build the exact byte string signed for an admin operation. `subject` identifies what is being acted on: a file_id for OP_FILE_DELETE, the - invitee's user_id for OP_INVITE_CREATE. + path relative to the shared root for OP_DIR_DELETE, the invitee's user_id for + OP_INVITE_CREATE. """ fields = [ op.encode(), diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 08dc47b..62d8847 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -37,6 +37,8 @@ class MNP: FILE_UPLOAD_ACK = "file_upload_ack" # node acknowledges chunk receipt DIR_CREATE = "dir_create" # client → node: make a directory DIR_CREATE_ACK = "dir_create_ack" # node → client: created + DIR_DELETE = "dir_delete" # client → node: remove an empty directory + DIR_DELETE_ACK = "dir_delete_ack" # node → client: removed FILE_DELETE = "file_delete" # client requests file deletion FILE_DELETE_ACK = "file_delete_ack" # node confirms deletion STREAM_REQUEST = "stream_req" # client requests MSE video stream diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 3ef0da5..995e7c7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -3,6 +3,7 @@ import { createContext, useContext, } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, LOCALES } from './i18n.js'; +import { ZipStream, entriesUnder } from './zipstream.js'; // ── Constants ──────────────────────────────────────────────────────────────── @@ -1158,6 +1159,107 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, } }, [groupId, token, descDraft, onGroupUpdated]); + /** + * Download a directory as a zip, written straight to disk. + * + * An archive of a group directory is routinely tens of gigabytes, so it is + * never held anywhere: each file is fetched chunk by chunk, decrypted, and + * handed to the zip writer, which hands it to the file the browser opened. + * Peak memory is one chunk plus one small record per file. + * + * Without the File System Access API there is nowhere to stream to, and the + * only alternative is to build the whole thing in memory — so that path is + * offered but says what it costs first. + */ + const downloadDirectory = useCallback(async (dir) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + + const files = entriesUnder(entries, dir); + if (!files.length) { + setError(t('group.zip_empty')); + return; + } + const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); + const suggested = (dir.split('/').pop() || 'files') + '.zip'; + + let writable = null; + if (window.showSaveFilePicker) { + const handle = await window.showSaveFilePicker({ + suggestedName: suggested, + types: [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }], + }); + writable = await handle.createWritable(); + } else if (!confirm(t('group.zip_no_stream', { + size: formatSize(totalBytes), name: suggested, + }))) { + return; + } + + setDlState({ name: suggested, progress: 0, total: totalBytes }); + const parts = writable ? null : []; + let written = 0; + + try { + const zip = new ZipStream(async (bytes) => { + if (writable) await writable.write(bytes); + else parts.push(bytes.slice()); + }); + + for (const { entry, name } of files) { + await zip.begin(name, entry.size, + new Date((entry.added_at || 0) * 1000)); + // A zero-byte file has no chunk to ask for; the header and an empty + // descriptor are the whole entry. + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + if (totalChunks > 0) await pipelinedDownload( + transport, gekRef.current, entry.id, totalChunks, + (bytes) => { + written += bytes; + setDlState(prev => prev && { ...prev, progress: written }); + }, + // pipelinedDownload writes in order, which is what the archive needs. + { write: (plaintext) => zip.write(plaintext) }); + await zip.end(); + } + await zip.finish(); + + if (writable) { + await writable.close(); + } else { + const url = URL.createObjectURL(new Blob(parts, { type: 'application/zip' })); + const a = document.createElement('a'); + a.href = url; + a.download = suggested; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + setDlState(null); + } catch (err) { + setDlState(null); + if (writable) await writable.abort().catch(() => {}); + if (err.name === 'AbortError') return; + setError(t('group.dl_failed', { err: err.message })); + } + }, [entries]); + + const deleteDirectory = useCallback(async (dir) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.deleteDirectory(dir, signFn); + applyIndex(await transport.fetchIndex()); + } catch (err) { + setError(err.message); + } + }, [applyIndex]); + const deleteFile = useCallback(async (entry) => { const transport = transportRef.current; if (!transport || !transport.connected) return; @@ -1383,17 +1485,50 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, </tr> </thead> <tbody> - ${subdirs.map(d => html` - <tr class="file-row dir-row" onClick=${() => - setCurrentPath(currentPath ? currentPath + '/' + d : d)}> + ${subdirs.map(d => { + const full = currentPath ? currentPath + '/' + d : d; + const inside = entriesUnder(entries, full); + const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0); + return html` + <tr class="file-row dir-row" key=${full} onClick=${() => + setCurrentPath(full)}> <td>\u{1F4C1}</td> <td>${d}/</td> - <td></td> + <td class="file-size">${inside.length ? formatSize(bytes) : ''}</td> <td class="td-type"></td> <td class="td-date"></td> - <td></td> + <td class="file-actions-cell"> + <button class="file-menu-btn" onClick=${(ev) => { + ev.stopPropagation(); + setMenuOpen(menuOpen === 'dir:' + full ? null : 'dir:' + full); + }}>${'\u{22EE}'}</button> + ${menuOpen === 'dir:' + full && html` + <div class="file-menu"> + ${inside.length > 0 && html` + <button onClick=${(ev) => { + ev.stopPropagation(); + setMenuOpen(null); + downloadDirectory(full); + }}> + <span class="fmi">${'\u{2B07}'}</span> + ${t('group.download_zip', { n: inside.length })} + </button> + `} + ${operatorPaired && status === 'connected' && html` + <button class="danger" onClick=${(ev) => { + ev.stopPropagation(); + setMenuOpen(null); + if (inside.length) { setError(t('group.rmdir_not_empty')); return; } + if (confirm(t('group.rmdir_confirm', { name: d }))) { + deleteDirectory(full); + } + }}><span class="fmi">${'\u{1F5D1}'}</span> ${t('group.rmdir')}</button> + `} + </div> + `} + </td> </tr> - `)} + `; })} ${sorted.map(e => { const canPreview = ['image', 'video', 'document'].includes(e.type) || e.name.match(/\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index a6281e2..14a6c78 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -278,6 +278,15 @@ const en = { + 'you normally talk. It works once, and it never passes through the hub.', 'members.invite_code_hint': 'They enter it the first time they open this group. ' + 'You do not need to be online then.', + 'group.download_zip': 'Download as zip ({n} files)', + 'group.zip_empty': 'That folder has nothing in it to download.', + 'group.zip_no_stream': 'This browser cannot write a download straight to disk, ' + + 'so {name} ({size}) has to be built in memory first. On a large archive ' + + 'that may fail. Continue?', + 'group.rmdir': 'Delete folder', + 'group.rmdir_confirm': 'Delete the folder "{name}"? It must be empty.', + 'group.rmdir_not_empty': 'That folder is not empty. Delete what is in it first — ' + + 'nothing here removes files you cannot see.', 'group.desc_edit': 'Edit description', 'group.desc_add': 'Add a description', 'group.desc_save': 'Save', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index e93c601..326aa8e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -493,6 +493,26 @@ class MeshBayTransport { return msg; } + /** + * Remove an empty directory. Operator only, and the node checks that — this + * signs with the identity it pinned for us, exactly like deleting a file. + */ + async deleteDirectory(dir, signFn) { + const msg = await this._sendAndWait({ + type: 'dir_delete', + v: '0.1', + dir, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + // `dir`, not msg.subject: comparing the node's answer against itself is + // no check at all, and the point of this one is that we know what we + // asked for without being told. + return this._authorizeAdminOp(msg, 'dir_delete', dir, signFn); + } + return msg; + } + requestStream(fileId) { this._send({ type: 'stream_req', v: '0.1', file_id: fileId }); } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/zipstream.js b/packages/meshbay-hub/src/meshbay_hub/static/zipstream.js new file mode 100644 index 0000000..bf2230a --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/zipstream.js @@ -0,0 +1,241 @@ +/** + * Streaming ZIP writer — store only, no compression. + * + * Written for downloading a whole directory out of a group. The archive can be + * tens of gigabytes, so nothing is buffered: bytes go to the sink as they + * arrive, and the only thing kept in memory is one small record per file for + * the central directory at the end. + * + * Store-only is deliberate. What people put in a group is video, images and + * archives — already compressed — so deflate would cost CPU on every byte to + * save nothing, and it would have to run in the same thread that is decrypting + * chunks. An uncompressed zip is also the one a stalled download leaves in a + * recoverable state. + * + * Sizes are written after the data, in a data descriptor (general purpose bit + * 3), because a stream cannot seek back to patch the header — the CRC is not + * known until the last byte has gone past. Zip64 is used per entry when a file + * is 4 GiB or larger, or when it starts past the 4 GiB mark, and for the + * archive itself when it ends past that mark or holds more than 65535 files. + * + * No browser globals: this module is exercised under Node by + * packages/meshbay-hub/tests/test_zipstream.py, which reads what it produces + * with Python's zipfile and compares it byte for byte. + */ + +const LOCAL_SIG = 0x04034b50; +const DESC_SIG = 0x08074b50; +const CENTRAL_SIG = 0x02014b50; +const EOCD64_SIG = 0x06064b50; +const LOC64_SIG = 0x07064b50; +const EOCD_SIG = 0x06054b50; + +const U32_MAX = 0xffffffff; +const ZIP64_THRESHOLD = 0xffffffff; + +// Bit 3: sizes and CRC follow the data. Bit 11: the name is UTF-8. +const FLAG_DATA_DESCRIPTOR = 0x0008; +const FLAG_UTF8 = 0x0800; + +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[i] = c; + } + return table; +})(); + +export function crc32(bytes, seed = 0) { + let c = ~seed; + for (let i = 0; i < bytes.length; i++) { + c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + } + return (~c) >>> 0; +} + +/** MS-DOS time and date, which is what a zip entry carries. */ +function dosDateTime(date) { + const d = date instanceof Date ? date : new Date(date); + const year = Math.max(1980, d.getFullYear()); + return { + time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1), + date: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(), + }; +} + +class Writer { + constructor(size) { + this.buf = new Uint8Array(size); + this.view = new DataView(this.buf.buffer); + this.off = 0; + } + u16(v) { this.view.setUint16(this.off, v, true); this.off += 2; return this; } + u32(v) { this.view.setUint32(this.off, v >>> 0, true); this.off += 4; return this; } + u64(v) { + this.view.setBigUint64(this.off, BigInt(v), true); + this.off += 8; + return this; + } + bytes(b) { this.buf.set(b, this.off); this.off += b.length; return this; } +} + +export class ZipStream { + /** + * @param {(bytes: Uint8Array) => Promise<void>|void} sink where the archive goes + */ + constructor(sink) { + this._sink = sink; + this._offset = 0; // bytes written so far — every entry's offset + this._entries = []; + this._current = null; + } + + async _write(bytes) { + await this._sink(bytes); + this._offset += bytes.length; + } + + /** + * Start a file. `size` is what the index says it will be; it decides whether + * this entry needs zip64, and nothing is trusted about it afterwards — the + * length actually written is what the archive records. + */ + async begin(name, size = 0, mtime = new Date(), { forceZip64 = false } = {}) { + if (this._current) throw new Error('A file is already open in this archive'); + + const nameBytes = new TextEncoder().encode(name.replace(/\\/g, '/')); + const zip64 = forceZip64 + || size >= ZIP64_THRESHOLD + || this._offset >= ZIP64_THRESHOLD; + const { time, date } = dosDateTime(mtime); + + this._current = { + name: nameBytes, zip64, time, date, + offset: this._offset, crc: 0, size: 0, + }; + + const extraLen = zip64 ? 20 : 0; + const w = new Writer(30 + nameBytes.length + extraLen); + w.u32(LOCAL_SIG) + .u16(zip64 ? 45 : 20) + .u16(FLAG_DATA_DESCRIPTOR | FLAG_UTF8) + .u16(0) // stored + .u16(time).u16(date) + .u32(0) // crc — in the descriptor + .u32(zip64 ? U32_MAX : 0) // compressed size + .u32(zip64 ? U32_MAX : 0) // uncompressed size + .u16(nameBytes.length) + .u16(extraLen) + .bytes(nameBytes); + if (zip64) { + // Placeholders: the real values go in the descriptor. The field has to be + // here all the same, or a reader has no way to know the descriptor's + // sizes are 8 bytes wide. + w.u16(0x0001).u16(16).u64(0).u64(0); + } + await this._write(w.buf); + } + + /** Feed the open file. Call as often as you like; nothing accumulates. */ + async write(bytes) { + if (!this._current) throw new Error('No file is open in this archive'); + if (!bytes.length) return; + this._current.crc = crc32(bytes, this._current.crc); + this._current.size += bytes.length; + await this._write(bytes); + } + + /** Close the open file, writing what is now known about it. */ + async end() { + if (!this._current) throw new Error('No file is open in this archive'); + const e = this._current; + // A file that grew past 4 GiB after being announced smaller still has to be + // described correctly, and its local header said otherwise. Recording it as + // zip64 in the central directory is what readers go by. + if (e.size >= ZIP64_THRESHOLD) e.zip64 = true; + + const w = new Writer(e.zip64 ? 24 : 16); + w.u32(DESC_SIG).u32(e.crc); + if (e.zip64) w.u64(e.size).u64(e.size); + else w.u32(e.size).u32(e.size); + await this._write(w.buf); + + this._entries.push(e); + this._current = null; + } + + /** Write the central directory and the end records. The archive is complete. */ + async finish() { + if (this._current) throw new Error('A file is still open in this archive'); + + const centralStart = this._offset; + for (const e of this._entries) { + const needsZip64 = e.zip64 || e.offset >= ZIP64_THRESHOLD; + const extraLen = needsZip64 ? 32 : 0; + const w = new Writer(46 + e.name.length + extraLen); + w.u32(CENTRAL_SIG) + .u16(0x031e) // made by: UNIX, spec 3.0 + .u16(needsZip64 ? 45 : 20) + .u16(FLAG_DATA_DESCRIPTOR | FLAG_UTF8) + .u16(0) + .u16(e.time).u16(e.date) + .u32(e.crc) + .u32(needsZip64 ? U32_MAX : e.size) + .u32(needsZip64 ? U32_MAX : e.size) + .u16(e.name.length) + .u16(extraLen) + .u16(0) // comment + .u16(0) // disk + .u16(0) // internal attrs + .u32(0o644 << 16) // external attrs: rw-r--r-- + .u32(needsZip64 ? U32_MAX : e.offset) + .bytes(e.name); + if (needsZip64) w.u16(0x0001).u16(28).u64(e.size).u64(e.size).u64(e.offset).u32(0); + await this._write(w.buf); + } + const centralSize = this._offset - centralStart; + + const archiveZip64 = centralStart >= ZIP64_THRESHOLD + || this._offset >= ZIP64_THRESHOLD + || this._entries.length > 0xffff; + + if (archiveZip64) { + const z = new Writer(56 + 20); + z.u32(EOCD64_SIG).u64(44) // size of this record, less 12 + .u16(0x031e).u16(45) + .u32(0).u32(0) + .u64(this._entries.length).u64(this._entries.length) + .u64(centralSize).u64(centralStart) + .u32(LOC64_SIG).u32(0).u64(centralStart + centralSize).u32(1); + await this._write(z.buf); + } + + const count = Math.min(this._entries.length, 0xffff); + const w = new Writer(22); + w.u32(EOCD_SIG).u16(0).u16(0).u16(count).u16(count) + .u32(archiveZip64 ? U32_MAX : centralSize) + .u32(archiveZip64 ? U32_MAX : centralStart) + .u16(0); + await this._write(w.buf); + return this._offset; + } +} + +/** + * Everything at or under `dir`, with the paths the archive should carry. + * + * `dir` is stripped from the front so an archive of "Holidays/2026" opens as + * "2026/…" rather than as a chain of empty parents. + */ +export function entriesUnder(entries, dir) { + const prefix = dir ? dir + '/' : ''; + return entries + .filter(e => (e.path || '') === dir || (e.path || '').startsWith(prefix)) + .map(e => { + const rest = (e.path || '').slice(dir.length).replace(/^\//, ''); + const base = dir.split('/').pop() || 'files'; + return { entry: e, name: [base, rest, e.name].filter(Boolean).join('/') }; + }); +} 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"] diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 64df7ac..81db0e9 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -56,6 +56,7 @@ from meshbay_common.handshake import ( ) from meshbay_common.adminop import ( ADMIN_CHALLENGE_TTL, + OP_DIR_DELETE, OP_FILE_DELETE, OP_INVITE_CREATE, admin_transcript, @@ -357,6 +358,8 @@ class WebRTCPeerSession: self._do_file_upload(msg) elif mtype == MNP.DIR_CREATE: self._do_dir_create(msg) + elif mtype == MNP.DIR_DELETE: + self._do_dir_delete(msg) elif mtype == MNP.FILE_DELETE: self._do_file_delete(msg) elif mtype == MNP.ADMIN_RESPONSE: @@ -932,6 +935,70 @@ class WebRTCPeerSession: "dir": str(target.relative_to(shared_root)), }) + def _do_dir_delete(self, msg: dict) -> None: + """ + Remove an empty directory, for the node operator. + + Creating one is not privileged — a member who can add a file may organise + where it goes — but removing one is: it acts on a name other members are + using, and on the operator's disk. Empty is the whole safety property + here. Nothing recursive: refusing a directory with anything in it means + this can never destroy content, whatever the caller intended, so the + operator deletes the files first and sees what they are losing. + """ + ctx = self._group_ctx() + shared_root = ctx.get("shared_root") + if not shared_root: + self._send({"type": "error", "detail": "No shared directory"}) + return + + target = safe_subdir(shared_root, msg.get("dir") or "") + if target is None or target == shared_root: + self._send({"type": "error", "detail": "Invalid directory"}) + return + if not target.is_dir(): + self._send({"type": "error", "detail": "Not a directory"}) + return + if any(target.iterdir()): + self._send({"type": "error", "detail": "Directory is not empty"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for deletion"}) + return + + self._issue_admin_challenge( + OP_DIR_DELETE, str(target.relative_to(shared_root))) + + async def _admin_exec_dir_delete( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + rel = pending["subject"] + ctx = self._group_ctx() + shared_root = ctx.get("shared_root") + target = safe_subdir(shared_root, rel) if shared_root else None + if target is None or target == shared_root or not target.is_dir(): + self._send({"type": "error", "detail": "Not a directory"}) + return + + # Operator only. A file has an uploader who may remove their own; a + # directory has none, so there is no second key to accept here. + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"dir_delete:{rel}") + return + + # Checked again after the signature: the emptiness test that let this + # through happened before a round trip to the operator's browser, and a + # file could have landed in the meantime. + if any(target.iterdir()): + self._send({"type": "error", "detail": "Directory is not empty"}) + return + + target.rmdir() + log.info("Directory removed by %s: %s", self._user_id[:8], rel) + self._audit("dir_delete", rel) + self._send({"type": MNP.DIR_DELETE_ACK, "v": MNP_VERSION, "dir": rel}) + async def _do_keypair_bundle_delete(self) -> None: """ Withdraw our own key backup from this node. @@ -1472,6 +1539,9 @@ class WebRTCPeerSession: if pending["op"] == OP_FILE_DELETE: asyncio.ensure_future( self._admin_exec_file_delete(pending, transcript, sig_bytes)) + elif pending["op"] == OP_DIR_DELETE: + asyncio.ensure_future( + self._admin_exec_dir_delete(pending, transcript, sig_bytes)) elif pending["op"] == OP_INVITE_CREATE: asyncio.ensure_future( self._admin_exec_invite_create(pending, transcript, sig_bytes)) diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index a5a48e4..88e426d 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -805,3 +805,112 @@ async def test_each_group_gets_its_own_key(tmp_path, roster): assert await roster.is_authorized("group-a", "member") is True assert await roster.is_authorized("group-b", "member") is False, ( "membership of one group must not admit anyone to another") + + +# ── Removing a directory ───────────────────────────────────────────────────── + +async def _dir_session(tmp_path, roster): + """A session with a shared root and an operator paired, ready for admin ops.""" + session = _session(tmp_path, roster, group_id="g1", gek=generate_gek()) + session._admin_ops = {} + session._ctx["has_admin_authority"] = True + return session + + +async def test_a_directory_with_anything_in_it_is_refused(tmp_path, roster): + session = await _dir_session(tmp_path, roster) + full = tmp_path / "shared" / "full" + full.mkdir() + (full / "keep.txt").write_text("still here") + + session._do_dir_delete({"dir": "full"}) + + assert _last(session).get("detail") == "Directory is not empty" + assert full.exists() and (full / "keep.txt").exists() + + +async def test_no_challenge_is_issued_without_an_operator(tmp_path, roster): + """Fails closed, and says so, rather than asking for a signature nobody can give.""" + session = await _dir_session(tmp_path, roster) + session._ctx["has_admin_authority"] = False + (tmp_path / "shared" / "empty").mkdir() + + session._do_dir_delete({"dir": "empty"}) + + assert _last(session).get("detail") == "No authorized key for deletion" + assert (tmp_path / "shared" / "empty").exists() + + +async def test_the_shared_root_itself_is_not_a_target(tmp_path, roster): + session = await _dir_session(tmp_path, roster) + for attempt in ("", ".", "/", "../shared"): + session._do_dir_delete({"dir": attempt}) + assert _last(session).get("type") == "error", f"{attempt!r} was accepted" + assert (tmp_path / "shared").is_dir() + + +async def test_escaping_the_shared_root_is_refused(tmp_path, roster): + session = await _dir_session(tmp_path, roster) + outside = tmp_path / "outside" + outside.mkdir() + + for attempt in ("../outside", "../../outside", "sub/../../outside"): + session._do_dir_delete({"dir": attempt}) + assert _last(session).get("type") == "error", f"{attempt!r} was accepted" + assert outside.is_dir(), "a path leaving the shared root removed a directory" + + +async def test_an_empty_directory_needs_a_signature_and_then_goes(tmp_path, roster): + """The whole round trip: challenge, operator signature, removal.""" + from meshbay_common.adminop import admin_transcript + + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code") + await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli") + + session = await _dir_session(tmp_path, roster) + (tmp_path / "shared" / "gone").mkdir() + + session._do_dir_delete({"dir": "gone"}) + challenge = _last(session) + assert challenge["type"] == "admin_challenge" + assert challenge["op"] == "dir_delete" + assert challenge["subject"] == "gone" + + transcript = admin_transcript( + op="dir_delete", node_pk_b64=session._node_pk_b64(), group_id="g1", + subject="gone", nonce=base64.b64decode(challenge["nonce"]), + ts=challenge["ts"]) + await session._admin_exec_dir_delete( + session._admin_ops.pop(challenge["op_id"]) if session._admin_ops + else {"op": "dir_delete", "subject": "gone"}, + transcript, sk_ed.sign(transcript)) + + assert _last(session)["type"] == "dir_delete_ack" + assert not (tmp_path / "shared" / "gone").exists() + + +async def test_someone_elses_signature_does_not_remove_it(tmp_path, roster): + from meshbay_common.adminop import admin_transcript + + sk_op, pk_op, pk_x = _keypair() + await roster.pin_identity("grenet", "grenet", pk_op, pk_x, "code") + await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli") + + sk_member, pk_member, pk_x_m = _keypair() + await roster.pin_identity("mallory", "mallory", pk_member, pk_x_m, "code") + await roster.set_member("g1", "mallory", ROLE_MEMBER, "active", "grenet") + + session = await _dir_session(tmp_path, roster) + (tmp_path / "shared" / "theirs").mkdir() + + transcript = admin_transcript( + op="dir_delete", node_pk_b64=session._node_pk_b64(), group_id="g1", + subject="theirs", nonce=b"\x22" * 32, ts=int(time.time())) + await session._admin_exec_dir_delete( + {"op": "dir_delete", "subject": "theirs"}, + transcript, sk_member.sign(transcript)) + + assert _last(session).get("detail") == "Signature verification failed" + assert (tmp_path / "shared" / "theirs").is_dir(), ( + "a member's signature removed a directory — only the operator may") |