1717import httpx
1818from pydantic import BaseModel , ValidationError
1919
20- from adcp .exceptions import RegistryError
20+ from adcp .exceptions import RegistryError , RegistryErrorDetails , RegistryValidationIssue
2121
2222_T = TypeVar ("_T" , bound = BaseModel )
2323from adcp .types .core import (
4949MAX_BULK_DOMAINS = 100
5050MAX_BULK_POLICIES = 100
5151MAX_REGISTRY_ERROR_DETAILS_BYTES = 64 * 1024
52+ MAX_PROJECTED_REGISTRY_ERROR_DETAILS_BYTES = 4 * 1024
53+ MAX_REGISTRY_ERROR_TOKEN_LENGTH = 128
54+ MAX_REGISTRY_ERROR_CODE_LENGTH = 64
55+ MAX_REGISTRY_ERROR_FIELD_LENGTH = 128
56+ MAX_REGISTRY_ERROR_LIST_ITEMS = 20
57+ MAX_REGISTRY_ERROR_PATH_SEGMENTS = 8
5258DEFAULT_MAX_REGISTRY_RESPONSE_BYTES = 256 * 1024
5359DEFAULT_MAX_BULK_REGISTRY_RESPONSE_BYTES = 2 * 1024 * 1024
5460MAX_RETRY_AFTER_SECONDS = 2_147_483.647
5561
5662_COMMUNITY_MIRROR_PLATFORM_RE = re .compile (r"^[a-z0-9_-]{1,64}$" )
63+ _REGISTRY_ERROR_TOKEN_RE = re .compile (r"^[A-Za-z0-9._:-]+$" )
64+ _REGISTRY_ERROR_CODE_RE = re .compile (r"^[A-Za-z][A-Za-z0-9._:-]*$" )
65+ _REGISTRY_ERROR_FIELD_RE = re .compile (r"^[A-Za-z0-9_.\[\]-]+$" )
66+ _REGISTRY_SECRET_CODE_RE = re .compile (
67+ r"^(?:(?:sk|pk|rk)[._:-]|api[_-]?key[._:-]|bearer[._:-]|basic[._:-]|eyj)" ,
68+ re .IGNORECASE ,
69+ )
70+ _LEGACY_REGISTRY_ERROR_CODES = frozenset ({"cursor_expired" , "unpublish_first" , "url_immutable" })
5771
5872_LARGE_REGISTRY_RESPONSE_PATHS = frozenset (
5973 {
@@ -135,8 +149,8 @@ def _retry_after_seconds(
135149 return None
136150
137151
138- def _registry_error_details (response : httpx .Response ) -> dict [str , Any ] | None :
139- """Return a bounded JSON object from a registry error response ."""
152+ def _registry_error_payload (response : httpx .Response ) -> dict [str , Any ] | None :
153+ """Parse a bounded registry error object for immediate local processing ."""
140154 content = response .content
141155 if isinstance (content , bytes ) and len (content ) > MAX_REGISTRY_ERROR_DETAILS_BYTES :
142156 return None
@@ -152,19 +166,195 @@ def _registry_error_details(response: httpx.Response) -> dict[str, Any] | None:
152166 return cast (dict [str , Any ], details )
153167
154168
169+ def _safe_registry_error_string (
170+ value : Any ,
171+ * ,
172+ max_length : int ,
173+ pattern : re .Pattern [str ],
174+ ) -> str | None :
175+ """Return a bounded machine token, excluding remote free-form prose."""
176+ if not isinstance (value , str ) or not 1 <= len (value ) <= max_length :
177+ return None
178+ if pattern .fullmatch (value ) is None :
179+ return None
180+ return value
181+
182+
183+ def _safe_registry_error_code (value : Any ) -> str | None :
184+ """Return a bounded code unless it resembles common credential material."""
185+ code = _safe_registry_error_string (
186+ value ,
187+ max_length = MAX_REGISTRY_ERROR_CODE_LENGTH ,
188+ pattern = _REGISTRY_ERROR_CODE_RE ,
189+ )
190+ if code is None or _REGISTRY_SECRET_CODE_RE .match (code ) is not None :
191+ return None
192+ return code
193+
194+
195+ def _safe_registry_validation_issue (value : Any ) -> RegistryValidationIssue | None :
196+ """Project one validation issue without rejected values or server prose."""
197+ if not isinstance (value , dict ):
198+ return None
199+ issue : RegistryValidationIssue = {}
200+ code = _safe_registry_error_code (value .get ("code" ))
201+ if code is not None :
202+ issue ["code" ] = code
203+ field = _safe_registry_error_string (
204+ value .get ("field" ),
205+ max_length = MAX_REGISTRY_ERROR_FIELD_LENGTH ,
206+ pattern = _REGISTRY_ERROR_FIELD_RE ,
207+ )
208+ if field is not None :
209+ issue ["field" ] = field
210+
211+ raw_path = value .get ("path" )
212+ if isinstance (raw_path , list ) and len (raw_path ) <= MAX_REGISTRY_ERROR_PATH_SEGMENTS :
213+ path : list [str | int ] = []
214+ for segment in raw_path :
215+ if isinstance (segment , bool ):
216+ path = []
217+ break
218+ if isinstance (segment , int ):
219+ if not 0 <= segment <= 1_000_000 :
220+ path = []
221+ break
222+ path .append (segment )
223+ continue
224+ safe_segment = _safe_registry_error_string (
225+ segment ,
226+ max_length = 64 ,
227+ pattern = _REGISTRY_ERROR_FIELD_RE ,
228+ )
229+ if safe_segment is None :
230+ path = []
231+ break
232+ path .append (safe_segment )
233+ if path :
234+ issue ["path" ] = path
235+ return issue or None
236+
237+
238+ def _safe_registry_retry_value (value : Any , * , scale : float ) -> int | float | None :
239+ """Normalize a recognized retry hint without retaining unbounded input."""
240+ seconds = _bounded_retry_seconds (value , scale = scale )
241+ if seconds is None :
242+ return None
243+ normalized = seconds / scale
244+ return int (normalized ) if normalized .is_integer () else normalized
245+
246+
247+ def _projected_registry_error_size (details : RegistryErrorDetails ) -> int :
248+ """Return the serialized size of a projected metadata envelope."""
249+ return len (json .dumps (details , ensure_ascii = False ).encode ("utf-8" ))
250+
251+
252+ def _registry_error_details (payload : dict [str , Any ] | None ) -> RegistryErrorDetails | None :
253+ """Allowlist bounded machine metadata from an untrusted registry error."""
254+ if payload is None :
255+ return None
256+
257+ projected : RegistryErrorDetails = {}
258+ code_value = payload .get ("code" )
259+ if code_value is None and payload .get ("error" ) in _LEGACY_REGISTRY_ERROR_CODES :
260+ # Some stable registry recovery discriminators predate the dedicated
261+ # code field and are returned in Error.error. Promote only documented
262+ # values; generic free-form error prose remains excluded even when it
263+ # happens to look like a machine token.
264+ code_value = payload .get ("error" )
265+ code = _safe_registry_error_code (code_value )
266+ if code is not None :
267+ projected ["code" ] = code
268+ field = _safe_registry_error_string (
269+ payload .get ("field" ),
270+ max_length = MAX_REGISTRY_ERROR_FIELD_LENGTH ,
271+ pattern = _REGISTRY_ERROR_FIELD_RE ,
272+ )
273+ if field is not None :
274+ projected ["field" ] = field
275+ for key in ("policy_id" , "existing_org_id" , "request_id" ):
276+ safe_value = _safe_registry_error_string (
277+ payload .get (key ),
278+ max_length = MAX_REGISTRY_ERROR_TOKEN_LENGTH ,
279+ pattern = _REGISTRY_ERROR_TOKEN_RE ,
280+ )
281+ if key == "policy_id" and safe_value is not None :
282+ projected ["policy_id" ] = safe_value
283+ elif key == "existing_org_id" and safe_value is not None :
284+ projected ["existing_org_id" ] = safe_value
285+ elif key == "request_id" and safe_value is not None :
286+ projected ["request_id" ] = safe_value
287+
288+ if isinstance (payload .get ("members_only" ), bool ):
289+ projected ["members_only" ] = payload ["members_only" ]
290+
291+ retry_after_ms = _safe_registry_retry_value (payload .get ("retryAfterMs" ), scale = 0.001 )
292+ if retry_after_ms is not None :
293+ projected ["retryAfterMs" ] = retry_after_ms
294+ retry_after = _safe_registry_retry_value (payload .get ("retryAfter" ), scale = 1.0 )
295+ if retry_after is not None :
296+ projected ["retryAfter" ] = retry_after
297+ retry_after_snake = _safe_registry_retry_value (payload .get ("retry_after" ), scale = 1.0 )
298+ if retry_after_snake is not None :
299+ projected ["retry_after" ] = retry_after_snake
300+
301+ raw_valid_values = payload .get ("valid_values" )
302+ if isinstance (raw_valid_values , list ):
303+ valid_values : list [str ] = []
304+ for value in raw_valid_values [:MAX_REGISTRY_ERROR_LIST_ITEMS ]:
305+ safe_value = _safe_registry_error_string (
306+ value ,
307+ max_length = 64 ,
308+ pattern = _REGISTRY_ERROR_TOKEN_RE ,
309+ )
310+ if safe_value is None :
311+ continue
312+ candidate = {** projected , "valid_values" : [* valid_values , safe_value ]}
313+ if _projected_registry_error_size (cast (RegistryErrorDetails , candidate )) > (
314+ MAX_PROJECTED_REGISTRY_ERROR_DETAILS_BYTES
315+ ):
316+ break
317+ valid_values .append (safe_value )
318+ if valid_values :
319+ projected ["valid_values" ] = valid_values
320+
321+ raw_issues = payload .get ("details" )
322+ if isinstance (raw_issues , list ):
323+ issues : list [RegistryValidationIssue ] = []
324+ for value in raw_issues [:MAX_REGISTRY_ERROR_LIST_ITEMS ]:
325+ issue = _safe_registry_validation_issue (value )
326+ if issue is None :
327+ continue
328+ candidate = {** projected , "validation_issues" : [* issues , issue ]}
329+ if _projected_registry_error_size (cast (RegistryErrorDetails , candidate )) > (
330+ MAX_PROJECTED_REGISTRY_ERROR_DETAILS_BYTES
331+ ):
332+ break
333+ issues .append (issue )
334+ if issues :
335+ projected ["validation_issues" ] = issues
336+
337+ if not projected :
338+ return None
339+ if _projected_registry_error_size (projected ) > MAX_PROJECTED_REGISTRY_ERROR_DETAILS_BYTES :
340+ return None
341+ return projected
342+
343+
155344def _registry_http_error (
156345 response : httpx .Response ,
157346 * ,
158347 method : str ,
159348 operation : str ,
160349) -> RegistryError :
161350 """Build a structured error without exposing an unbounded response body."""
162- details = _registry_error_details (response )
351+ payload = _registry_error_payload (response )
352+ details = _registry_error_details (payload )
163353 return RegistryError (
164354 f"{ operation } failed: HTTP { response .status_code } " ,
165355 status_code = response .status_code ,
166356 method = method .upper (),
167- retry_after_seconds = _retry_after_seconds (response , details ),
357+ retry_after_seconds = _retry_after_seconds (response , payload ),
168358 details = details ,
169359 )
170360
0 commit comments