summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-15 12:19:22 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-15 12:19:22 +0200
commit8cd7e467ebec987f66c4fe93a8d87dfbc57304d2 (patch)
tree0ebd406d893b49ebdf9290ea1a4ac3474d01b7bd
parent0503682c0e2add135b88c2a1fadfe07455680a71 (diff)
downloadmeshbay-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>
-rw-r--r--docs/USERGUIDE.md29
-rw-r--r--docs/meshbay-draft-v5.md19
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py4
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js147
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/zipstream.js241
-rw-r--r--packages/meshbay-hub/tests/test_zipstream.py191
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py70
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py109
11 files changed, 834 insertions, 7 deletions
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index e2193f0..7f69712 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -529,6 +529,35 @@ look at what was sent, move it, or empty it without hunting through the tree.
- Uploads are attributed to the identity the node pinned for that member, and that is what
decides who may delete the file later — not anything the hub says.
+### Downloading a folder as a zip
+
+Any member can take a whole folder: **⋮ → Download as zip** on the folder's row.
+The archive is built in the browser as the files arrive and written straight to
+disk, so a 40 GB folder costs 40 GB of disk and a few megabytes of memory.
+
+- Nothing is compressed. Group content is video, images and archives — already
+ compressed — so deflating would spend CPU on every byte to save nothing, in the
+ same thread that is decrypting.
+- The archive opens as the folder you asked for: a zip of `Holidays/2026` unpacks
+ as `2026/…`, not as a chain of empty parents.
+- Files over 4 GiB, and archives over 4 GiB, use zip64. Anything current reads
+ them; a tool from before 2003 may not.
+- **Firefox and Safari cannot write a download straight to disk** (no File System
+ Access API). There, the archive has to be assembled in memory first, and the
+ browser says so, with the size, before starting. Use Chrome or Edge for a large
+ one.
+
+### Deleting a directory
+
+**⋮ → Delete folder**, for the node operator, from a paired browser (§3). The
+directory must be **empty** — nothing here is recursive, and a folder with
+anything in it is refused before a signature is even asked for. Delete the files
+first, where you can see what you are losing.
+
+Like every privileged action on a node, it is signed with the key the node pinned
+for that browser and refused otherwise: hub membership, or an admin role on the
+hub, grants nothing here.
+
### Creating a directory
Any active member can create a directory from the Files panel (**New folder**). It is
diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md
index a8b7e4b..b03328b 100644
--- a/docs/meshbay-draft-v5.md
+++ b/docs/meshbay-draft-v5.md
@@ -250,6 +250,25 @@ version accepted a client-chosen path and overwrote whatever was there, which de
deletion authorization as well: overwriting a file made the attacker its recorded
uploader, and therefore the party allowed to delete it (C5a).
+### 5.2b Removing a directory
+
+Creating a directory is not privileged (§5.2); removing one is, and it is refused
+unless the directory is empty. The emptiness rule is the safety property: whatever
+the caller intended, and whatever the browser sent, this operation cannot destroy
+content. It is checked twice — once before the challenge is issued, and again
+after the signature comes back, because a file can land during the round trip to
+the operator's browser.
+
+Authorized by `_verify_admin_sig` alone. A file also accepts its uploader's key,
+since the node recorded who put it there; a directory has no uploader, so there is
+no second key to accept.
+
+Archives are not the node's business: a member downloading a folder as a zip
+fetches the same encrypted chunks as any other download and assembles the archive
+in the browser. The node serves no bundles, holds no temporary files, and cannot
+be asked to compress anything — one fewer place where a request turns into work
+on someone else's disk.
+
### 5.3 Operator interface
Two personas, and they need different tools:
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")