Skip to content

Commit c08ffd3

Browse files
authored
Merge pull request #1973 from tobixen/fix-unclosed-scandir
Fix unclosed scandir iterator in path_to_filesystem
2 parents 5b5fc8d + 0388051 commit c08ffd3

File tree

2 files changed

+95
-3
lines changed

2 files changed

+95
-3
lines changed

radicale/pathutils.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,10 @@ def path_to_filesystem(root: str, sane_path: str) -> str:
286286
safe_path = os.path.join(safe_path, part)
287287
# Check for conflicting files (e.g. case-insensitive file systems
288288
# or short names on Windows file systems)
289-
if (os.path.lexists(safe_path) and
290-
part not in (e.name for e in os.scandir(safe_path_parent))):
291-
raise CollidingPathError(part)
289+
if os.path.lexists(safe_path):
290+
with os.scandir(safe_path_parent) as entries:
291+
if part not in (e.name for e in entries):
292+
raise CollidingPathError(part)
292293
return safe_path
293294

294295

radicale/tests/test_pathutils.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# This file is part of Radicale - CalDAV and CardDAV server
2+
# Copyright © 2025 Tobias Brox <tobias@tobix.eu>
3+
#
4+
# This library is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This library is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
16+
17+
"""
18+
Tests for pathutils module.
19+
20+
"""
21+
22+
import gc
23+
import os
24+
import tempfile
25+
26+
import pytest
27+
28+
from radicale import pathutils
29+
30+
31+
class TestPathToFilesystem:
32+
"""Tests for path_to_filesystem function."""
33+
34+
@pytest.mark.filterwarnings("error::ResourceWarning")
35+
@pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning")
36+
def test_scandir_iterator_closed(self) -> None:
37+
"""Verify that os.scandir iterator is properly closed.
38+
39+
This test catches ResourceWarning: unclosed scandir iterator
40+
which occurs when os.scandir() is used without a context manager.
41+
See: https://github.com/Kozea/Radicale/issues/1972
42+
43+
The ResourceWarning is emitted during garbage collection when an
44+
unclosed scandir iterator is finalized. We use pytest.mark.filterwarnings
45+
to convert both ResourceWarning and PytestUnraisableExceptionWarning
46+
to errors.
47+
"""
48+
with tempfile.TemporaryDirectory() as tmpdir:
49+
# Create a subdirectory so path_to_filesystem has something
50+
# to scan (the scandir check is for case-insensitive filesystems)
51+
subdir = os.path.join(tmpdir, "testdir")
52+
os.makedirs(subdir)
53+
54+
# Call path_to_filesystem - if scandir iterator is not closed,
55+
# a ResourceWarning will be emitted during garbage collection
56+
result = pathutils.path_to_filesystem(tmpdir, "testdir")
57+
assert result == subdir
58+
59+
# Force garbage collection to trigger any ResourceWarning
60+
# from unclosed iterators
61+
gc.collect()
62+
63+
def test_path_to_filesystem_basic(self) -> None:
64+
"""Test basic path_to_filesystem functionality."""
65+
with tempfile.TemporaryDirectory() as tmpdir:
66+
# Test empty path
67+
result = pathutils.path_to_filesystem(tmpdir, "")
68+
assert result == tmpdir
69+
70+
# Test single component
71+
subdir = os.path.join(tmpdir, "test")
72+
os.makedirs(subdir)
73+
result = pathutils.path_to_filesystem(tmpdir, "test")
74+
assert result == subdir
75+
76+
# Test nested path
77+
nested = os.path.join(subdir, "nested")
78+
os.makedirs(nested)
79+
result = pathutils.path_to_filesystem(tmpdir, "test/nested")
80+
assert result == nested
81+
82+
def test_unsafe_path_raises(self) -> None:
83+
"""Test that unsafe path components raise UnsafePathError."""
84+
with tempfile.TemporaryDirectory() as tmpdir:
85+
# Hidden files (starting with .) are not safe
86+
with pytest.raises(pathutils.UnsafePathError):
87+
pathutils.path_to_filesystem(tmpdir, ".hidden")
88+
89+
# Backup files (ending with ~) are not safe
90+
with pytest.raises(pathutils.UnsafePathError):
91+
pathutils.path_to_filesystem(tmpdir, "backup~")

0 commit comments

Comments
 (0)