summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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")