22
33from __future__ import annotations
44
5+ from typing import Literal , TypeAlias
6+
57from 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
1013class _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
4565def _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
69103def _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