Skip to content

Commit f50a7bd

Browse files
Merge branch 'main' into feat/memory-lifecycle-ci-guards
2 parents 9c04757 + c4ba239 commit f50a7bd

12 files changed

Lines changed: 325 additions & 32 deletions

File tree

src/file_organizer/api/utils.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@
1313

1414

1515
def resolve_path(path_value: str, allowed_paths: Optional[list[str]] = None) -> Path:
16-
"""Expand and normalize a filesystem path."""
16+
"""Expand and normalize a filesystem path, then enforce allowed-root policy.
17+
18+
Uses ``Path.resolve()`` + ``Path.is_relative_to()`` to evaluate containment,
19+
which correctly handles symlinks, ``..`` sequences, and Windows drive-qualified
20+
paths — unlike a bare ``str.startswith`` or ``os.path.commonpath`` comparison.
21+
"""
1722
# Path is validated against allowed roots below.
18-
resolved = Path(path_value).expanduser() # codeql[py/path-injection]
19-
resolved_str = os.path.realpath(resolved)
23+
resolved = Path(path_value).expanduser().resolve() # codeql[py/path-injection]
2024
if not allowed_paths:
2125
raise ApiError(
2226
status_code=403,
@@ -26,15 +30,15 @@ def resolve_path(path_value: str, allowed_paths: Optional[list[str]] = None) ->
2630

2731
# Allowed roots are configuration-controlled.
2832
# codeql[py/path-injection]
29-
roots = [os.path.realpath(Path(root).expanduser()) for root in allowed_paths]
33+
roots = [Path(root).expanduser().resolve() for root in allowed_paths]
3034
if not roots:
3135
raise ApiError(
3236
status_code=403,
3337
error="path_not_allowed",
3438
message="No allowed paths configured for this API instance.",
3539
)
3640
try:
37-
allowed = any(os.path.commonpath([resolved_str, root]) == root for root in roots)
41+
allowed = any(resolved == root or resolved.is_relative_to(root) for root in roots)
3842
except ValueError:
3943
allowed = False
4044
if not allowed:
@@ -44,7 +48,7 @@ def resolve_path(path_value: str, allowed_paths: Optional[list[str]] = None) ->
4448
message="Path is outside allowed roots.",
4549
)
4650

47-
return Path(resolved_str)
51+
return resolved
4852

4953

5054
def is_hidden(path: Path) -> bool:

src/file_organizer/interfaces/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@
2525
BatchProcessorProtocol,
2626
FileProcessorProtocol,
2727
)
28-
from file_organizer.interfaces.storage import CacheProtocol, StorageProtocol
28+
from file_organizer.interfaces.storage import MISSING, CacheProtocol, StorageProtocol
2929

