aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/roots.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/roots.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/roots.py63
1 files changed, 58 insertions, 5 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py
index 89b0441..2de0708 100644
--- a/packages/meshbay-node/src/meshbay_node/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/roots.py
@@ -30,6 +30,7 @@ from __future__ import annotations
import asyncio
import logging
+import os
import re
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
@@ -52,25 +53,77 @@ SAFE_UPLOAD_NAME = re.compile(
re.UNICODE)
-def _free_name(directory: Path, filename: str) -> str:
+# Files Windows Explorer acts on by itself when it shows a folder: a link's
+# icon, a folder's settings, a search connector. Placed by a member in a folder
+# the operator browses, any of them can make Explorer contact a server of the
+# member's choosing with the operator's Windows credentials — a known attack,
+# and why mail providers refuse the same types. Refused for every node: a
+# Linux node's folder may be shared to Windows machines.
+SHELL_ACTIVE_NAMES = frozenset({"desktop.ini"})
+SHELL_ACTIVE_SUFFIXES = (".lnk", ".url", ".scf", ".library-ms", ".searchconnector-ms")
+
+
+def shell_active(filename: str) -> bool:
+ name = filename.lower()
+ return name in SHELL_ACTIVE_NAMES or name.endswith(SHELL_ACTIVE_SUFFIXES)
+
+
+def _free_name(directory: Path, filename: str,
+ taken: frozenset[str] | set[str] = frozenset()) -> str:
"""
`filename`, or the first "name (n).ext" that is not taken.
- Never returns the name of a file that exists, so an upload cannot replace
- one — the property the per-user quarantine used to provide (C5a).
+ Never returns the name of a file that exists, nor one in `taken` — names
+ uploads in flight will publish under — so an upload cannot replace a file
+ or another upload (C5a).
"""
- if not (directory / filename).exists():
+ def free(name: str) -> bool:
+ return name not in taken and not (directory / name).exists()
+
+ if free(filename):
return filename
stem, dot, ext = filename.rpartition(".")
if not dot:
stem, ext = filename, ""
for n in range(2, 1000):
candidate = f"{stem} ({n}){dot}{ext}"
- if not (directory / candidate).exists():
+ if free(candidate):
return candidate
raise FileExistsError(filename)
+def publish_upload(part: Path, directory: Path, stored_name: str, filename: str,
+ taken: frozenset[str] | set[str] = frozenset()) -> str:
+ """
+ Move a finished `.part` to its name without ever replacing a file. The name
+ it was published under, which may not be `stored_name`.
+
+ A rename replaces whatever is at the target, and the target can appear
+ while the upload runs — the operator copying a file in, another group's
+ upload into a shared folder. A hard link refuses an existing target, so it
+ is the publication; where the filesystem has none (FAT, exFAT, some network
+ shares), the existence check and the rename are as close as it gets. A
+ taken name moves on to the next free one rather than failing the upload.
+ """
+ name = stored_name
+ for _ in range(8):
+ target = directory / name
+ try:
+ os.link(part, target)
+ except FileExistsError:
+ name = _free_name(directory, filename, taken)
+ continue
+ except OSError:
+ if target.exists():
+ name = _free_name(directory, filename, taken)
+ continue
+ part.rename(target)
+ return name
+ part.unlink()
+ return name
+ raise FileExistsError(stored_name)
+
+
def safe_subdir(roots: RootSet, rel: str) -> Path | None:
"""
Resolve a client-supplied directory inside one of the group's roots, or refuse.