summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/names.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/names.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/names.py69
1 files changed, 69 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/names.py b/packages/meshbay-cli/src/meshbay_cli/client/names.py
new file mode 100644
index 0000000..f6d7d32
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/client/names.py
@@ -0,0 +1,69 @@
+"""
+Names crossing between a group and this machine, in both directions.
+
+**Uploading**, the node decides: `SAFE_UPLOAD_NAME` and the Explorer-active types
+in `meshbay_node/roots.py`. This is a copy of that rule, checked before anything
+is sent so a folder holding one refused name is refused whole instead of
+arriving with holes in it — as the Files tab does with its own copy.
+`test_names.py` holds this copy to the node's answers.
+
+**Downloading**, a name from the index is someone else's text. It is made
+writable on this platform (`meshbay_common.paths.sanitize_for_download`, the
+same rule the browser applies), and the resolved path must stay under the
+destination: checked after resolution, never by comparing strings.
+"""
+
+import re
+from pathlib import Path
+
+from meshbay_common.paths import fold, sanitize_for_download
+
+UPLOAD_NAME = re.compile(
+ r"^[^\W_]"
+ r"[\w .\-()\[\]'’,&+#@]{0,127}"
+ r"(?<![ .])$",
+ re.UNICODE)
+
+SHELL_ACTIVE_NAMES = frozenset({"desktop.ini"})
+SHELL_ACTIVE_SUFFIXES = (".lnk", ".url", ".scf", ".library-ms", ".searchconnector-ms")
+
+
+def upload_name_problem(name: str, *, is_dir: bool = False) -> str | None:
+ if not UPLOAD_NAME.match(name):
+ return ("starts with something other than a letter or digit, ends with a space "
+ "or a dot, holds a character the node does not accept, or is longer "
+ "than 128 characters")
+ lower = name.lower()
+ if not is_dir and (lower in SHELL_ACTIVE_NAMES or lower.endswith(SHELL_ACTIVE_SUFFIXES)):
+ return "a file type Windows Explorer acts on by itself; no node accepts it"
+ return None
+
+
+def same_name(a: str, b: str) -> bool:
+ """As the node and the Files tab compare names in a folder: without case."""
+ return fold(a) == fold(b)
+
+
+class UnsafeName(Exception):
+ pass
+
+
+def local_path(dest: Path, parts: list[str]) -> tuple[Path, bool]:
+ """
+ Where a remote file or folder lands under `dest`, and whether a name had to
+ be changed to be written here.
+ """
+ renamed = False
+ out = dest
+ for part in parts:
+ safe = sanitize_for_download(part)
+ if safe in (".", ".."):
+ safe = "unnamed"
+ renamed |= safe != part
+ out = out / safe
+ base = dest.resolve()
+ try:
+ out.resolve().relative_to(base)
+ except ValueError:
+ raise UnsafeName(f"{'/'.join(parts)!r} would land outside {dest}") from None
+ return out, renamed