summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/meshbay-draft-v6.md14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js18
-rw-r--r--packages/meshbay-hub/tests/test_upload_controls_hidden.py7
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py3
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py3
-rw-r--r--packages/meshbay-node/src/meshbay_node/roots.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py55
-rw-r--r--packages/meshbay-node/tests/test_root_writable_policy.py8
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py90
12 files changed, 156 insertions, 84 deletions
diff --git a/docs/meshbay-draft-v6.md b/docs/meshbay-draft-v6.md
index 4439cd1..28aea0c 100644
--- a/docs/meshbay-draft-v6.md
+++ b/docs/meshbay-draft-v6.md
@@ -74,12 +74,22 @@
v5 confines uploads to `shared_root/uploads/` with a filename allowlist, no overwrite,
chunk ordering and a size cap. All four protections stand. Two amendments:
-- There is no single `shared_root`. **Each root is read-only or read-write**, and the
- quarantine lives inside whichever writable root the upload is addressed to. If that
+- There is no single `shared_root`. **Each root is read-only or read-write**, and an
+ upload goes to the folder the sender is looking at, inside a writable root. If that
root is unavailable the upload fails with a stated reason and never falls back to
another; if the group has no writable root, uploads are refused rather than guessed.
(Amended 2026-09-06 — the original text designated *one* root as the upload
destination, and the client named none. See `docs/refactor-groups.md` §1.1.)
+- **There is no `uploads/` quarantine directory any more** (2026-09-06). It was the
+ last of v5's, the per-user layer having gone on 2026-08-14, and it went for the same
+ reason: a folder appearing beside the operator's library because somebody sent a
+ file is the node deciding how their disk is arranged. **What made the quarantine
+ worth having was never the subdirectory** — it is the filename allowlist, the size
+ cap, the chunk ordering and the no-overwrite rule, and all four are unchanged.
+ The client now names the destination folder, which is safe for one reason and only
+ one: it is resolved through `RootSet.resolve()`, which refuses `..`, absolute
+ segments and anything escaping its root, symlinks included. A member answers "which
+ of this group's folders", never "which path on the operator's disk".
- **The no-overwrite rule is unchanged and still holds on exFAT/NTFS.** An earlier
draft claimed a string comparison let `README.TXT` land on `readme.txt` there. It does
not: the check is `Path.exists()`, and `stat()` is itself case-insensitive on those
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
index 4714674..0a7ef26 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -204,7 +204,8 @@ function ChatImage({ filename, entries, transportRef, gekRef }) {
// root is read-only, or the one drive that was writable is unplugged — and the
// paperclip says so rather than producing a refusal from the node.
function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
- onPreview, attachRoot = '', onActivity, status }) {
+ onPreview, attachRoot = '', attachDir = '',
+ onActivity, status }) {
const [messages, setMessages] = useState([]);
const [hasMore, setHasMore] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
@@ -484,7 +485,10 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
try {
// Two people sending IMG_1234.jpg both succeed; the node picks a free name
// and the message has to point at the one it chose.
- const ack = await transport.uploadFile(file, { root: attachRoot });
+ // `attachDir` is the folder the operator chose in Settings; `attachRoot`
+ // is the fallback for a group where they have not chosen one yet.
+ const ack = await transport.uploadFile(
+ file, { root: attachRoot, dir: attachDir || undefined });
const storedAs = (ack && ack.stored_as) || file.name;
await new Promise(r => setTimeout(r, 2500));
if (onRefreshIndex) await onRefreshIndex();
@@ -506,7 +510,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
} finally {
setAttaching(false);
}
- }, [username, onRefreshIndex, jumpToBottom, attachRoot]);
+ }, [username, onRefreshIndex, jumpToBottom, attachRoot, attachDir]);
const onKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index a6a3ca4..9ca3aae 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -75,12 +75,12 @@ function FilesPanel({
e.target.value = '';
const transport = transportRef.current;
if (!files.length || !transport || !transport.connected) return;
- // The root being browsed is the destination. A group can have several
- // writable roots, so leaving the node to pick one means a file uploaded
- // from a folder the operator is looking at lands in a different one —
- // which is only noticed much later, if at all.
- const uploadRoot = currentPath ? currentPath.split('/')[0] : '';
- if (!uploadRoot) return;
+ // The folder on screen is the destination — not its root, and not a
+ // subdirectory of the node's invention. Somebody dropping a file into the
+ // folder they are looking at expects it to be in that folder.
+ const uploadDir = currentPath;
+ if (!uploadDir) return;
+ const uploadRoot = uploadDir.split('/')[0];
setError('');
for (const file of files) {
@@ -92,6 +92,7 @@ function FilesPanel({
onProgress: (sent) => onProgress(sent, file.size),
signal,
root: uploadRoot,
+ dir: uploadDir,
});
// The node re-indexes on a filesystem event, so there is nothing to
// wait on but the clock. Refreshing here means the file appears in
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 513792b..a1e6411 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -590,7 +590,16 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
[nodeRoots]);
const legacyNode = nodeRoots.length > 0
&& nodeRoots.every((r) => r.writable === undefined);
- const attachRoot = writableRoots.length ? writableRoots[0].name
+ // The operator's chosen attachment folder wins where there is one — that is
+ // what the Chat settings pane is for. Its root has to be writable and
+ // present, or the choice is stale (they made it read-only, or ejected the
+ // drive) and the fallback is better than a refusal at send time.
+ const chatDirRoot = chatDirectory ? chatDirectory.split('/')[0] : '';
+ const chatDirUsable = Boolean(
+ chatDirRoot && writableRoots.some((r) => r.name === chatDirRoot));
+ const attachDir = chatDirUsable ? chatDirectory : '';
+ const attachRoot = chatDirUsable ? chatDirRoot
+ : writableRoots.length ? writableRoots[0].name
: (legacyNode && memberUpload
? (nodeRoots.find((r) => r.upload) || nodeRoots[0]).name
: '');
@@ -655,7 +664,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
groupId, transportRef, gekRef, status, username,
entries, availableEntries, nodeDirs, nodeRoots,
setEntries, setNodeDirs, setNodeRoots, applyIndex,
- isNodeAdmin, operatorPaired, attachRoot, userId, setError, onPreview,
+ isNodeAdmin, operatorPaired, attachRoot, attachDir, userId, setError, onPreview,
onRefreshIndex: refreshIndex, onActivity: touchActivity,
// Plural everywhere: Videos and Music read a list now, and Photos always
// did. The scalar `videoRoot`/`audioRoot` shapes survive only on the wire,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 32f8539..3acfd20 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -1778,13 +1778,18 @@ class MeshBayTransport {
* free one rather than replacing anything. The ack says which, and that is what
* this returns.
*
- * `root` names which shared directory to upload into — a name, never a path;
- * the node picks the destination inside it. Since a group can have several
- * writable roots, leaving it out is a guess, and the node's fallback ("the
- * first writable one") exists only for MNP 1.0 clients, which had exactly one
- * destination. Every caller here browses a root and knows which one it is.
+ * `dir` names the folder to upload into, as a virtual path
+ * (`Media/Films/1999`) — where the sender is actually looking. The node
+ * resolves it against the group's own roots, which refuses `..`, absolute
+ * segments and anything escaping its root; it is a place among the group's
+ * folders, never a path on the operator's filesystem.
+ *
+ * `root` is the older, coarser form: the root's name and nothing below it.
+ * Kept because a node that predates `dir` reads it, and because Chat has no
+ * folder on screen to name. Omitting both leaves the node to pick, which it
+ * only does for a client old enough to have had one destination.
*/
- async uploadFile(file, { chunkSize, onProgress, signal, root } = {}) {
+ async uploadFile(file, { chunkSize, onProgress, signal, root, dir } = {}) {
// The same file twice at once would confuse the node, which keys its own
// upload state by name — and would race for the same destination.
if (this._uploaders.has(file.name)) {
@@ -1836,6 +1841,7 @@ class MeshBayTransport {
total_chunks: total,
data: buf,
...(root ? { root } : {}),
+ ...(dir ? { dir } : {}),
});
}
while (acked < total) {
diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
index ae1f444..7b8b0e3 100644
--- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py
+++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
@@ -125,8 +125,11 @@ def test_files_uploads_into_the_root_it_is_showing():
page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
upload = page[page.index("const uploadFile"):]
upload = upload[:upload.index("const makeDirectory")]
- assert "root: uploadRoot" in upload, "the node is left to choose"
- assert "currentPath.split('/')[0]" in upload
+ assert "dir: uploadDir" in upload, "the node is left to choose the folder"
+ assert "const uploadDir = currentPath" in upload, (
+ "the destination is not the folder on screen")
+ assert "root: uploadRoot" in upload, (
+ "a node too old for `dir` reads `root`, and gets nothing without it")
# ── Learning the answer ─────────────────────────────────────────────────────
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index 4712312..7673a51 100644
--- a/packages/meshbay-node/src/meshbay_node/config.py
+++ b/packages/meshbay-node/src/meshbay_node/config.py
@@ -184,7 +184,6 @@ class RootSpec:
kind: str = "generic" # generic|video|audio|photo — a view hint, unused for now
writable: bool = False # RW roots accept uploads from group members
removable: bool = False # operator can eject this root before unplugging the device
- direct: bool = False # uploads land at root path, not in a subdirectory
@dataclass
@@ -225,7 +224,7 @@ class GroupConfig:
for r in self.roots:
r.writable = False
self.roots.append(RootSpec(
- path=self.upload_dir.strip(), writable=True, direct=True))
+ path=self.upload_dir.strip(), writable=True))
@dataclass
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index 5b10452..3dfdc23 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -679,8 +679,7 @@ async def add_root(state: dict, group_id: str, path: str, *,
from meshbay_node.config import RootSpec
cfg.roots.append(RootSpec(
path=str(added.path), name=added.name, kind=added.kind,
- writable=added.writable, removable=added.removable,
- direct=added.direct))
+ writable=added.writable, removable=added.removable))
# Deliberately *not* mutating the live RootSet in place.
#
diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py
index 9d3f7cb..ffe801c 100644
--- a/packages/meshbay-node/src/meshbay_node/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/roots.py
@@ -122,7 +122,6 @@ class Root:
kind: str = "generic"
writable: bool = False
removable: bool = False
- direct: bool = False
ejected: bool = False
available: bool = True
@@ -226,8 +225,7 @@ class RootSet:
writable=writable,
removable=bool(spec.get("removable", False)),
ejected=bool(spec.get("ejected", False)),
- available=not bool(spec.get("ejected", False)),
- direct=bool(spec.get("direct", False)))
+ available=not bool(spec.get("ejected", False)))
_refuse_nesting(root, roots)
roots.append(root)
by_folded[root.folded] = root
@@ -370,8 +368,6 @@ class RootSet:
"ejected": r.ejected,
# Backward compat for MNP 1.0 clients
"upload": r.writable}
- if r.direct:
- d["direct"] = True
out.append(d)
return out
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 d341d8c..61458f2 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -214,7 +214,6 @@ JOIN_FAILURE_WINDOW = 600 # seconds
# attachments from the chat alike. One visible directory the operator can look
# into, back up or empty — rather than a hidden tree of per-user uuids that
# nobody could read, or files scattered wherever someone happened to be looking.
-UPLOAD_DIR_NAME = "uploads"
def _extract_dtls_fingerprint(sdp: str) -> bytes:
@@ -4016,7 +4015,12 @@ class WebRTCPeerSession:
# later — the same reason the old single upload root was never guessed.
# A client that names nothing is an MNP 1.0 one, and there was exactly
# one destination in its world: the first writable root.
- target_root_name = str(msg.get("root") or "").strip()
+ # `dir` is the folder being browsed, as a virtual path
+ # (`Media/Films/1999`); `root` is the older, coarser form and is what
+ # its first segment means on its own.
+ target_rel = str(msg.get("dir") or "").strip().strip("/")
+ target_root_name = (target_rel.split("/")[0] if target_rel
+ else str(msg.get("root") or "").strip())
upload_root = None
if target_root_name:
upload_root = roots.by_name(target_root_name)
@@ -4052,18 +4056,43 @@ class WebRTCPeerSession:
"filename": filename})
return
- if upload_root.direct:
- rel_dir = upload_root.name
- target_dir = upload_root.path
+ # The folder the sender is looking at, and no subdirectory of the node's
+ # invention.
+ #
+ # Uploads used to be confined to `<root>/uploads/`, created on demand.
+ # That was the last of v5's quarantine (the per-user layer went on
+ # 2026-08-14, for the same reason): a shared directory nobody can
+ # organise is not a shared directory, and a folder appearing beside the
+ # operator's library because somebody sent a file is the node deciding
+ # how their disk is arranged.
+ #
+ # What made the quarantine worth having is not the subdirectory — it is
+ # the filename allowlist, the size cap, the chunk ordering, and the
+ # no-overwrite rule below. All four are unchanged.
+ #
+ # `resolve()` and not a join: it refuses `..`, absolute segments and
+ # anything whose resolved form escapes its root, symlinks included. The
+ # client names *where among the group's own folders*, never a path on
+ # the operator's filesystem.
+ if target_rel:
+ target_dir = roots.resolve(target_rel)
+ if target_dir is None or not target_dir.is_dir():
+ self._send({"type": "error",
+ "detail": "Not a directory in this group",
+ "code": "no_such_directory",
+ "filename": filename})
+ return
+ rel_dir = target_rel
else:
- rel_dir = f"{upload_root.name}/{UPLOAD_DIR_NAME}"
- target_dir = upload_root.path / UPLOAD_DIR_NAME
- try:
- target_dir.mkdir(parents=True, exist_ok=True)
- except OSError as e:
- log.warning("Cannot create upload folder in root %r: %s",
- upload_root.name, e)
- self._send({"type": "error", "detail": "Upload folder unavailable",
+ # An MNP 1.0 client names nothing; the root itself is where its one
+ # destination now is.
+ target_dir = upload_root.path
+ rel_dir = upload_root.name
+ if not target_dir.is_dir():
+ self._send({"type": "error",
+ "detail": f"Directory '{upload_root.name}' is "
+ f"currently unavailable",
+ "code": "root_unavailable",
"filename": filename})
return
diff --git a/packages/meshbay-node/tests/test_root_writable_policy.py b/packages/meshbay-node/tests/test_root_writable_policy.py
index da95032..d7f2666 100644
--- a/packages/meshbay-node/tests/test_root_writable_policy.py
+++ b/packages/meshbay-node/tests/test_root_writable_policy.py
@@ -62,14 +62,16 @@ def _session(tmp_path: Path, user_id: str, *,
def _upload(session, filename="clip.mp4", body=b"bytes"):
session._do_file_upload({
- "filename": filename, "root": "shared",
+ "filename": filename, "dir": "shared",
"chunk_index": 0, "total_chunks": 1,
"data": base64.b64encode(body).decode(),
})
def _uploads_dir(session) -> Path:
- return session._ctx["roots"].roots[0].path / "uploads"
+ # The root itself: the `uploads/` subdirectory the node used to create is
+ # gone (see test_security_regressions._uploads_dir for why).
+ return session._ctx["roots"].roots[0].path
# ── The door, not the button ─────────────────────────────────────────────────
@@ -80,7 +82,7 @@ async def test_a_member_cannot_upload_to_a_read_only_root(tmp_path):
refusal = [m for m in session.sent if m.get("type") == "error"]
assert refusal and refusal[0].get("code") == "root_read_only"
- assert not _uploads_dir(session).exists()
+ assert not (_uploads_dir(session) / "clip.mp4").exists()
async def test_members_upload_normally_to_a_writable_root(tmp_path):
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
index 9db8ac1..1a318f7 100644
--- a/packages/meshbay-node/tests/test_security_regressions.py
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -133,14 +133,22 @@ def test_the_node_never_generates_a_name_it_would_refuse(tmp_path):
def _uploads_dir(session) -> Path:
"""
- Where this session's uploads land: uploads/ inside its first writable root.
+ Where an unaddressed upload lands: the first writable root itself.
+
+ There is no `uploads/` subdirectory any more. It was the last of v5's
+ quarantine — the per-user layer went on 2026-08-14 — and it went for the
+ same reason: a folder appearing beside the operator's library because
+ somebody sent a file is the node deciding how their disk is arranged. The
+ protections that made the quarantine worth having are the allowlist, the
+ size cap, the chunk ordering and the no-overwrite rule, and every one of
+ them is asserted below, unchanged.
Asked of the root set rather than assembled by hand, so a test cannot pass
while agreeing with a wrong answer the code also produced.
"""
writable = session._ctx["roots"].writable_roots
assert writable, "the fixture must give the group a writable root"
- return writable[0].path / "uploads"
+ return writable[0].path
def _session(tmp_path: Path, user_id: str) -> WebRTCPeerSession:
@@ -176,7 +184,6 @@ def test_upload_cannot_overwrite_another_members_file(tmp_path):
"""
victim = _session(tmp_path, "victim-user")
uploads = _uploads_dir(victim)
- uploads.mkdir()
original = uploads / "important.mp4"
original.write_bytes(b"operator's original content")
@@ -226,50 +233,57 @@ def test_dir_create_cannot_escape_the_shared_root(tmp_path, bad):
assert set(tmp_path.rglob("*")) == before, f"created something via {bad!r}"
-def test_upload_ignores_any_directory_the_client_asks_for(tmp_path):
+def test_the_client_names_a_folder_and_never_a_filesystem_path(tmp_path):
"""
- The destination inside a root is the node's decision, and stays so.
+ The destination is now the folder the sender is looking at, which means the
+ client does choose it — and the whole of what keeps that safe is that the
+ choice is *resolved against the group's own roots* rather than joined to
+ one.
- A client now names the *root* it is uploading into — it has to, once a group
- can have several writable ones — but that is a name looked up in the root
- table, never a path. Everything below the root is still chosen here, so the
- traversal surface a client-chosen destination would open does not exist.
+ `RootSet.resolve()` refuses `..`, absolute segments and anything whose
+ resolved form escapes its root, symlinks included. So "which of this
+ group's folders" is answerable by a member and "which path on the
+ operator's disk" is not.
"""
session = _session(tmp_path, "user-1")
+ (session._ctx["roots"].roots[0].path / "sub").mkdir()
+ before = set(tmp_path.rglob("*"))
- session._do_file_upload({
- "filename": "note.txt", "dir": "../../etc", "path": "/etc",
- "chunk_index": 0, "total_chunks": 1,
- "data": base64.b64encode(b"x").decode(),
- })
+ for bad in ("../../etc", "/etc", "shared/../..", "shared/../../etc",
+ "nope", "shared/missing"):
+ session.sent.clear()
+ session._do_file_upload({
+ "filename": "note.txt", "dir": bad,
+ "chunk_index": 0, "total_chunks": 1,
+ "data": base64.b64encode(b"x").decode(),
+ })
+ refusal = [m for m in session.sent if m.get("type") == "error"]
+ assert refusal, f"{bad!r} was accepted"
+ assert refusal[0].get("code") in ("no_such_root", "no_such_directory"), bad
- assert (_uploads_dir(session) / "note.txt").read_bytes() == b"x"
- assert not (tmp_path / "etc").exists()
+ assert set(tmp_path.rglob("*")) == before, "a refused upload still wrote"
-@pytest.mark.parametrize("named_root", [
- "../../etc", "/etc", "shared/../..", "Shared/uploads", "nope",
-])
-def test_a_root_name_is_looked_up_never_joined(tmp_path, named_root):
+def test_an_upload_lands_in_the_folder_it_names(tmp_path):
"""
- The name the client sends is matched against the group's root table and
- refused when it matches nothing. A version that joined it to a path — or
- that quietly fell back to the first writable root — would turn "which
- directory" into either a traversal or a file on a disk the operator did
- not intend, and the second is discovered weeks later.
+ And in that folder itself — the `uploads/` subdirectory the node used to
+ create is gone. Somebody dropping a file into the folder they are looking
+ at expects it to be in that folder.
"""
session = _session(tmp_path, "user-1")
- before = set(tmp_path.rglob("*"))
+ root = session._ctx["roots"].roots[0]
+ (root.path / "Albums").mkdir()
session._do_file_upload({
- "filename": "note.txt", "root": named_root,
+ "filename": "note.txt", "dir": f"{root.name}/Albums",
"chunk_index": 0, "total_chunks": 1,
"data": base64.b64encode(b"x").decode(),
})
- refusal = [m for m in session.sent if m.get("type") == "error"]
- assert refusal and refusal[0].get("code") == "no_such_root", named_root
- assert set(tmp_path.rglob("*")) == before, f"wrote something via {named_root!r}"
+ assert (root.path / "Albums" / "note.txt").read_bytes() == b"x"
+ assert not (root.path / "Albums" / "uploads").exists(), (
+ "the node invented a subdirectory in the operator's library")
+ assert not (root.path / "uploads").exists()
def test_an_upload_goes_to_the_root_it_names(tmp_path):
@@ -291,13 +305,13 @@ def test_an_upload_goes_to_the_root_it_names(tmp_path):
])
session._do_file_upload({
- "filename": "note.txt", "root": "Incoming",
+ "filename": "note.txt", "dir": "Incoming",
"chunk_index": 0, "total_chunks": 1,
"data": base64.b64encode(b"x").decode(),
})
- assert (incoming / "uploads" / "note.txt").read_bytes() == b"x"
- assert not (media / "uploads").exists(), "it went to the first root instead"
+ assert (incoming / "note.txt").read_bytes() == b"x"
+ assert not (media / "note.txt").exists(), "it went to the first root instead"
def test_a_read_only_root_refuses_an_upload(tmp_path):
@@ -314,14 +328,14 @@ def test_a_read_only_root_refuses_an_upload(tmp_path):
session._is_node_admin = lambda: True
session._do_file_upload({
- "filename": "note.txt", "root": "Published",
+ "filename": "note.txt", "dir": "Published",
"chunk_index": 0, "total_chunks": 1,
"data": base64.b64encode(b"x").decode(),
})
refusal = [m for m in session.sent if m.get("type") == "error"]
assert refusal and refusal[0].get("code") == "root_read_only"
- assert not (published / "uploads").exists()
+ assert not (published / "note.txt").exists()
def test_a_fully_read_only_group_refuses_an_unaddressed_upload(tmp_path):
@@ -343,7 +357,7 @@ def test_a_fully_read_only_group_refuses_an_unaddressed_upload(tmp_path):
refusal = [m for m in session.sent if m.get("type") == "error"]
assert refusal and refusal[0].get("code") == "no_writable_root"
- assert not (published / "uploads").exists()
+ assert not (published / "note.txt").exists()
def test_an_ejected_root_refuses_an_upload(tmp_path):
@@ -362,14 +376,14 @@ def test_an_ejected_root_refuses_an_upload(tmp_path):
session._ctx["roots"] = roots
session._do_file_upload({
- "filename": "note.txt", "root": "USB",
+ "filename": "note.txt", "dir": "USB",
"chunk_index": 0, "total_chunks": 1,
"data": base64.b64encode(b"x").decode(),
})
refusal = [m for m in session.sent if m.get("type") == "error"]
assert refusal and refusal[0].get("code") == "root_unavailable"
- assert not (usb / "uploads").exists()
+ assert not (usb / "note.txt").exists()
def test_two_members_can_send_the_same_filename(tmp_path):