Merge pull request #2087 from pbiering/issue-2081

* Fix: broken storage/mtime granularity detection on vfat
* Improve: `path_to_filesystem()` by pre-detection of collision-free file system
* Adjustment: MKCOL/MKCALENDAR return now CONFLICT instead of BADREQUEST of file name collision
This commit is contained in:
Peter Bieringer
2026-04-15 06:23:19 +02:00
committed by GitHub
13 changed files with 200 additions and 26 deletions

View File

@@ -1,6 +1,9 @@
# Changelog # Changelog
## 3.7.2.dev ## 3.7.2.dev
* Fix: broken storage/mtime granularity detection on vfat
* Improve: `path_to_filesystem()` by pre-detection of collision-free file system
* Adjustment: MKCOL/MKCALENDAR return now CONFLICT instead of BADREQUEST of file name collision
## 3.7.1 ## 3.7.1
* Fix: share address book collection as birthday calendar not working on non-DEBUG level * Fix: share address book collection as birthday calendar not working on non-DEBUG level

View File

@@ -90,6 +90,8 @@ class ApplicationPartMkcalendar(ApplicationBase):
return httputils.FORBIDDEN return httputils.FORBIDDEN
else: else:
return httputils.INTERNAL_SERVER_ERROR return httputils.INTERNAL_SERVER_ERROR
elif type(e) is pathutils.CollidingPathError:
return httputils.CONFLICT
else: else:
logger.warning( logger.warning(
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)

View File

@@ -94,6 +94,8 @@ class ApplicationPartMkcol(ApplicationBase):
return httputils.FORBIDDEN return httputils.FORBIDDEN
else: else:
return httputils.INTERNAL_SERVER_ERROR return httputils.INTERNAL_SERVER_ERROR
elif type(e) is pathutils.CollidingPathError:
return httputils.CONFLICT
else: else:
logger.warning( logger.warning(
"Bad MKCOL request on %r (type:%s): %s", path, collection_type, e, exc_info=True) "Bad MKCOL request on %r (type:%s): %s", path, collection_type, e, exc_info=True)

View File

@@ -27,6 +27,7 @@ import os
import pathlib import pathlib
import posixpath import posixpath
import sys import sys
import tempfile
import threading import threading
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Iterator, Type, Union from typing import Iterator, Type, Union
@@ -266,28 +267,45 @@ def is_safe_filesystem_path_component(path: str) -> bool:
is_safe_path_component(path)) 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`. """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. `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 ``/``. `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, Conversion of `sane_path` is done in a secure manner,
or raises ``ValueError``. 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)) assert sane_path == strip_path(sanitize_path(sane_path))
safe_path = root safe_path = root
parts = sane_path.split("/") if sane_path else [] parts = sane_path.split("/") if sane_path else []
for part in parts: for part in parts:
if not is_safe_filesystem_path_component(part): if not is_safe_filesystem_path_component(part):
raise UnsafePathError(part) raise UnsafePathError(part)
safe_path_parent = safe_path
safe_path = os.path.join(safe_path, part) safe_path = os.path.join(safe_path, part)
# Check for conflicting files (e.g. case-insensitive file systems # Check for conflicting files (e.g. case-insensitive file systems
# or short names on Windows 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): if not path_is_collision_free:
if sys.platform == "win32" and False: # temporary for testing
# 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) 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 return safe_path
@@ -365,3 +383,72 @@ 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) 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 False
return True 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_case_sensitive(path):
# Test: case sensitive
if not os.path.isdir(path):
raise ValueError("%r is not a path" % (path))
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)
result = True
try:
os.mkdir(test_dir_lc)
except FileExistsError:
result = False
else:
# cleanup
os.rmdir(test_dir_lc)
# cleanup
os.rmdir(test_dir_uc)
os.rmdir(base_dir)
logger.debug("path_is_collision_free (case-sensitive): path=%r result=%s", path, result)
return result
def path_is_collision_free_no_short_filename(path):
"""Check whether path supports short-filename collision-free entries."""
if not os.path.isdir(path):
raise ValueError("%r is not a path" % (path))
base_dir = tempfile.mkdtemp(dir=path)
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)
result = True
try:
os.mkdir(test_dir_short)
except FileExistsError:
result = False
else:
# cleanup
os.rmdir(test_dir_short)
# cleanup
os.rmdir(test_dir_long)
os.rmdir(base_dir)
logger.debug("path_is_collision_free (no short-filename): path=%r result=%s", path, result)
return result

View File

@@ -104,15 +104,16 @@ class Storage(
logger.warning("Storage item mtime resolution test not possible, cannot write file: %r (%s)", path, e) logger.warning("Storage item mtime resolution test not possible, cannot write file: %r (%s)", path, e)
raise raise
# set mtime_ns for tests # set mtime_ns for tests
mtime_ns = os.stat(path).st_mtime_ns
try: try:
os.utime(path, times=None, ns=(MTIME_NS_TEST, MTIME_NS_TEST)) os.utime(path, times=None, ns=(mtime_ns + MTIME_NS_TEST, mtime_ns + MTIME_NS_TEST))
except Exception as e: except Exception as e:
logger.warning("Storage item mtime resolution test not possible, cannot set utime on file: %r (%s)", path, e) logger.warning("Storage item mtime resolution test not possible, cannot set utime on file: %r (%s)", path, e)
os.remove(path) os.remove(path)
raise raise
logger.debug("Storage item mtime resoultion test set: %d" % MTIME_NS_TEST) logger.debug("Storage item mtime resoultion test set: %d ns" % MTIME_NS_TEST)
mtime_ns = os.stat(path).st_mtime_ns mtime_ns = os.stat(path).st_mtime_ns - mtime_ns
logger.debug("Storage item mtime resoultion test get: %d" % mtime_ns) logger.debug("Storage item mtime resoultion test get: %d ns" % mtime_ns)
# start analysis # start analysis
precision = 1 precision = 1
mtime_ns_test = MTIME_NS_TEST mtime_ns_test = MTIME_NS_TEST
@@ -171,6 +172,14 @@ class Storage(
logger.warning("Storage location subfolder: %r does not exist, creating now", self._get_collection_root_folder()) logger.warning("Storage location subfolder: %r does not exist, creating now", self._get_collection_root_folder())
self._makedirs_synced(self._get_collection_root_folder()) self._makedirs_synced(self._get_collection_root_folder())
logger.info("Storage location subfolder permissions: %s", pathutils.path_permissions_as_string(self._get_collection_root_folder())) logger.info("Storage location subfolder permissions: %s", pathutils.path_permissions_as_string(self._get_collection_root_folder()))
logger.info("Storage location subfolder softlink support: %s", pathutils.path_supports_symlink(self._get_collection_root_folder()))
filesystem_root_folder_is_collision_free_case_sensitive = pathutils.path_is_collision_free_case_sensitive(self._get_collection_root_folder())
filesystem_root_folder_is_collision_free_no_short_filename = pathutils.path_is_collision_free_no_short_filename(self._get_collection_root_folder())
self._filesystem_root_folder_is_collision_free = filesystem_root_folder_is_collision_free_case_sensitive and filesystem_root_folder_is_collision_free_no_short_filename
logger.info("Storage location subfolder is collision free: %s (case-sensitive=%s no-short-filename=%s)",
self._filesystem_root_folder_is_collision_free,
filesystem_root_folder_is_collision_free_case_sensitive,
filesystem_root_folder_is_collision_free_no_short_filename)
logger.info("Storage cache subfolder usage for 'item': %s", self._use_cache_subfolder_for_item) logger.info("Storage cache subfolder usage for 'item': %s", self._use_cache_subfolder_for_item)
logger.info("Storage cache subfolder usage for 'history': %s", self._use_cache_subfolder_for_history) logger.info("Storage cache subfolder usage for 'history': %s", self._use_cache_subfolder_for_history)
logger.info("Storage cache subfolder usage for 'sync-token': %s", self._use_cache_subfolder_for_synctoken) logger.info("Storage cache subfolder usage for 'sync-token': %s", self._use_cache_subfolder_for_synctoken)

View File

@@ -32,6 +32,7 @@ class CollectionBase(storage.BaseCollection):
_path: str _path: str
_encoding: str _encoding: str
_filesystem_path: str _filesystem_path: str
_filesystem_root_folder_is_collision_free: bool
def __init__(self, storage_: "multifilesystem.Storage", path: str, def __init__(self, storage_: "multifilesystem.Storage", path: str,
filesystem_path: Optional[str] = None) -> None: filesystem_path: Optional[str] = None) -> None:
@@ -42,8 +43,9 @@ class CollectionBase(storage.BaseCollection):
self._path = pathutils.strip_path(path) self._path = pathutils.strip_path(path)
self._encoding = storage_.configuration.get("encoding", "stock") self._encoding = storage_.configuration.get("encoding", "stock")
self._skip_broken_item = storage_.configuration.get("storage", "skip_broken_item") self._skip_broken_item = storage_.configuration.get("storage", "skip_broken_item")
self._filesystem_root_folder_is_collision_free = storage_._filesystem_root_folder_is_collision_free
if filesystem_path is None: if filesystem_path is None:
filesystem_path = pathutils.path_to_filesystem(folder, self.path) filesystem_path = pathutils.path_to_filesystem(folder, self.path, self._filesystem_root_folder_is_collision_free)
self._filesystem_path = filesystem_path self._filesystem_path = filesystem_path
# TODO: better fix for "mypy" # TODO: better fix for "mypy"
@@ -79,6 +81,7 @@ class StorageBase(storage.BaseStorage):
_folder_umask: str _folder_umask: str
_config_umask: int _config_umask: int
_max_resource_size: int _max_resource_size: int
_filesystem_root_folder_is_collision_free: bool = False
def __init__(self, configuration: config.Configuration) -> None: def __init__(self, configuration: config.Configuration) -> None:
super().__init__(configuration) super().__init__(configuration)

View File

@@ -65,7 +65,7 @@ class StoragePartCreateCollection(StorageBase):
# Path should already be sanitized # Path should already be sanitized
sane_path = pathutils.strip_path(href) sane_path = pathutils.strip_path(href)
filesystem_path = pathutils.path_to_filesystem(folder, sane_path) filesystem_path = pathutils.path_to_filesystem(folder, sane_path, self._filesystem_root_folder_is_collision_free)
logger.debug("Create collection: %r" % filesystem_path) logger.debug("Create collection: %r" % filesystem_path)
if not props: if not props:

View File

@@ -46,7 +46,7 @@ class CollectionPartDelete(CollectionPartHistory, CollectionBase):
# Delete an item # Delete an item
if not pathutils.is_safe_filesystem_path_component(href): if not pathutils.is_safe_filesystem_path_component(href):
raise pathutils.UnsafePathError(href) raise pathutils.UnsafePathError(href)
path = pathutils.path_to_filesystem(self._filesystem_path, href) path = pathutils.path_to_filesystem(self._filesystem_path, href, self._filesystem_root_folder_is_collision_free)
if not os.path.isfile(path): if not os.path.isfile(path):
raise storage.ComponentNotFoundError(href) raise storage.ComponentNotFoundError(href)
os.remove(path) os.remove(path)

View File

@@ -52,11 +52,11 @@ class StoragePartDiscover(StorageBase):
# Create the root collection # Create the root collection
self._makedirs_synced(folder) self._makedirs_synced(folder)
try: try:
filesystem_path = pathutils.path_to_filesystem(folder, sane_path) filesystem_path = pathutils.path_to_filesystem(folder, sane_path, self._filesystem_root_folder_is_collision_free)
except ValueError as e: except ValueError as e:
# Path is unsafe # Path is unsafe
logger.debug("Unsafe path %r requested from storage: %s", logger.warning("Unsafe path %r requested from storage: %s",
sane_path, e, exc_info=True) sane_path, e, exc_info=False)
return return
# Check if the path exists and if it leads to a collection or an item # Check if the path exists and if it leads to a collection or an item
@@ -110,7 +110,7 @@ class StoragePartDiscover(StorageBase):
href = base64.b64encode(group.encode('utf-8')).decode('ascii') href = base64.b64encode(group.encode('utf-8')).decode('ascii')
logger.debug(f"searching for group calendar {group} {href}") logger.debug(f"searching for group calendar {group} {href}")
sane_child_path = f"GROUPS/{href}" sane_child_path = f"GROUPS/{href}"
if not os.path.isdir(pathutils.path_to_filesystem(folder, sane_child_path)): if not os.path.isdir(pathutils.path_to_filesystem(folder, sane_child_path, self._filesystem_root_folder_is_collision_free)):
continue continue
child_path = f"/GROUPS/{href}/" child_path = f"/GROUPS/{href}/"
with child_context_manager(sane_child_path, None): with child_context_manager(sane_child_path, None):

View File

@@ -59,7 +59,8 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
if not pathutils.is_safe_filesystem_path_component(href): if not pathutils.is_safe_filesystem_path_component(href):
raise pathutils.UnsafePathError(href) raise pathutils.UnsafePathError(href)
path = pathutils.path_to_filesystem(self._filesystem_path, path = pathutils.path_to_filesystem(self._filesystem_path,
href) href,
self._filesystem_root_folder_is_collision_free)
except ValueError as e: except ValueError as e:
logger.debug( logger.debug(
"Can't translate name %r safely to filesystem in %r: %s", "Can't translate name %r safely to filesystem in %r: %s",

View File

@@ -35,8 +35,8 @@ class StoragePartMove(StorageBase):
assert isinstance(to_collection, multifilesystem.Collection) assert isinstance(to_collection, multifilesystem.Collection)
assert isinstance(item.collection, multifilesystem.Collection) assert isinstance(item.collection, multifilesystem.Collection)
assert item.href assert item.href
move_from = pathutils.path_to_filesystem(item.collection._filesystem_path, item.href) move_from = pathutils.path_to_filesystem(item.collection._filesystem_path, item.href, self._filesystem_root_folder_is_collision_free)
move_to = pathutils.path_to_filesystem(to_collection._filesystem_path, to_href) move_to = pathutils.path_to_filesystem(to_collection._filesystem_path, to_href, self._filesystem_root_folder_is_collision_free)
try: try:
os.replace(move_from, move_to) os.replace(move_from, move_to)
except OSError as e: except OSError as e:

View File

@@ -39,7 +39,7 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache,
) -> Tuple[radicale_item.Item, Optional[radicale_item.Item]]: ) -> Tuple[radicale_item.Item, Optional[radicale_item.Item]]:
if not pathutils.is_safe_filesystem_path_component(href): if not pathutils.is_safe_filesystem_path_component(href):
raise pathutils.UnsafePathError(href) raise pathutils.UnsafePathError(href)
path = pathutils.path_to_filesystem(self._filesystem_path, href) path = pathutils.path_to_filesystem(self._filesystem_path, href, self._filesystem_root_folder_is_collision_free)
old_item = self._get(href, verify_href=False) old_item = self._get(href, verify_href=False)
try: try:
with self._atomic_write(path, newline="") as fo: # type: ignore with self._atomic_write(path, newline="") as fo: # type: ignore

View File

@@ -26,13 +26,13 @@ import logging
import os import os
import re import re
import shutil import shutil
import sys import tempfile
from typing import ClassVar, cast from typing import ClassVar, cast
import pytest import pytest
import radicale.tests.custom.storage_simple_sync import radicale.tests.custom.storage_simple_sync
from radicale import logger from radicale import logger, pathutils
from radicale.tests import BaseTest from radicale.tests import BaseTest
from radicale.tests.helpers import get_file_content from radicale.tests.helpers import get_file_content
from radicale.tests.test_base import TestBaseRequests as _TestBaseRequests from radicale.tests.test_base import TestBaseRequests as _TestBaseRequests
@@ -190,7 +190,7 @@ class TestMultiFileSystem(BaseTest):
assert answer is not None assert answer is not None
assert "\r\nUID:%s\r\n" % uid in answer assert "\r\nUID:%s\r\n" % uid in answer
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows") @pytest.mark.skipif(not pathutils.path_supports_symlink(tempfile.mkdtemp()), reason="TEMP is not supporting symlink")
def test_collection_sharing_by_softlink(self) -> None: def test_collection_sharing_by_softlink(self) -> None:
"""Test collection sharing by softlink.""" """Test collection sharing by softlink."""
self.configure({"auth": {"type": "none"}}) self.configure({"auth": {"type": "none"}})
@@ -198,11 +198,11 @@ class TestMultiFileSystem(BaseTest):
file_item = "event1.ics" file_item = "event1.ics"
self.mkcalendar(path_group_col, login="group:grouppw") self.mkcalendar(path_group_col, login="group:grouppw")
event = get_file_content(file_item) event = get_file_content(file_item)
self.put(os.path.join(path_group_col, file_item), event) self.put(path_group_col + file_item, event)
fs_path_group_col = self.colpath + "/collection-root" + path_group_col fs_path_group_col = os.path.join(self.colpath, "collection-root", "group", "calendar-shared.ics")
fs_path_group_col_rel = ".." + path_group_col fs_path_group_col_rel = os.path.join("..", "group", "calendar-shared.ics")
fs_path_user = self.colpath + "/collection-root/user" fs_path_user = os.path.join(self.colpath, "collection-root", "user")
fs_path_user_col = self.colpath + "/collection-root/user/calendar-group.ics" fs_path_user_col = os.path.join(self.colpath, "collection-root", "user", "calendar-group.ics")
logger.debug("colpath=%r fs_path_group_col=%r", self.colpath, fs_path_group_col) logger.debug("colpath=%r fs_path_group_col=%r", self.colpath, fs_path_group_col)
assert os.path.isdir(fs_path_group_col) assert os.path.isdir(fs_path_group_col)
# create user directory and check # create user directory and check
@@ -218,6 +218,73 @@ class TestMultiFileSystem(BaseTest):
assert os.path.islink(fs_path_user_col) assert os.path.islink(fs_path_user_col)
self.propfind("/user/", login="user:userpw", HTTP_DEPTH="1") self.propfind("/user/", login="user:userpw", HTTP_DEPTH="1")
def test_colliding_items_by_file_case_insensitive(self, caplog) -> None:
"""Test for colliding files on file systems."""
caplog.set_level(logging.WARNING)
self.configure({"logging": {"request_content_on_debug": "False"}})
fs_collision_free = pathutils.path_is_collision_free_case_sensitive(self.colpath)
file_item = "EvEnT1.iCs"
path_coll = "/calendar.ics/"
self.mkcalendar(path_coll)
event = get_file_content(file_item.lower())
path_item = os.path.join(path_coll, file_item)
path_uc = os.path.join(path_coll, file_item.upper())
path_lc = os.path.join(path_coll, file_item.lower())
self.put(path_item, event)
self.put(path_uc, event, check=409)
if not fs_collision_free:
logs = caplog.messages
assert len([log for log in logs if "File name collision" in log]) == 1
self.put(path_lc, event, check=409)
if not fs_collision_free:
logs = caplog.messages
assert len([log for log in logs if "File name collision" in log]) == 2
def test_colliding_items_by_dir_case_insensitive(self, caplog) -> None:
"""Test for colliding dirs on file systems."""
caplog.set_level(logging.WARNING)
self.configure({"logging": {"request_content_on_debug": "False"}})
fs_collision_free = pathutils.path_is_collision_free_case_sensitive(self.colpath)
path_coll = "/CaLeNdAr.ics/"
self.mkcalendar(path_coll)
if fs_collision_free:
self.mkcalendar(path_coll.lower(), check=201)
else:
self.mkcalendar(path_coll.lower(), check=409)
logs = caplog.messages
assert len([log for log in logs if "File name collision" in log]) == 1
if fs_collision_free:
self.mkcalendar(path_coll.upper(), check=201)
else:
self.mkcalendar(path_coll.upper(), check=409)
logs = caplog.messages
assert len([log for log in logs if "File name collision" in log]) == 2
def test_colliding_items_by_dir_shortname(self, caplog) -> None:
"""Test for colliding dirs (shortname) on file systems."""
caplog.set_level(logging.WARNING)
self.configure({"logging": {"request_content_on_debug": "False"}})
fs_collision_free = pathutils.path_is_collision_free_no_short_filename(self.colpath)
fs_collision_free_case_sensitive = pathutils.path_is_collision_free_case_sensitive(self.colpath)
path_coll = "/calendarlongname.ics/"
path_coll_short = "/calend~1.ics/"
self.mkcalendar(path_coll)
if fs_collision_free:
self.mkcalendar(path_coll_short.lower(), check=201)
if not fs_collision_free_case_sensitive:
# cleanup to avoid collision below
self.delete(path_coll_short.lower())
else:
self.mkcalendar(path_coll_short.lower(), check=409)
logs = caplog.messages
assert len([log for log in logs if "File name collision" in log]) == 1
if fs_collision_free:
self.mkcalendar(path_coll_short.upper(), check=201)
else:
self.mkcalendar(path_coll_short.upper(), check=409)
logs = caplog.messages
assert len([log for log in logs if "File name collision" in log]) == 2
@pytest.mark.skipif(not shutil.which("flock"), reason="flock command not found") @pytest.mark.skipif(not shutil.which("flock"), reason="flock command not found")
@pytest.mark.skipif(radicale.log.logger.getEffectiveLevel() == logging.INFO, reason="requires loglevel DEBUG") @pytest.mark.skipif(radicale.log.logger.getEffectiveLevel() == logging.INFO, reason="requires loglevel DEBUG")
def test_hook_placeholders_PUT(self, caplog) -> None: def test_hook_placeholders_PUT(self, caplog) -> None: