From 07e3a2cbadeb0ec6d5e4049a7a50225fcc48742a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 14 Apr 2026 08:56:13 +0200 Subject: [PATCH] pathutils: add tests for symlink support or collision-free folder and improve path_to_filesystem --- radicale/pathutils.py | 95 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/radicale/pathutils.py b/radicale/pathutils.py index d81b4bd1..34a80402 100644 --- a/radicale/pathutils.py +++ b/radicale/pathutils.py @@ -27,6 +27,7 @@ import os import pathlib import posixpath import sys +import tempfile import threading from tempfile import TemporaryDirectory from typing import Iterator, Type, Union @@ -266,28 +267,45 @@ def is_safe_filesystem_path_component(path: str) -> bool: is_safe_path_component(path)) -def path_to_filesystem(root: str, sane_path: str) -> str: +def path_to_filesystem(root: str, sane_path: str, path_is_collision_free: bool = False) -> str: """Convert `sane_path` to a local filesystem path relative to `root`. `root` must be a secure filesystem path, it will be prepend to the path. `sane_path` must be a sanitized path without leading or trailing ``/``. + `path_is_collision_free` is a toggle whether it was earlier detected as collision-free + Conversion of `sane_path` is done in a secure manner, or raises ``ValueError``. """ + # logger.trace("path_to_filesystem root=%r sane_path=%r path_is_collision_free=%s", root, sane_path, path_is_collision_free) assert sane_path == strip_path(sanitize_path(sane_path)) safe_path = root parts = sane_path.split("/") if sane_path else [] for part in parts: if not is_safe_filesystem_path_component(part): raise UnsafePathError(part) + safe_path_parent = safe_path safe_path = os.path.join(safe_path, part) # Check for conflicting files (e.g. case-insensitive file systems # or short names on Windows file systems) - if (os.path.lexists(safe_path) and not os.path.realpath(safe_path).endswith(part)) and not os.path.islink(safe_path): - raise CollidingPathError(part) + if not path_is_collision_free: + if sys.platform == "win32": + # logger.trace("path_to_filesystem check (win32): %r", part) + # if (os.path.lexists(safe_path) and not os.path.realpath(safe_path).endswith(part)) and not os.path.islink(safe_path): + if (os.path.lexists(safe_path) and not os.path.realpath(safe_path).endswith(part)): + raise CollidingPathError(part) + else: + # logger.trace("path_to_filesystem check (!win32): %r", part) + if os.path.lexists(safe_path): + with os.scandir(safe_path_parent) as entries: + if part not in (e.name for e in entries): + raise CollidingPathError(part) + else: + # logger.trace("path_to_filesystem check (skipped): %r", part) + pass return safe_path @@ -365,3 +383,74 @@ def file_check_size(path: str, limit: int): logger.warning("file skipped because size exceeds limit %s > %s: %r", utils.format_unit(size, binary=True), utils.format_unit(limit, binary=True), path) return False return True + + +def path_supports_symlink(path): + """Check whether path supports symlink.""" + if not os.path.isdir(path): + raise ValueError("%r is not a path" % (path)) + result = True + test_dir1 = tempfile.mkdtemp(dir=path) + test_dir2 = tempfile.mkdtemp(dir=path) + os.rmdir(test_dir2) + try: + os.symlink(test_dir1, test_dir2) + except PermissionError: + result = False + else: + # cleanup + os.remove(test_dir2) + finally: + # cleanup + os.rmdir(test_dir1) + return result + + +def path_is_collision_free(path): + """Check whether path supports case colliding-free entries.""" + if not os.path.isdir(path): + raise ValueError("%r is not a path" % (path)) + + result = True + + # Test 1: case sensitive + base_dir = tempfile.mkdtemp(dir=path) + test_dir = "TESTDIR" + test_dir_uc = os.path.join(base_dir, test_dir.upper()) + test_dir_lc = os.path.join(base_dir, test_dir.lower()) + os.mkdir(test_dir_uc) + try: + os.mkdir(test_dir_lc) + except FileExistsError: + result = False + else: + # cleanup + os.rmdir(test_dir_lc) + finally: + # cleanup + os.rmdir(test_dir_uc) + if not result: + # early exit + os.rmdir(base_dir) + logger.trace("path_is_collision_free: path=%r result=%s", path, result) + return result + + # Test 2: short filename + test_dir = "TESTDIRLONG" + test_dir_long = os.path.join(base_dir, test_dir) + test_dir_short = os.path.join(base_dir, test_dir[:6] + "~1") + os.mkdir(test_dir_long) + try: + os.mkdir(test_dir_short) + except FileExistsError: + result = False + else: + # cleanup + os.rmdir(test_dir_short) + finally: + # cleanup + os.rmdir(test_dir_long) + # final exit + os.rmdir(base_dir) + logger.trace("path_is_collision_free: path=%r result=%s", path, result) + return result