3030
__all__ = [
3131
"AudioModelProtocol",
3232
"BatchProcessorProtocol",
3333
"CacheProtocol",
3434
"FileProcessorProtocol",
3535
"LearnerProtocol",
36+
"MISSING",
3637
"PipelineStage",
3738
"ScorerProtocol",
3839
"StageContext",

src/file_organizer/interfaces/pipeline.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from __future__ import annotations
99

1010
from dataclasses import dataclass, field
11-
from pathlib import Path
11+
from pathlib import Path, PureWindowsPath
1212
from typing import Any, Protocol, runtime_checkable
1313

1414

@@ -51,8 +51,21 @@ class StageContext:
5151

5252
@staticmethod
5353
def _validate_path_component(field_name: str, value: str) -> str:
54-
"""Reject traversal sequences and separators in a path component."""
55-
if value and (".." in value or "/" in value or "\\" in value):
54+
r"""Reject traversal sequences, separators, and Windows drive qualifiers.
55+
56+
Checks for ``..``, POSIX ``/``, Windows ``\``, and any Windows
57+
drive letter or UNC anchor (``PureWindowsPath.drive`` or ``.anchor``
58+
being non-empty), so that ``output_dir / value`` cannot escape the
59+
intended output directory on any platform.
60+
"""
61+
if not value:
62+
return value
63+
if ".." in value or "/" in value or "\\" in value:
64+
raise ValueError(f"Invalid {field_name}: {value!r}")
65+
# Reject Windows drive-qualified values such as "C:" or "C:docs"
66+
# which have no slash but still produce an absolute PureWindowsPath.
67+
win_path = PureWindowsPath(value)
68+
if win_path.drive or win_path.anchor:
5669
raise ValueError(f"Invalid {field_name}: {value!r}")
5770
return value
5871

src/file_organizer/interfaces/processor.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ class FileProcessorProtocol(Protocol):
1818
1919
Implementations accept a file path and return a processed-file
2020
dataclass (``ProcessedFile``, ``ProcessedImage``, etc.).
21+
22+
The three keyword arguments below are the common subset shared by
23+
every concrete processor (``TextProcessor``, ``VisionProcessor``, etc.).
24+
Individual implementations may expose additional keyword-only parameters
25+
(e.g. ``perform_ocr`` on ``VisionProcessor``) without violating this
26+
protocol.
2127
"""
2228

2329
def initialize(self) -> None:
@@ -27,9 +33,24 @@ def initialize(self) -> None:
2733
def process_file(
2834
self,
2935
file_path: str | Path,
30-
**kwargs: Any,
36+
*,
37+
generate_description: bool = True,
38+
generate_folder: bool = True,
39+
generate_filename: bool = True,
3140
) -> Any:
32-
"""Process a single file and return a result dataclass."""
41+
"""Process a single file and return a result dataclass.
42+
43+
Args:
44+
file_path: Path to the file to process.
45+
generate_description: Whether to generate a natural-language
46+
description of the file's content.
47+
generate_folder: Whether to suggest a target folder name.
48+
generate_filename: Whether to suggest a new filename.
49+
50+
Returns:
51+
A processed-file dataclass (e.g. ``ProcessedFile``,
52+
``ProcessedImage``) containing the generated metadata.
53+
"""
3354
...
3455

3556

src/file_organizer/interfaces/storage.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@
77
from __future__ import annotations
88

99
from collections.abc import Callable
10-
from typing import Any, Protocol, TypeVar, runtime_checkable
10+
from typing import Any, Final, Protocol, TypeVar, runtime_checkable
1111

1212
T = TypeVar("T")
1313

14+
#: Sentinel used by :meth:`StorageProtocol.get` to distinguish a stored
15+
#: ``None`` value from a missing key.
16+
MISSING: Final[object] = object()
17+
1418

1519
@runtime_checkable
1620
class CacheProtocol(Protocol):
@@ -23,9 +27,14 @@ class CacheProtocol(Protocol):
2327
def get_or_load(
2428
self,
2529
key: str,
30+
/,
2631
loader: Callable[[], Any],
2732
) -> Any:
28-
"""Return cached value for *key*, or call *loader* to populate it."""
33+
"""Return cached value for *key*, or call *loader* to populate it.
34+
35+
*key* is positional-only to match the ``ModelCache`` implementation
36+
and prevent callers from passing it as a keyword argument.
37+
"""
2938
...
3039

3140
def stats(self) -> Any:
@@ -41,8 +50,18 @@ class StorageProtocol(Protocol):
4150
retrieving serializable data.
4251
"""
4352

44-
def get(self, key: str) -> Any | None:
45-
"""Retrieve the value for *key*, or ``None`` if absent."""
53+
def get(self, key: str, default: Any = MISSING) -> Any:
54+
"""Retrieve the value for *key*.
55+
56+
Returns *default* if *key* is absent. When *default* is omitted the
57+
module-level :data:`MISSING` sentinel is returned on a miss, allowing
58+
callers to distinguish a stored ``None`` from an absent key::
59+
60+
value = storage.get("my_key")
61+
if value is MISSING:
62+
# key not present
63+
...
64+
"""
4665
...
4766

4867
def put(self, key: str, value: Any) -> None:

src/file_organizer/methodologies/para/ai/file_mover.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,10 @@ def _is_already_organized(
419419
try:
420420
file_resolved = file_path.resolve()
421421
expected_resolved = expected_parent.resolve()
422-
return str(file_resolved).startswith(str(expected_resolved))
423-
except OSError:
422+
return file_resolved == expected_resolved or file_resolved.is_relative_to(
423+
expected_resolved
424+
)
425+
except (OSError, ValueError):
424426
return False
425427

426428
def _resolve_collision(self, destination: Path) -> Path:

src/file_organizer/models/registry.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from __future__ import annotations
1111

12+
import copy
1213
from dataclasses import dataclass
1314

1415

@@ -34,24 +35,33 @@ class ModelInfo:
3435

3536

3637
def get_text_models() -> list[ModelInfo]:
37-
"""Return all registered text models with domain metadata."""
38+
"""Return a deep copy of all registered text models with domain metadata.
39+
40+
Returns a copy so that callers cannot mutate the shared registry state.
41+
"""
3842
from file_organizer.models.text_registry import TEXT_MODELS
3943

40-
return list(TEXT_MODELS)
44+
return copy.deepcopy(list(TEXT_MODELS))
4145

4246

4347
def get_vision_models() -> list[ModelInfo]:
44-
"""Return all registered vision models with domain metadata."""
48+
"""Return a deep copy of all registered vision models with domain metadata.
49+
50+
Returns a copy so that callers cannot mutate the shared registry state.
51+
"""
4552
from file_organizer.models.vision_registry import VISION_MODELS
4653

47-
return list(VISION_MODELS)
54+
return copy.deepcopy(list(VISION_MODELS))
4855

4956

5057
def get_audio_models() -> list[ModelInfo]:
51-
"""Return all registered audio models with domain metadata."""
58+
"""Return a deep copy of all registered audio models with domain metadata.
59+
60+
Returns a copy so that callers cannot mutate the shared registry state.
61+
"""
5262
from file_organizer.models.audio_registry import AUDIO_MODELS
5363

54-
return list(AUDIO_MODELS)
64+
return copy.deepcopy(list(AUDIO_MODELS))
5565

5666

5767
def get_all_models() -> list[ModelInfo]:

tests/api/test_utils.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,70 @@ def test_file_inside_allowed_root(self, tmp_path: Path) -> None:
6464
result = resolve_path(str(f), allowed_paths=[str(tmp_path)])
6565
assert result == f.resolve()
6666

67+
def test_path_prefix_attack_blocked(self, tmp_path: Path) -> None:
68+
# Regression for #672: str.startswith() would allow "/allowed_dir_suffix"
69+
# to pass when "/allowed_dir" is the allowed root.
70+
# Path.is_relative_to() correctly rejects this because the directory
71+
# boundary is respected (it's not a child path).
72+
allowed = tmp_path / "data"
73+
allowed.mkdir()
74+
attack = tmp_path / "data_extra"
75+
attack.mkdir()
76+
with pytest.raises(ApiError) as exc_info:
77+
resolve_path(str(attack), allowed_paths=[str(allowed)])
78+
assert exc_info.value.status_code == 403
79+
assert exc_info.value.error == "path_not_allowed"
80+
81+
def test_dotdot_traversal_via_subdirectory_blocked(self, tmp_path: Path) -> None:
82+
# Ensure that a path with embedded ".." that would escape the root is blocked.
83+
allowed = tmp_path / "allowed"
84+
allowed.mkdir()
85+
outside = tmp_path / "outside"
86+
outside.mkdir()
87+
# Construct a path that goes through the allowed root and back out
88+
traversal = str(allowed) + "/../outside"
89+
with pytest.raises(ApiError) as exc_info:
90+
resolve_path(traversal, allowed_paths=[str(allowed)])
91+
assert exc_info.value.status_code == 403
92+
93+
def test_symlink_escaping_allowed_root_blocked(self, tmp_path: Path) -> None:
94+
# A symlink inside the allowed root that points outside must be blocked.
95+
allowed = tmp_path / "allowed"
96+
allowed.mkdir()
97+
outside = tmp_path / "secret"
98+
outside.mkdir()
99+
# Create a symlink inside allowed that points to outside
100+
link = allowed / "escape_link"
101+
link.symlink_to(outside)
102+
with pytest.raises(ApiError) as exc_info:
103+
resolve_path(str(link), allowed_paths=[str(allowed)])
104+
assert exc_info.value.status_code == 403
105+
106+
def test_windows_drive_qualified_path_blocked(self, tmp_path: Path) -> None:
107+
# Drive-qualified input must never be accepted when it is outside the
108+
# configured allowlist root (Windows and non-Windows behavior alike).
109+
allowed = tmp_path / "allowed"
110+
allowed.mkdir()
111+
with pytest.raises(ApiError) as exc_info:
112+
resolve_path(r"C:\Windows\System32", allowed_paths=[str(allowed)])
113+
assert exc_info.value.status_code == 403
114+
assert exc_info.value.error == "path_not_allowed"
115+
116+
def test_unc_path_blocked_when_outside_allowlist(self, tmp_path: Path) -> None:
117+
# UNC-style paths are treated as out-of-scope unless explicitly under an
118+
# allowed root; this guards Windows network-share escape cases.
119+
allowed = tmp_path / "allowed"
120+
allowed.mkdir()
121+
with pytest.raises(ApiError) as exc_info:
122+
resolve_path(r"\\server\share\secret.txt", allowed_paths=[str(allowed)])
123+
assert exc_info.value.status_code == 403
124+
assert exc_info.value.error == "path_not_allowed"
125+
126+
def test_returns_path_object(self, tmp_path: Path) -> None:
127+
result = resolve_path(str(tmp_path), allowed_paths=[str(tmp_path)])
128+
assert isinstance(result, Path)
129+
assert result.is_absolute()
130+
67131

68132
# ---------------------------------------------------------------------------
69133
# is_hidden

tests/ci/test_path_security_contract.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@
2525
"path = Path(config_path).expanduser()",
2626
},
2727
"api/utils.py": {
28-
"resolved = Path(path_value).expanduser()",
29-
"roots = [os.path.realpath(Path(root).expanduser()) for root in allowed_paths]",
30-
"return Path(resolved_str)",
28+
"resolved = Path(path_value).expanduser().resolve()",
29+
"roots = [Path(root).expanduser().resolve() for root in allowed_paths]",
30+
"return resolved",
3131
# Multi-line variants (formatter may break long lines)
32-
"os.path.realpath(Path(root).expanduser())",
32+
"Path(root).expanduser().resolve() for root in allowed_paths",
3333
},
3434
"api/routers/system.py": {
3535
"file_info_from_path(Path(info.path))",
@@ -74,8 +74,8 @@
7474
"path = Path(config_path).expanduser()",
7575
},
7676
"api/utils.py": {
77-
"resolved = Path(path_value).expanduser()",
78-
"roots = [os.path.realpath(Path(root).expanduser()) for root in allowed_paths]",
77+
"resolved = Path(path_value).expanduser().resolve()",
78+
"roots = [Path(root).expanduser().resolve() for root in allowed_paths]",
7979
},
8080
}
8181

