Skip to content

Commit 75c685c

Browse files
committed
fix: enforce sequence policies and normalize paths across platforms
1 parent b257dec commit 75c685c

4 files changed

Lines changed: 466 additions & 184 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -264,11 +264,15 @@ Run all checks:
264264
Or individually:
265265

266266
```bash
267-
python3 -m ruff format .
268-
python3 -m ruff check . --fix
269-
python3 -m mypy src
270-
python3 -m pytest -vv
271-
python3 -m build
267+
python -m ruff format .
268+
python -m ruff check . --fix
269+
python -m ruff format .
270+
python -m ruff format --check .
271+
python -m ruff check .
272+
python -m mypy src
273+
python -m pytest -vv
274+
python -m compileall -q src tests
275+
python -m build
272276
```
273277

274278
## Design goals

‎src/logprivacy/path_rules/allowlist.py‎

Lines changed: 87 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
from __future__ import annotations
44

5+
from typing import Literal, TypeAlias
6+
57
from logprivacy.field_rules.rules import normalize_field_name
68

7-
_TraversalPath = tuple[str | int, ...]
9+
_TraversalPath: TypeAlias = tuple[str | int, ...]
10+
_AllowlistPathMatch: TypeAlias = Literal["none", "prefix", "exact"]
811

912

1013
class _AllowlistMatcher:
@@ -13,80 +16,114 @@ class _AllowlistMatcher:
1316
Allowlist paths are dot-separated patterns that may use ``*`` as a
1417
single-segment wildcard: ``"orders.*.status"``.
1518
16-
A field at path ``P`` is retained when:
17-
- ``P`` exactly matches an allowlist pattern, OR
18-
- ``P`` is a proper prefix of an allowlist pattern (meaning there is
19-
allowed content nested under ``P``).
19+
``match_path()`` distinguishes three states:
20+
21+
- ``"exact"``: the current path is explicitly allowed;
22+
- ``"prefix"``: the current path is a parent of allowed descendants;
23+
- ``"none"``: the current path is not covered by the allowlist.
2024
21-
This ensures parent objects are preserved when any of their descendants
22-
are allowed.
25+
Distinguishing exact matches from parent prefixes is important for safe
26+
traversal. A scalar value at a prefix-only path cannot contain the allowed
27+
descendant and must not be emitted merely because its path is a prefix.
2328
"""
2429

2530
__slots__ = ("_patterns",)
2631

2732
def __init__(self, patterns: tuple[str, ...]) -> None:
28-
parsed: list[tuple[str, ...]] = []
29-
for pat in patterns:
30-
parsed.append(_parse_allowlist_path(pat))
31-
self._patterns: tuple[tuple[str, ...], ...] = tuple(parsed)
33+
self._patterns: tuple[tuple[str, ...], ...] = tuple(
34+
_parse_allowlist_path(pattern) for pattern in patterns
35+
)
36+
37+
def match_path(self, path: _TraversalPath) -> _AllowlistPathMatch:
38+
"""Return whether ``path`` is exact, a parent prefix, or not covered."""
39+
if not self._patterns:
40+
return "none"
41+
42+
normalized_path = _normalize_traversal_path(path)
43+
has_prefix_match = False
44+
45+
for pattern in self._patterns:
46+
match = _match_normalized_path(normalized_path, pattern)
47+
if match == "exact":
48+
return "exact"
49+
if match == "prefix":
50+
has_prefix_match = True
51+
52+
return "prefix" if has_prefix_match else "none"
3253

3354
def is_path_allowed(self, path: _TraversalPath) -> bool:
34-
"""Return True if ``path`` is allowed or leads to allowed content.
55+
"""Return whether ``path`` is explicitly allowed or leads to allowed content.
3556
36-
Returns False when no patterns are configured (empty allowlist rejects everything).
37-
The caller is responsible for checking whether an allowlist is active at all.
57+
This compatibility helper preserves the previous boolean API. Traversal
58+
code that needs to decide whether a scalar may be emitted should use
59+
``match_path()`` so it can distinguish an exact match from a parent
60+
prefix.
3861
"""
39-
if not self._patterns:
40-
return False
41-
norm = _normalize_traversal_path(path)
42-
return any(_path_matches_or_prefixes_pattern(norm, pattern) for pattern in self._patterns)
62+
return self.match_path(path) != "none"
4363

