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.py70
1 files changed, 70 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py
index bc27bf7..74ea2f6 100644
--- a/packages/meshbay-node/src/meshbay_node/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/roots.py
@@ -29,6 +29,7 @@ are one directory.
from __future__ import annotations
import logging
+import re
from dataclasses import dataclass, field
from pathlib import Path
@@ -38,6 +39,75 @@ log = logging.getLogger(__name__)
VALID_KINDS = ("generic", "video", "audio", "photo")
+# Filenames and subdirectory names sent by clients. A leading dot is a hidden
+# file on every platform, a leading hyphen confuses CLI tools, a leading space
+# cannot start one, and a trailing space or dot is refused because it makes two
+# different files look identical in a list.
+SAFE_UPLOAD_NAME = re.compile(
+ r"^[^\W_]" # letter or digit — never ‘.’, ‘-’ or space
+ r"[\w .\-()\[\]'\u2019,&+#@]{0,127}" # body: word chars plus mild punctuation
+ r"(?<![ .])$", # and never ending on a space or a dot
+ re.UNICODE)
+
+
+def _free_name(directory: Path, filename: str) -> 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).
+ """
+ if not (directory / filename).exists():
+ 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():
+ return candidate
+ raise FileExistsError(filename)
+
+
+def safe_subdir(roots: "RootSet", rel: str) -> Path | None:
+ """
+ Resolve a client-supplied directory inside one of the group's roots, or refuse.
+
+ The path arrives from the wire, so every part is checked: the first segment
+ must name a root that is readable right now, each later segment against the
+ same allowlist as filenames, and the resolved result against that root's
+ directory. `..`, absolute paths, symlinks pointing out, and anything with a
+ separator in a segment are all refused here rather than in the caller, so
+ there is one place to get it right.
+
+ The virtual root itself — `""` — is deliberately **not** resolvable. It is
+ not a directory on anyone's disk: a file cannot be written there and a
+ directory cannot be created there, because it belongs to no volume. Callers
+ that used to receive the shared root for an empty path now receive None,
+ which is the honest answer.
+
+ The quarantine was the fix for C5a; what actually mattered in it — no
+ overwrite, a name allowlist, and confinement — is kept by this plus the
+ caller's existing checks.
+ """
+ found = roots.split(rel or "")
+ if found is None:
+ return None
+ root, tail = found
+ if not root.available:
+ return None
+ parts = [seg for seg in tail.split("/") if seg not in ("", ".")]
+ if any(seg == ".." or not SAFE_UPLOAD_NAME.match(seg) for seg in parts):
+ return None
+ try:
+ target = (root.path / Path(*parts)).resolve() if parts else root.path.resolve()
+ base = root.path.resolve()
+ except OSError:
+ return None
+ if target != base and base not in target.parents:
+ return None
+ return target
+
class RootError(ValueError):
"""A root set that cannot be built. The message is shown to the operator."""