tests/interfaces/test_protocol_conformance.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88

99
from __future__ import annotations
1010

11+
import inspect
1112
from unittest.mock import MagicMock, patch
1213

1314
import pytest
1415

1516
from file_organizer.interfaces import (
17+
MISSING,
1618
AudioModelProtocol,
1719
BatchProcessorProtocol,
1820
CacheProtocol,
@@ -229,8 +231,8 @@ class TestStorageProtocolConformance:
229231

230232
def test_stub_satisfies_storage_protocol(self) -> None:
231233
class _StubStorage:
232-
def get(self, key: str) -> object | None:
233-
return None
234+
def get(self, key: str, default: object = MISSING) -> object:
235+
return default
234236

235237
def put(self, key: str, value: object) -> None:
236238
pass
@@ -241,6 +243,12 @@ def delete(self, key: str) -> bool:
241243
def exists(self, key: str) -> bool:
242244
return False
243245

246+
protocol_params = list(inspect.signature(StorageProtocol.get).parameters.values())
247+
stub_params = list(inspect.signature(_StubStorage.get).parameters.values())
248+
assert len(stub_params) == len(protocol_params)
249+
assert [param.name for param in stub_params] == [param.name for param in protocol_params]
250+
assert [param.kind for param in stub_params] == [param.kind for param in protocol_params]
251+
assert stub_params[-1].default is MISSING
244252
assert isinstance(_StubStorage(), StorageProtocol)
245253

246254

0 commit comments

Comments
 (0)