4464

4565
def _normalize_traversal_path(path: _TraversalPath) -> tuple[str, ...]:
46-
result: list[str] = []
47-
for seg in path:
48-
if isinstance(seg, int):
49-
result.append(str(seg))
66+
normalized_segments: list[str] = []
67+
for segment in path:
68+
if isinstance(segment, int):
69+
normalized_segments.append(str(segment))
5070
else:
51-
result.append(normalize_field_name(seg))
52-
return tuple(result)
53-
54-
55-
def _path_matches_or_prefixes_pattern(norm_path: tuple[str, ...], pattern: tuple[str, ...]) -> bool:
56-
"""Return True if norm_path matches pattern exactly or is a proper prefix of it."""
57-
plen = len(pattern)
58-
nlen = len(norm_path)
59-
if nlen > plen:
60-
return False
61-
for p_seg, n_seg in zip(pattern[:nlen], norm_path, strict=False):
62-
if p_seg == "*":
63-
continue
64-
if p_seg != n_seg:
65-
return False
66-
return True
71+
normalized_segments.append(normalize_field_name(segment))
72+
return tuple(normalized_segments)
73+
74+
75+
def _match_normalized_path(
76+
normalized_path: tuple[str, ...],
77+
pattern: tuple[str, ...],
78+
) -> _AllowlistPathMatch:
79+
"""Match an already-normalized traversal path against one pattern."""
80+
path_length = len(normalized_path)
81+
pattern_length = len(pattern)
82+
83+
if path_length > pattern_length:
84+
return "none"
85+
86+
for pattern_segment, path_segment in zip(pattern[:path_length], normalized_path, strict=False):
87+
if pattern_segment != "*" and pattern_segment != path_segment:
88+
return "none"
89+
90+
if path_length == pattern_length:
91+
return "exact"
92+
return "prefix"
93+
94+
95+
def _path_matches_or_prefixes_pattern(
96+
norm_path: tuple[str, ...],
97+
pattern: tuple[str, ...],
98+
) -> bool:
99+
"""Compatibility wrapper for the previous private helper."""
100+
return _match_normalized_path(norm_path, pattern) != "none"
67101

68102

69103
def _parse_allowlist_path(path_str: str) -> tuple[str, ...]:
70-
"""Parse 'error.type' -> ('error', 'type'), 'orders.*.status' -> ('orders', '*', 'status')."""
104+
"""Parse an allowlist path into normalized segments."""
71105
if not path_str:
72106
raise ValueError("allowlist path must not be empty")
73107
if path_str.startswith(".") or path_str.endswith("."):
74108
raise ValueError(f"allowlist path {path_str!r} must not start or end with '.'")
75109
if ".." in path_str:
76110
raise ValueError(f"allowlist path {path_str!r} must not contain '..'")
111+
77112
segments: list[str] = []
78-
for seg in path_str.split("."):
79-
if not seg:
113+
for segment in path_str.split("."):
114+
if not segment:
80115
raise ValueError(f"empty segment in allowlist path {path_str!r}")
81-
if seg == "**":
116+
if segment == "**":
82117
raise ValueError("'**' is not supported in allowlist paths; use '*'")
83-
if "*" in seg and seg != "*":
84-
raise ValueError(f"partial wildcard {seg!r} in allowlist path {path_str!r}")
85-
if seg == "*":
118+
if "*" in segment and segment != "*":
119+
raise ValueError(f"partial wildcard {segment!r} in allowlist path {path_str!r}")
120+
if segment == "*":
86121
segments.append("*")
87-
else:
88-
norm = normalize_field_name(seg)
89-
if not norm:
90-
raise ValueError(f"allowlist segment {seg!r} normalizes to empty string")
91-
segments.append(norm)
122+
continue
123+
124+
normalized_segment = normalize_field_name(segment)
125+
if not normalized_segment:
126+
raise ValueError(f"allowlist segment {segment!r} normalizes to empty string")
127+
segments.append(normalized_segment)
128+
92129
return tuple(segments)

0 commit comments

Comments
 (0)