granttrace 2.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api_sentinel.py +402 -0
- core/__init__.py +4 -0
- core/auditor.py +1101 -0
- core/config_scaffold.py +254 -0
- core/config_validator.py +1408 -0
- core/diff.py +839 -0
- core/evidence.py +353 -0
- core/generator.py +1048 -0
- core/models.py +104 -0
- core/parameters.py +198 -0
- core/parser.py +495 -0
- core/reporter.py +359 -0
- core/transactions.py +167 -0
- granttrace-2.4.0.dist-info/METADATA +108 -0
- granttrace-2.4.0.dist-info/RECORD +21 -0
- granttrace-2.4.0.dist-info/WHEEL +5 -0
- granttrace-2.4.0.dist-info/entry_points.txt +2 -0
- granttrace-2.4.0.dist-info/licenses/LICENSE +202 -0
- granttrace-2.4.0.dist-info/top_level.txt +3 -0
- mock_server/__init__.py +4 -0
- mock_server/server.py +190 -0
core/generator.py
ADDED
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Constraint-aware request data generation for GrantTrace."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
import datetime
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
12
|
+
from fractions import Fraction
|
|
13
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
JsonPathPart = Union[str, int]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SchemaGenerationError(ValueError):
|
|
20
|
+
"""The bounded generator cannot construct a demonstrably valid request."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SmartDataGenerator:
|
|
24
|
+
PRIVILEGE_KEYS = [
|
|
25
|
+
"is_admin",
|
|
26
|
+
"isAdmin",
|
|
27
|
+
"admin",
|
|
28
|
+
"role",
|
|
29
|
+
"roles",
|
|
30
|
+
"user_type",
|
|
31
|
+
"privilege",
|
|
32
|
+
"privileges",
|
|
33
|
+
"permission",
|
|
34
|
+
"permissions",
|
|
35
|
+
"is_superuser",
|
|
36
|
+
"superuser",
|
|
37
|
+
"verified",
|
|
38
|
+
"status",
|
|
39
|
+
"group",
|
|
40
|
+
"group_id",
|
|
41
|
+
"tier",
|
|
42
|
+
"plan",
|
|
43
|
+
"credit",
|
|
44
|
+
"balance",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_NON_RESOURCE_PARAMETER_NAMES = {
|
|
48
|
+
"page",
|
|
49
|
+
"page_id",
|
|
50
|
+
"page_size",
|
|
51
|
+
"pagesize",
|
|
52
|
+
"limit",
|
|
53
|
+
"offset",
|
|
54
|
+
"cursor",
|
|
55
|
+
"sort",
|
|
56
|
+
"order",
|
|
57
|
+
"q",
|
|
58
|
+
"query",
|
|
59
|
+
"search",
|
|
60
|
+
"filter",
|
|
61
|
+
}
|
|
62
|
+
_PARAMETER_SCHEMA_KEYS = {
|
|
63
|
+
"type",
|
|
64
|
+
"format",
|
|
65
|
+
"enum",
|
|
66
|
+
"default",
|
|
67
|
+
"example",
|
|
68
|
+
"examples",
|
|
69
|
+
"minimum",
|
|
70
|
+
"maximum",
|
|
71
|
+
"exclusiveMinimum",
|
|
72
|
+
"exclusiveMaximum",
|
|
73
|
+
"multipleOf",
|
|
74
|
+
"minLength",
|
|
75
|
+
"maxLength",
|
|
76
|
+
"pattern",
|
|
77
|
+
"minItems",
|
|
78
|
+
"maxItems",
|
|
79
|
+
"items",
|
|
80
|
+
"nullable",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def _parameter_schema(cls, param_meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
85
|
+
schema = param_meta.get("schema")
|
|
86
|
+
if isinstance(schema, dict):
|
|
87
|
+
return schema
|
|
88
|
+
# Swagger 2.0 puts primitive parameter constraints directly on the
|
|
89
|
+
# parameter instead of under a schema key.
|
|
90
|
+
return {
|
|
91
|
+
key: copy.deepcopy(value)
|
|
92
|
+
for key, value in param_meta.items()
|
|
93
|
+
if key in cls._PARAMETER_SCHEMA_KEYS
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def _is_resource_identifier(cls, param_meta: Dict[str, Any]) -> bool:
|
|
98
|
+
original = str(param_meta.get("name", ""))
|
|
99
|
+
normalized = re.sub(r"[-.\s]+", "_", original).lower()
|
|
100
|
+
if normalized in cls._NON_RESOURCE_PARAMETER_NAMES:
|
|
101
|
+
return False
|
|
102
|
+
if normalized in {"id", "uuid", "uid", "resource_id", "object_id"}:
|
|
103
|
+
return True
|
|
104
|
+
if re.search(r"(?:^|_)(?:id|uuid|uid)$", normalized):
|
|
105
|
+
return True
|
|
106
|
+
return bool(re.search(r"(?:Id|ID|Uuid|UUID|Uid|UID)$", original))
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _example_from_examples(examples: Any) -> Any:
|
|
110
|
+
if isinstance(examples, list) and examples:
|
|
111
|
+
return examples[0]
|
|
112
|
+
if isinstance(examples, dict):
|
|
113
|
+
for entry in examples.values():
|
|
114
|
+
if isinstance(entry, dict) and "value" in entry:
|
|
115
|
+
return entry["value"]
|
|
116
|
+
if not isinstance(entry, dict):
|
|
117
|
+
return entry
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def _documented_values(
|
|
122
|
+
cls, param_meta: Dict[str, Any], schema: Dict[str, Any]
|
|
123
|
+
) -> Iterable[Any]:
|
|
124
|
+
if "example" in param_meta:
|
|
125
|
+
yield param_meta["example"]
|
|
126
|
+
parameter_example = cls._example_from_examples(param_meta.get("examples"))
|
|
127
|
+
if parameter_example is not None:
|
|
128
|
+
yield parameter_example
|
|
129
|
+
if "example" in schema:
|
|
130
|
+
yield schema["example"]
|
|
131
|
+
schema_example = cls._example_from_examples(schema.get("examples"))
|
|
132
|
+
if schema_example is not None:
|
|
133
|
+
yield schema_example
|
|
134
|
+
if "default" in schema:
|
|
135
|
+
yield schema["default"]
|
|
136
|
+
if "const" in schema:
|
|
137
|
+
yield schema["const"]
|
|
138
|
+
enum = schema.get("enum")
|
|
139
|
+
if isinstance(enum, list):
|
|
140
|
+
yield from enum
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _coerce_for_schema(value: Any, schema: Dict[str, Any]) -> Any:
|
|
144
|
+
schema_type = schema.get("type")
|
|
145
|
+
if schema_type == "integer" and not isinstance(value, bool):
|
|
146
|
+
try:
|
|
147
|
+
return int(value)
|
|
148
|
+
except (TypeError, ValueError):
|
|
149
|
+
return value
|
|
150
|
+
if schema_type == "number" and not isinstance(value, bool):
|
|
151
|
+
try:
|
|
152
|
+
return float(value)
|
|
153
|
+
except (TypeError, ValueError):
|
|
154
|
+
return value
|
|
155
|
+
if schema_type == "boolean" and isinstance(value, str):
|
|
156
|
+
if value.lower() in {"true", "1"}:
|
|
157
|
+
return True
|
|
158
|
+
if value.lower() in {"false", "0"}:
|
|
159
|
+
return False
|
|
160
|
+
if schema_type == "string" and value is not None:
|
|
161
|
+
return str(value)
|
|
162
|
+
return value
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def validate_schema_value(cls, value: Any, schema: Any, *, request: bool = True) -> bool:
|
|
166
|
+
"""Validate the supported schema subset recursively, failing closed.
|
|
167
|
+
|
|
168
|
+
In request mode, required readOnly properties need not be supplied.
|
|
169
|
+
readOnly is otherwise an annotation, so explicitly authorized injection
|
|
170
|
+
probes can still validate the field's type and value constraints.
|
|
171
|
+
Unresolved references and unsupported evaluation-dependent keywords
|
|
172
|
+
return False. This is not a complete JSON Schema implementation.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
return cls._validate_schema_value(value, schema, request=request)
|
|
176
|
+
except (TypeError, ValueError, OverflowError, RecursionError, re.error):
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def _value_satisfies_schema(cls, value: Any, schema: Dict[str, Any]) -> bool:
|
|
181
|
+
"""Backward-compatible request-value validation helper."""
|
|
182
|
+
return cls.validate_schema_value(value, schema)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def _validate_schema_value(cls, value: Any, schema: Any, *, request: bool) -> bool:
|
|
186
|
+
if isinstance(schema, bool):
|
|
187
|
+
return schema
|
|
188
|
+
if not isinstance(schema, dict):
|
|
189
|
+
return False
|
|
190
|
+
if any(key in schema for key in (
|
|
191
|
+
"$ref", "$dynamicRef", "$recursiveRef", "unevaluatedProperties", "unevaluatedItems"
|
|
192
|
+
)):
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
def check(candidate, child):
|
|
196
|
+
return cls._validate_schema_value(candidate, child, request=request)
|
|
197
|
+
|
|
198
|
+
for keyword in ("allOf", "anyOf", "oneOf"):
|
|
199
|
+
if keyword not in schema:
|
|
200
|
+
continue
|
|
201
|
+
branches = schema[keyword]
|
|
202
|
+
if not isinstance(branches, list) or not branches:
|
|
203
|
+
return False
|
|
204
|
+
matches = [check(value, child) for child in branches]
|
|
205
|
+
if keyword == "allOf" and not all(matches):
|
|
206
|
+
return False
|
|
207
|
+
if keyword == "anyOf" and not any(matches):
|
|
208
|
+
return False
|
|
209
|
+
if keyword == "oneOf" and sum(matches) != 1:
|
|
210
|
+
return False
|
|
211
|
+
if "not" in schema and check(value, schema["not"]):
|
|
212
|
+
return False
|
|
213
|
+
if "if" in schema:
|
|
214
|
+
selected = "then" if check(value, schema["if"]) else "else"
|
|
215
|
+
if selected in schema and not check(value, schema[selected]):
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
def json_equal(left: Any, right: Any) -> bool:
|
|
219
|
+
# Python considers True == 1, but JSON Schema does not.
|
|
220
|
+
if isinstance(left, bool) != isinstance(right, bool):
|
|
221
|
+
return False
|
|
222
|
+
if isinstance(left, dict) and isinstance(right, dict):
|
|
223
|
+
return left.keys() == right.keys() and all(json_equal(left[k], right[k]) for k in left)
|
|
224
|
+
if isinstance(left, list) and isinstance(right, list):
|
|
225
|
+
return len(left) == len(right) and all(json_equal(a, b) for a, b in zip(left, right))
|
|
226
|
+
return left == right
|
|
227
|
+
|
|
228
|
+
if "const" in schema and not json_equal(value, schema["const"]):
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
enum = schema.get("enum")
|
|
232
|
+
if enum is not None and (not isinstance(enum, list) or not any(json_equal(value, item) for item in enum)):
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
schema_type = schema.get("type")
|
|
236
|
+
if value is None and schema.get("nullable"):
|
|
237
|
+
return True
|
|
238
|
+
if isinstance(schema_type, list):
|
|
239
|
+
return any(
|
|
240
|
+
check(value, {**schema, "type": item})
|
|
241
|
+
for item in schema_type
|
|
242
|
+
)
|
|
243
|
+
if schema_type == "null":
|
|
244
|
+
return value is None
|
|
245
|
+
if value is None:
|
|
246
|
+
return schema_type is None
|
|
247
|
+
if schema_type is not None and schema_type not in {
|
|
248
|
+
"integer", "number", "boolean", "string", "array", "object"
|
|
249
|
+
}:
|
|
250
|
+
return False
|
|
251
|
+
if schema_type == "integer" and (isinstance(value, bool) or not isinstance(value, int)):
|
|
252
|
+
return False
|
|
253
|
+
if schema_type == "number" and (
|
|
254
|
+
isinstance(value, bool) or not isinstance(value, (int, float))
|
|
255
|
+
):
|
|
256
|
+
return False
|
|
257
|
+
if schema_type == "boolean" and not isinstance(value, bool):
|
|
258
|
+
return False
|
|
259
|
+
if schema_type == "string" and not isinstance(value, str):
|
|
260
|
+
return False
|
|
261
|
+
if schema_type == "array" and not isinstance(value, list):
|
|
262
|
+
return False
|
|
263
|
+
if schema_type == "object" and not isinstance(value, dict):
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
267
|
+
if not math.isfinite(value):
|
|
268
|
+
return False
|
|
269
|
+
minimum = schema.get("minimum")
|
|
270
|
+
maximum = schema.get("maximum")
|
|
271
|
+
exclusive_minimum = schema.get("exclusiveMinimum")
|
|
272
|
+
exclusive_maximum = schema.get("exclusiveMaximum")
|
|
273
|
+
if minimum is not None:
|
|
274
|
+
if exclusive_minimum is True and value <= minimum:
|
|
275
|
+
return False
|
|
276
|
+
if exclusive_minimum is not True and value < minimum:
|
|
277
|
+
return False
|
|
278
|
+
if isinstance(exclusive_minimum, (int, float)) and not isinstance(exclusive_minimum, bool):
|
|
279
|
+
if value <= exclusive_minimum:
|
|
280
|
+
return False
|
|
281
|
+
if maximum is not None:
|
|
282
|
+
if exclusive_maximum is True and value >= maximum:
|
|
283
|
+
return False
|
|
284
|
+
if exclusive_maximum is not True and value > maximum:
|
|
285
|
+
return False
|
|
286
|
+
if isinstance(exclusive_maximum, (int, float)) and not isinstance(exclusive_maximum, bool):
|
|
287
|
+
if value >= exclusive_maximum:
|
|
288
|
+
return False
|
|
289
|
+
multiple = schema.get("multipleOf")
|
|
290
|
+
if isinstance(multiple, (int, float)) and multiple > 0:
|
|
291
|
+
quotient = Fraction(str(value)) / Fraction(str(multiple))
|
|
292
|
+
if quotient.denominator != 1:
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
if isinstance(value, str):
|
|
296
|
+
if len(value) < int(schema.get("minLength", 0)):
|
|
297
|
+
return False
|
|
298
|
+
max_length = schema.get("maxLength")
|
|
299
|
+
if max_length is not None and len(value) > int(max_length):
|
|
300
|
+
return False
|
|
301
|
+
pattern = schema.get("pattern")
|
|
302
|
+
if pattern:
|
|
303
|
+
try:
|
|
304
|
+
if re.search(str(pattern), value) is None:
|
|
305
|
+
return False
|
|
306
|
+
except re.error:
|
|
307
|
+
return False
|
|
308
|
+
fmt = str(schema.get("format", "")).lower()
|
|
309
|
+
if fmt == "uuid":
|
|
310
|
+
try:
|
|
311
|
+
uuid.UUID(value)
|
|
312
|
+
except (ValueError, AttributeError):
|
|
313
|
+
return False
|
|
314
|
+
elif fmt == "email" and not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", value):
|
|
315
|
+
return False
|
|
316
|
+
elif fmt == "date":
|
|
317
|
+
try:
|
|
318
|
+
datetime.date.fromisoformat(value)
|
|
319
|
+
except ValueError:
|
|
320
|
+
return False
|
|
321
|
+
elif fmt == "date-time":
|
|
322
|
+
try:
|
|
323
|
+
datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
324
|
+
except ValueError:
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
if isinstance(value, list):
|
|
328
|
+
if len(value) < int(schema.get("minItems", 0)):
|
|
329
|
+
return False
|
|
330
|
+
max_items = schema.get("maxItems")
|
|
331
|
+
if max_items is not None and len(value) > int(max_items):
|
|
332
|
+
return False
|
|
333
|
+
if schema.get("uniqueItems"):
|
|
334
|
+
if any(json_equal(item, previous) for index, item in enumerate(value) for previous in value[:index]):
|
|
335
|
+
return False
|
|
336
|
+
prefix = schema.get("prefixItems", [])
|
|
337
|
+
items = schema.get("items", {})
|
|
338
|
+
if isinstance(items, list):
|
|
339
|
+
prefix, items = items, schema.get("additionalItems", {})
|
|
340
|
+
if not all(check(item, prefix[index] if index < len(prefix) else items) for index, item in enumerate(value)):
|
|
341
|
+
return False
|
|
342
|
+
if "contains" in schema:
|
|
343
|
+
count = sum(check(item, schema["contains"]) for item in value)
|
|
344
|
+
if count < schema.get("minContains", 1) or count > schema.get("maxContains", math.inf):
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
if isinstance(value, dict):
|
|
348
|
+
properties = schema.get("properties", {})
|
|
349
|
+
if not isinstance(properties, dict):
|
|
350
|
+
return False
|
|
351
|
+
required = schema.get("required", [])
|
|
352
|
+
for name in required:
|
|
353
|
+
child = properties.get(name, {})
|
|
354
|
+
effective = cls._effective_schema(child) if isinstance(child, dict) else {}
|
|
355
|
+
if request and effective.get("readOnly"):
|
|
356
|
+
continue
|
|
357
|
+
if name not in value:
|
|
358
|
+
return False
|
|
359
|
+
if len(value) < schema.get("minProperties", 0) or len(value) > schema.get("maxProperties", math.inf):
|
|
360
|
+
return False
|
|
361
|
+
patterns = schema.get("patternProperties", {})
|
|
362
|
+
for name, item in value.items():
|
|
363
|
+
if "propertyNames" in schema and not check(name, schema["propertyNames"]):
|
|
364
|
+
return False
|
|
365
|
+
if name in properties and not check(item, properties[name]):
|
|
366
|
+
return False
|
|
367
|
+
matched = [child for pattern, child in patterns.items() if re.search(pattern, name)]
|
|
368
|
+
if not all(check(item, child) for child in matched):
|
|
369
|
+
return False
|
|
370
|
+
if name not in properties and not matched and not check(item, schema.get("additionalProperties", {})):
|
|
371
|
+
return False
|
|
372
|
+
for name, dependencies in schema.get("dependentRequired", {}).items():
|
|
373
|
+
if name in value and not all(key in value for key in dependencies):
|
|
374
|
+
return False
|
|
375
|
+
for name, child in schema.get("dependentSchemas", {}).items():
|
|
376
|
+
if name in value and not check(value, child):
|
|
377
|
+
return False
|
|
378
|
+
for name, dependency in schema.get("dependencies", {}).items():
|
|
379
|
+
if name in value:
|
|
380
|
+
if isinstance(dependency, list):
|
|
381
|
+
if not all(key in value for key in dependency):
|
|
382
|
+
return False
|
|
383
|
+
elif not check(value, dependency):
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
return True
|
|
387
|
+
|
|
388
|
+
@staticmethod
|
|
389
|
+
def _serialize_parameter_value(value: Any) -> str:
|
|
390
|
+
if isinstance(value, bool):
|
|
391
|
+
return "true" if value else "false"
|
|
392
|
+
if isinstance(value, list):
|
|
393
|
+
return ",".join(SmartDataGenerator._serialize_parameter_value(item) for item in value)
|
|
394
|
+
if isinstance(value, dict):
|
|
395
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
396
|
+
return str(value)
|
|
397
|
+
|
|
398
|
+
@classmethod
|
|
399
|
+
def generate_value_for_param(
|
|
400
|
+
cls, param_meta: Dict[str, Any], candidate_id: Optional[str] = None
|
|
401
|
+
) -> str:
|
|
402
|
+
"""Return the historical string representation for existing callers.
|
|
403
|
+
|
|
404
|
+
Request builders should use ``generate_raw_value_for_param`` so arrays,
|
|
405
|
+
objects and booleans retain their types until style-aware serialization.
|
|
406
|
+
"""
|
|
407
|
+
return cls._serialize_parameter_value(cls.generate_raw_value_for_param(param_meta, candidate_id))
|
|
408
|
+
|
|
409
|
+
@classmethod
|
|
410
|
+
def generate_raw_value_for_param(
|
|
411
|
+
cls, param_meta: Dict[str, Any], candidate_id: Optional[str] = None
|
|
412
|
+
) -> Any:
|
|
413
|
+
"""Generate a schema-valid parameter value without serializing it.
|
|
414
|
+
|
|
415
|
+
``candidate_id`` is applied only to parameters whose names identify a
|
|
416
|
+
resource (for example ``user_id`` or ``documentUuid``). Paging, search,
|
|
417
|
+
sorting, and unrelated query parameters continue to use their schemas.
|
|
418
|
+
"""
|
|
419
|
+
schema = cls._parameter_schema(param_meta)
|
|
420
|
+
|
|
421
|
+
if candidate_id is not None and cls._is_resource_identifier(param_meta):
|
|
422
|
+
candidate = cls._coerce_for_schema(candidate_id, schema)
|
|
423
|
+
if cls._value_satisfies_schema(candidate, schema):
|
|
424
|
+
return candidate
|
|
425
|
+
|
|
426
|
+
for documented in cls._documented_values(param_meta, schema):
|
|
427
|
+
value = cls._coerce_for_schema(documented, schema)
|
|
428
|
+
if cls._value_satisfies_schema(value, schema):
|
|
429
|
+
return value
|
|
430
|
+
|
|
431
|
+
if not schema:
|
|
432
|
+
parameter_name = str(param_meta.get("name", "")).lower()
|
|
433
|
+
return "test_user_id" if ("user" in parameter_name or "name" in parameter_name) else "sample_param"
|
|
434
|
+
|
|
435
|
+
value = cls._generate_schema_sample(schema, include_optional=True, field_name=param_meta.get("name"))
|
|
436
|
+
return value
|
|
437
|
+
|
|
438
|
+
@classmethod
|
|
439
|
+
def _numeric_bounds(cls, schema: Dict[str, Any]) -> Tuple[Any, bool, Any, bool]:
|
|
440
|
+
bounds = []
|
|
441
|
+
for ordinary, exclusive, lower in (
|
|
442
|
+
("minimum", "exclusiveMinimum", True), ("maximum", "exclusiveMaximum", False)
|
|
443
|
+
):
|
|
444
|
+
bound = schema.get(ordinary)
|
|
445
|
+
strict = schema.get(exclusive) is True
|
|
446
|
+
extra = schema.get(exclusive)
|
|
447
|
+
if isinstance(extra, (int, float)) and not isinstance(extra, bool):
|
|
448
|
+
if bound is None or (extra >= bound if lower else extra <= bound):
|
|
449
|
+
bound, strict = extra, True
|
|
450
|
+
bounds.extend((bound, strict))
|
|
451
|
+
return tuple(bounds)
|
|
452
|
+
|
|
453
|
+
@classmethod
|
|
454
|
+
def _numeric_sample(cls, schema: Dict[str, Any], integer: bool) -> Union[int, float]:
|
|
455
|
+
lower, lower_strict, upper, upper_strict = cls._numeric_bounds(schema)
|
|
456
|
+
if lower is not None and upper is not None:
|
|
457
|
+
if lower > upper or (lower == upper and (lower_strict or upper_strict)):
|
|
458
|
+
raise SchemaGenerationError("Numeric constraints have an empty intersection")
|
|
459
|
+
multiple = schema.get("multipleOf")
|
|
460
|
+
candidates: List[Any] = [0, 1, -1]
|
|
461
|
+
if multiple is not None:
|
|
462
|
+
if isinstance(multiple, bool) or multiple <= 0:
|
|
463
|
+
raise SchemaGenerationError("multipleOf must be positive")
|
|
464
|
+
step = Fraction(str(multiple))
|
|
465
|
+
if integer:
|
|
466
|
+
# Integer multiples of p/q are exactly multiples of p.
|
|
467
|
+
step = Fraction(step.numerator)
|
|
468
|
+
if lower is not None:
|
|
469
|
+
count = math.ceil(Fraction(str(lower)) / step)
|
|
470
|
+
if lower_strict and count * step <= Fraction(str(lower)):
|
|
471
|
+
count += 1
|
|
472
|
+
elif upper is not None:
|
|
473
|
+
count = math.floor(Fraction(str(upper)) / step)
|
|
474
|
+
if upper_strict and count * step >= Fraction(str(upper)):
|
|
475
|
+
count -= 1
|
|
476
|
+
else:
|
|
477
|
+
count = 1
|
|
478
|
+
candidates.insert(0, int(count * step) if integer else float(count * step))
|
|
479
|
+
elif integer:
|
|
480
|
+
if lower is not None:
|
|
481
|
+
candidates.insert(0, math.floor(lower) + 1 if lower_strict else math.ceil(lower))
|
|
482
|
+
if upper is not None:
|
|
483
|
+
candidates.append(math.ceil(upper) - 1 if upper_strict else math.floor(upper))
|
|
484
|
+
else:
|
|
485
|
+
if lower is not None and upper is not None:
|
|
486
|
+
candidates.insert(0, lower / 2 + upper / 2)
|
|
487
|
+
if lower is not None:
|
|
488
|
+
candidates.append(math.nextafter(float(lower), math.inf) if lower_strict else float(lower))
|
|
489
|
+
if upper is not None:
|
|
490
|
+
candidates.append(math.nextafter(float(upper), -math.inf) if upper_strict else float(upper))
|
|
491
|
+
for candidate in candidates:
|
|
492
|
+
if cls.validate_schema_value(candidate, schema):
|
|
493
|
+
return int(candidate) if integer else float(candidate)
|
|
494
|
+
raise SchemaGenerationError("Cannot construct a number satisfying all constraints")
|
|
495
|
+
|
|
496
|
+
@staticmethod
|
|
497
|
+
def _pattern_token(pattern: str) -> Optional[str]:
|
|
498
|
+
"""Synthesize common anchored regexes without adding a dependency."""
|
|
499
|
+
source = pattern
|
|
500
|
+
if source.startswith("^"):
|
|
501
|
+
source = source[1:]
|
|
502
|
+
if source.endswith("$") and not source.endswith("\\$"):
|
|
503
|
+
source = source[:-1]
|
|
504
|
+
|
|
505
|
+
result: List[str] = []
|
|
506
|
+
index = 0
|
|
507
|
+
try:
|
|
508
|
+
while index < len(source):
|
|
509
|
+
char = source[index]
|
|
510
|
+
if char in "()|":
|
|
511
|
+
return None
|
|
512
|
+
if char == "\\":
|
|
513
|
+
index += 1
|
|
514
|
+
token = source[index]
|
|
515
|
+
atom = {"d": "0", "w": "a", "s": " "}.get(token, token)
|
|
516
|
+
elif char == "[":
|
|
517
|
+
end = source.index("]", index + 1)
|
|
518
|
+
content = source[index + 1 : end]
|
|
519
|
+
if content.startswith("^"):
|
|
520
|
+
return None
|
|
521
|
+
if "A-Z" in content:
|
|
522
|
+
atom = "A"
|
|
523
|
+
elif "a-z" in content:
|
|
524
|
+
atom = "a"
|
|
525
|
+
elif "0-9" in content or "\\d" in content:
|
|
526
|
+
atom = "0"
|
|
527
|
+
else:
|
|
528
|
+
atom = content[0] if content else "a"
|
|
529
|
+
index = end
|
|
530
|
+
elif char == ".":
|
|
531
|
+
atom = "a"
|
|
532
|
+
else:
|
|
533
|
+
atom = char
|
|
534
|
+
|
|
535
|
+
repeat = 1
|
|
536
|
+
if index + 1 < len(source):
|
|
537
|
+
next_char = source[index + 1]
|
|
538
|
+
if next_char == "{":
|
|
539
|
+
end = source.index("}", index + 2)
|
|
540
|
+
repeat = int(source[index + 2 : end].split(",", 1)[0] or "0")
|
|
541
|
+
index = end
|
|
542
|
+
elif next_char == "+":
|
|
543
|
+
repeat = 1
|
|
544
|
+
index += 1
|
|
545
|
+
elif next_char in "*?":
|
|
546
|
+
repeat = 0
|
|
547
|
+
index += 1
|
|
548
|
+
result.append(atom * repeat)
|
|
549
|
+
index += 1
|
|
550
|
+
except (IndexError, ValueError):
|
|
551
|
+
return None
|
|
552
|
+
|
|
553
|
+
value = "".join(result)
|
|
554
|
+
try:
|
|
555
|
+
return value if re.search(pattern, value) is not None else None
|
|
556
|
+
except re.error:
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
@classmethod
|
|
560
|
+
def _string_sample(cls, schema: Dict[str, Any], field_name: Optional[str]) -> str:
|
|
561
|
+
fmt = str(schema.get("format", "")).lower()
|
|
562
|
+
normalized_name = str(field_name or "").lower()
|
|
563
|
+
if fmt == "uuid" or "uuid" in normalized_name:
|
|
564
|
+
value = str(uuid.uuid4())
|
|
565
|
+
elif fmt == "email" or "email" in normalized_name:
|
|
566
|
+
value = "audit@example.com"
|
|
567
|
+
elif fmt == "date-time":
|
|
568
|
+
value = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
569
|
+
elif fmt == "date":
|
|
570
|
+
value = datetime.date.today().isoformat()
|
|
571
|
+
elif schema.get("pattern"):
|
|
572
|
+
value = cls._pattern_token(str(schema["pattern"])) or "sample"
|
|
573
|
+
elif "name" in normalized_name:
|
|
574
|
+
value = "test_user"
|
|
575
|
+
else:
|
|
576
|
+
value = "sample_text"
|
|
577
|
+
|
|
578
|
+
minimum = int(schema.get("minLength", 0))
|
|
579
|
+
maximum_raw = schema.get("maxLength")
|
|
580
|
+
maximum = int(maximum_raw) if maximum_raw is not None else None
|
|
581
|
+
if len(value) < minimum:
|
|
582
|
+
value += "a" * (minimum - len(value))
|
|
583
|
+
if maximum is not None and len(value) > maximum:
|
|
584
|
+
value = value[:maximum]
|
|
585
|
+
return value
|
|
586
|
+
|
|
587
|
+
@classmethod
|
|
588
|
+
def _merge_constraints(cls, left: Dict[str, Any], right: Dict[str, Any]) -> Dict[str, Any]:
|
|
589
|
+
"""Build an intersection view for synthesis, never last-wins constraints.
|
|
590
|
+
|
|
591
|
+
Original schemas remain the authority for final recursive validation;
|
|
592
|
+
this view intentionally does not claim to normalize all JSON Schema.
|
|
593
|
+
"""
|
|
594
|
+
merged = copy.deepcopy(left)
|
|
595
|
+
for key, value in right.items():
|
|
596
|
+
if key not in merged:
|
|
597
|
+
merged[key] = copy.deepcopy(value)
|
|
598
|
+
elif key == "properties":
|
|
599
|
+
for name, child in value.items():
|
|
600
|
+
old = merged[key].get(name)
|
|
601
|
+
merged[key][name] = copy.deepcopy(child) if old is None or old == child else {"allOf": [old, child]}
|
|
602
|
+
elif key == "required":
|
|
603
|
+
merged[key] = list(dict.fromkeys(merged[key] + value))
|
|
604
|
+
elif key in {"minLength", "minItems", "minProperties", "minContains"}:
|
|
605
|
+
merged[key] = max(merged[key], value)
|
|
606
|
+
elif key in {"maxLength", "maxItems", "maxProperties", "maxContains"}:
|
|
607
|
+
merged[key] = min(merged[key], value)
|
|
608
|
+
elif key == "enum":
|
|
609
|
+
merged[key] = [item for item in merged[key] if cls.validate_schema_value(item, {"enum": value})]
|
|
610
|
+
elif key == "const" and merged[key] != value:
|
|
611
|
+
merged["enum"] = []
|
|
612
|
+
elif key == "type":
|
|
613
|
+
old_types = merged[key] if isinstance(merged[key], list) else [merged[key]]
|
|
614
|
+
new_types = value if isinstance(value, list) else [value]
|
|
615
|
+
shared = [kind for kind in old_types if kind in new_types]
|
|
616
|
+
if ("integer" in old_types and "number" in new_types) or ("number" in old_types and "integer" in new_types):
|
|
617
|
+
shared.append("integer")
|
|
618
|
+
shared = list(dict.fromkeys(shared))
|
|
619
|
+
merged[key] = shared[0] if len(shared) == 1 else shared
|
|
620
|
+
elif key in {"readOnly", "writeOnly", "uniqueItems"}:
|
|
621
|
+
merged[key] = bool(merged[key] or value)
|
|
622
|
+
elif key == "multipleOf":
|
|
623
|
+
a, b = Fraction(str(merged[key])), Fraction(str(value))
|
|
624
|
+
if a <= 0 or b <= 0:
|
|
625
|
+
raise SchemaGenerationError("multipleOf must be positive")
|
|
626
|
+
merged[key] = float(Fraction(math.lcm(a.numerator, b.numerator), math.gcd(a.denominator, b.denominator)))
|
|
627
|
+
elif key in {"items", "additionalProperties"} and merged[key] != value:
|
|
628
|
+
merged[key] = {"allOf": [merged[key], copy.deepcopy(value)]}
|
|
629
|
+
|
|
630
|
+
for offset, ordinary, exclusive in (
|
|
631
|
+
(0, "minimum", "exclusiveMinimum"), (2, "maximum", "exclusiveMaximum")
|
|
632
|
+
):
|
|
633
|
+
bounds = [cls._numeric_bounds(item)[offset:offset + 2] for item in (left, right)]
|
|
634
|
+
bounds = [item for item in bounds if item[0] is not None]
|
|
635
|
+
if bounds:
|
|
636
|
+
extreme = (max if offset == 0 else min)(item[0] for item in bounds)
|
|
637
|
+
merged[ordinary] = extreme
|
|
638
|
+
merged[exclusive] = any(strict for bound, strict in bounds if bound == extreme)
|
|
639
|
+
return merged
|
|
640
|
+
|
|
641
|
+
@classmethod
|
|
642
|
+
def _effective_schema(cls, schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
643
|
+
if schema is True:
|
|
644
|
+
return {}
|
|
645
|
+
if schema is False:
|
|
646
|
+
return {"enum": []}
|
|
647
|
+
if not isinstance(schema, dict):
|
|
648
|
+
raise SchemaGenerationError("A schema must be an object or boolean")
|
|
649
|
+
effective = {key: copy.deepcopy(value) for key, value in schema.items() if key != "allOf"}
|
|
650
|
+
branches = schema.get("allOf", [])
|
|
651
|
+
if not isinstance(branches, list):
|
|
652
|
+
raise SchemaGenerationError("allOf must be an array")
|
|
653
|
+
for branch in branches:
|
|
654
|
+
effective = cls._merge_constraints(effective, cls._effective_schema(branch))
|
|
655
|
+
return effective
|
|
656
|
+
|
|
657
|
+
@classmethod
|
|
658
|
+
def _request_sample(cls, value: Any, schema: Any) -> Any:
|
|
659
|
+
"""Remove readOnly data from documented examples before using them."""
|
|
660
|
+
effective = cls._effective_schema(schema)
|
|
661
|
+
if isinstance(value, dict):
|
|
662
|
+
properties = effective.get("properties", {})
|
|
663
|
+
return {
|
|
664
|
+
name: cls._request_sample(item, properties.get(name, {}))
|
|
665
|
+
for name, item in value.items()
|
|
666
|
+
if not cls._effective_schema(properties.get(name, {})).get("readOnly")
|
|
667
|
+
}
|
|
668
|
+
if isinstance(value, list):
|
|
669
|
+
items = effective.get("items", {})
|
|
670
|
+
prefix = effective.get("prefixItems", [])
|
|
671
|
+
if isinstance(items, list):
|
|
672
|
+
prefix, items = items, effective.get("additionalItems", {})
|
|
673
|
+
return [cls._request_sample(item, prefix[index] if index < len(prefix) else items) for index, item in enumerate(value)]
|
|
674
|
+
return copy.deepcopy(value)
|
|
675
|
+
|
|
676
|
+
@classmethod
|
|
677
|
+
def _generate_schema_sample(
|
|
678
|
+
cls,
|
|
679
|
+
schema: Dict[str, Any],
|
|
680
|
+
*,
|
|
681
|
+
include_optional: bool,
|
|
682
|
+
field_name: Optional[str] = None,
|
|
683
|
+
) -> Any:
|
|
684
|
+
if not isinstance(schema, (dict, bool)):
|
|
685
|
+
raise SchemaGenerationError("A schema must be an object or boolean")
|
|
686
|
+
effective = cls._effective_schema(schema)
|
|
687
|
+
for documented in cls._documented_values({}, effective):
|
|
688
|
+
candidate = cls._request_sample(documented, schema)
|
|
689
|
+
if cls.validate_schema_value(candidate, schema):
|
|
690
|
+
return candidate
|
|
691
|
+
if "enum" in effective and not effective["enum"]:
|
|
692
|
+
raise SchemaGenerationError("Schema constraints have an empty enum intersection")
|
|
693
|
+
|
|
694
|
+
for keyword in ("oneOf", "anyOf"):
|
|
695
|
+
if keyword not in effective:
|
|
696
|
+
continue
|
|
697
|
+
branches = effective[keyword]
|
|
698
|
+
base = {key: value for key, value in effective.items() if key != keyword}
|
|
699
|
+
for branch in branches:
|
|
700
|
+
try:
|
|
701
|
+
candidate = cls._generate_schema_sample(
|
|
702
|
+
{"allOf": [base, branch]}, include_optional=include_optional, field_name=field_name
|
|
703
|
+
)
|
|
704
|
+
except SchemaGenerationError:
|
|
705
|
+
continue
|
|
706
|
+
if cls.validate_schema_value(candidate, schema):
|
|
707
|
+
return candidate
|
|
708
|
+
raise SchemaGenerationError(f"Cannot construct a value satisfying {keyword}")
|
|
709
|
+
|
|
710
|
+
candidate = cls._synthesize_schema_sample(effective, include_optional=include_optional, field_name=field_name)
|
|
711
|
+
if not cls.validate_schema_value(candidate, schema):
|
|
712
|
+
raise SchemaGenerationError(
|
|
713
|
+
f"Cannot construct a valid value for {field_name or 'request body'}; "
|
|
714
|
+
"constraints may conflict or require unsupported synthesis. Supply a valid example."
|
|
715
|
+
)
|
|
716
|
+
return candidate
|
|
717
|
+
|
|
718
|
+
@classmethod
|
|
719
|
+
def _synthesize_schema_sample(cls, effective: Dict[str, Any], *, include_optional: bool, field_name: Optional[str]) -> Any:
|
|
720
|
+
if not effective:
|
|
721
|
+
return {}
|
|
722
|
+
|
|
723
|
+
schema_type = effective.get("type")
|
|
724
|
+
if isinstance(schema_type, list):
|
|
725
|
+
for kind in schema_type:
|
|
726
|
+
try:
|
|
727
|
+
return cls._generate_schema_sample({**effective, "type": kind}, include_optional=include_optional, field_name=field_name)
|
|
728
|
+
except SchemaGenerationError:
|
|
729
|
+
continue
|
|
730
|
+
raise SchemaGenerationError("No allowed type yields a valid request value")
|
|
731
|
+
if not schema_type:
|
|
732
|
+
if "properties" in effective or "additionalProperties" in effective:
|
|
733
|
+
schema_type = "object"
|
|
734
|
+
elif "items" in effective:
|
|
735
|
+
schema_type = "array"
|
|
736
|
+
else:
|
|
737
|
+
schema_type = "string"
|
|
738
|
+
|
|
739
|
+
if schema_type == "object":
|
|
740
|
+
properties = effective.get("properties", {})
|
|
741
|
+
if not isinstance(properties, dict):
|
|
742
|
+
raise SchemaGenerationError("Object properties must be a mapping")
|
|
743
|
+
required = set(effective.get("required", []))
|
|
744
|
+
result: Dict[str, Any] = {}
|
|
745
|
+
for name, child in properties.items():
|
|
746
|
+
if cls._effective_schema(child).get("readOnly"):
|
|
747
|
+
continue
|
|
748
|
+
if include_optional or name in required or len(result) < effective.get("minProperties", 0):
|
|
749
|
+
result[name] = cls._generate_schema_sample(
|
|
750
|
+
child, include_optional=include_optional, field_name=name
|
|
751
|
+
)
|
|
752
|
+
for name in required - set(properties):
|
|
753
|
+
result[name] = cls._generate_schema_sample(
|
|
754
|
+
effective.get("additionalProperties", {}), include_optional=include_optional, field_name=name
|
|
755
|
+
)
|
|
756
|
+
return result
|
|
757
|
+
|
|
758
|
+
if schema_type == "array":
|
|
759
|
+
minimum = int(effective.get("minItems", 0))
|
|
760
|
+
maximum_raw = effective.get("maxItems")
|
|
761
|
+
maximum = int(maximum_raw) if maximum_raw is not None else None
|
|
762
|
+
count = max(1, minimum) if include_optional else minimum
|
|
763
|
+
if maximum is not None:
|
|
764
|
+
count = min(count, maximum)
|
|
765
|
+
item_schema = effective.get("items", {})
|
|
766
|
+
prefix = effective.get("prefixItems", [])
|
|
767
|
+
if isinstance(item_schema, list):
|
|
768
|
+
prefix, item_schema = item_schema, effective.get("additionalItems", {})
|
|
769
|
+
return [
|
|
770
|
+
cls._generate_schema_sample(
|
|
771
|
+
prefix[index] if index < len(prefix) else item_schema,
|
|
772
|
+
include_optional=include_optional, field_name=field_name
|
|
773
|
+
)
|
|
774
|
+
for index in range(max(0, count))
|
|
775
|
+
]
|
|
776
|
+
if schema_type == "integer":
|
|
777
|
+
return cls._numeric_sample(effective, integer=True)
|
|
778
|
+
if schema_type == "number":
|
|
779
|
+
return cls._numeric_sample(effective, integer=False)
|
|
780
|
+
if schema_type == "boolean":
|
|
781
|
+
return False
|
|
782
|
+
if schema_type == "null":
|
|
783
|
+
return None
|
|
784
|
+
return cls._string_sample(effective, field_name)
|
|
785
|
+
|
|
786
|
+
@classmethod
|
|
787
|
+
def generate_schema_sample(cls, schema: Dict[str, Any]) -> Any:
|
|
788
|
+
"""Backward-compatible full schema sample (includes optional fields)."""
|
|
789
|
+
return cls._generate_schema_sample(schema, include_optional=True)
|
|
790
|
+
|
|
791
|
+
@classmethod
|
|
792
|
+
def build_baseline_body(
|
|
793
|
+
cls, schema: Optional[Dict[str, Any]], *, include_optional: bool = False
|
|
794
|
+
) -> Any:
|
|
795
|
+
"""Build a valid baseline body, including required fields by default."""
|
|
796
|
+
if schema is None:
|
|
797
|
+
return {}
|
|
798
|
+
return cls._generate_schema_sample(schema, include_optional=include_optional)
|
|
799
|
+
|
|
800
|
+
# A descriptive alias for callers that prefer the word "valid".
|
|
801
|
+
build_valid_baseline_body = build_baseline_body
|
|
802
|
+
|
|
803
|
+
@classmethod
|
|
804
|
+
def _normalized_privilege_names(cls) -> set:
|
|
805
|
+
return {
|
|
806
|
+
re.sub(r"[^a-z0-9]", "", key.lower())
|
|
807
|
+
for key in cls.PRIVILEGE_KEYS
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
@classmethod
|
|
811
|
+
def _is_privilege_key(cls, name: str) -> bool:
|
|
812
|
+
normalized = re.sub(r"[^a-z0-9]", "", name.lower())
|
|
813
|
+
targets = cls._normalized_privilege_names()
|
|
814
|
+
if normalized in targets:
|
|
815
|
+
return True
|
|
816
|
+
if any(normalized.endswith(target) for target in targets if len(target) >= 4):
|
|
817
|
+
return True
|
|
818
|
+
# Preserve the old detector's useful handling of names such as
|
|
819
|
+
# account_role while avoiding matches in arbitrary prose-like names.
|
|
820
|
+
tokens = [
|
|
821
|
+
re.sub(r"[^a-z0-9]", "", token)
|
|
822
|
+
for token in re.split(r"[_\-.\s]+", name.lower())
|
|
823
|
+
]
|
|
824
|
+
return any(token in targets for token in tokens if token)
|
|
825
|
+
|
|
826
|
+
@classmethod
|
|
827
|
+
def _iter_privilege_fields(
|
|
828
|
+
cls,
|
|
829
|
+
schema: Dict[str, Any],
|
|
830
|
+
path: Tuple[JsonPathPart, ...] = (),
|
|
831
|
+
) -> Iterable[Tuple[Tuple[JsonPathPart, ...], Dict[str, Any]]]:
|
|
832
|
+
effective = cls._effective_schema(schema)
|
|
833
|
+
for keyword in ("oneOf", "anyOf"):
|
|
834
|
+
for branch in effective.get(keyword, []):
|
|
835
|
+
if isinstance(branch, dict):
|
|
836
|
+
yield from cls._iter_privilege_fields(branch, path)
|
|
837
|
+
properties = effective.get("properties", {})
|
|
838
|
+
if not isinstance(properties, dict):
|
|
839
|
+
return
|
|
840
|
+
for name, child in properties.items():
|
|
841
|
+
# readOnly describes normal writes; it is precisely a useful
|
|
842
|
+
# protected-field candidate for authorized mass-assignment tests.
|
|
843
|
+
if not isinstance(child, dict):
|
|
844
|
+
continue
|
|
845
|
+
child_path = path + (name,)
|
|
846
|
+
if cls._is_privilege_key(name):
|
|
847
|
+
yield child_path, child
|
|
848
|
+
|
|
849
|
+
child_effective = cls._effective_schema(child)
|
|
850
|
+
child_type = child_effective.get("type")
|
|
851
|
+
if child_type == "object" or "properties" in child_effective:
|
|
852
|
+
yield from cls._iter_privilege_fields(child_effective, child_path)
|
|
853
|
+
elif child_type == "array" and isinstance(child_effective.get("items"), dict):
|
|
854
|
+
item_schema = cls._effective_schema(child_effective["items"])
|
|
855
|
+
if item_schema.get("type") == "object" or "properties" in item_schema:
|
|
856
|
+
yield from cls._iter_privilege_fields(item_schema, child_path + (0,))
|
|
857
|
+
|
|
858
|
+
@classmethod
|
|
859
|
+
def _mutation_value(cls, field_name: str, schema: Dict[str, Any]) -> Any:
|
|
860
|
+
effective = cls._effective_schema(schema)
|
|
861
|
+
schema_type = effective.get("type")
|
|
862
|
+
if not schema_type:
|
|
863
|
+
if "properties" in effective:
|
|
864
|
+
schema_type = "object"
|
|
865
|
+
elif "items" in effective:
|
|
866
|
+
schema_type = "array"
|
|
867
|
+
else:
|
|
868
|
+
schema_type = "string"
|
|
869
|
+
|
|
870
|
+
normalized = field_name.lower()
|
|
871
|
+
if schema_type == "boolean":
|
|
872
|
+
candidates: Sequence[Any] = (True, False)
|
|
873
|
+
elif schema_type in {"integer", "number"}:
|
|
874
|
+
maximum = effective.get("maximum")
|
|
875
|
+
candidates = (maximum, 1, cls._numeric_sample(effective, schema_type == "integer"))
|
|
876
|
+
elif schema_type == "array":
|
|
877
|
+
item_schema = effective.get("items", {})
|
|
878
|
+
item_schema = item_schema if isinstance(item_schema, dict) else {}
|
|
879
|
+
item_value = cls._mutation_value(field_name, item_schema)
|
|
880
|
+
count = max(1, int(effective.get("minItems", 0)))
|
|
881
|
+
maximum = effective.get("maxItems")
|
|
882
|
+
if maximum is not None:
|
|
883
|
+
count = min(count, int(maximum))
|
|
884
|
+
candidates = ([copy.deepcopy(item_value) for _ in range(max(0, count))],)
|
|
885
|
+
elif schema_type == "object":
|
|
886
|
+
candidates = (cls._generate_schema_sample(effective, include_optional=True),)
|
|
887
|
+
else:
|
|
888
|
+
if any(word in normalized for word in ("role", "admin", "user_type")):
|
|
889
|
+
preferred = ("admin", "superuser", "root")
|
|
890
|
+
elif any(word in normalized for word in ("permission", "privilege")):
|
|
891
|
+
preferred = ("root", "*", "admin")
|
|
892
|
+
elif normalized in {"status", "verified"}:
|
|
893
|
+
preferred = ("active", "verified", "admin")
|
|
894
|
+
elif normalized in {"tier", "plan", "group", "group_id"}:
|
|
895
|
+
preferred = ("enterprise", "premium", "admin")
|
|
896
|
+
else:
|
|
897
|
+
preferred = ("admin", "root", "superuser")
|
|
898
|
+
enum = effective.get("enum")
|
|
899
|
+
if isinstance(enum, list):
|
|
900
|
+
ordered_enum = [value for value in preferred if value in enum]
|
|
901
|
+
ordered_enum.extend(value for value in enum if value not in ordered_enum)
|
|
902
|
+
candidates = tuple(ordered_enum) + preferred
|
|
903
|
+
else:
|
|
904
|
+
candidates = preferred
|
|
905
|
+
|
|
906
|
+
for candidate in candidates:
|
|
907
|
+
if candidate is None:
|
|
908
|
+
continue
|
|
909
|
+
coerced = cls._coerce_for_schema(candidate, effective)
|
|
910
|
+
if cls._value_satisfies_schema(coerced, effective):
|
|
911
|
+
return copy.deepcopy(coerced)
|
|
912
|
+
return cls._generate_schema_sample(
|
|
913
|
+
effective, include_optional=True, field_name=field_name
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
@staticmethod
|
|
917
|
+
def _set_path(container: Any, path: Tuple[JsonPathPart, ...], value: Any) -> Any:
|
|
918
|
+
if not path:
|
|
919
|
+
return copy.deepcopy(value)
|
|
920
|
+
root = copy.deepcopy(container)
|
|
921
|
+
if not isinstance(root, (dict, list)):
|
|
922
|
+
root = [] if isinstance(path[0], int) else {}
|
|
923
|
+
current = root
|
|
924
|
+
for index, part in enumerate(path):
|
|
925
|
+
final = index == len(path) - 1
|
|
926
|
+
next_part = None if final else path[index + 1]
|
|
927
|
+
if isinstance(part, int):
|
|
928
|
+
if not isinstance(current, list):
|
|
929
|
+
raise TypeError("Numeric mutation path requires a list container")
|
|
930
|
+
while len(current) <= part:
|
|
931
|
+
current.append([] if isinstance(next_part, int) else {})
|
|
932
|
+
if final:
|
|
933
|
+
current[part] = copy.deepcopy(value)
|
|
934
|
+
else:
|
|
935
|
+
if not isinstance(current[part], (dict, list)):
|
|
936
|
+
current[part] = [] if isinstance(next_part, int) else {}
|
|
937
|
+
current = current[part]
|
|
938
|
+
else:
|
|
939
|
+
if not isinstance(current, dict):
|
|
940
|
+
raise TypeError("Named mutation path requires an object container")
|
|
941
|
+
if final:
|
|
942
|
+
current[part] = copy.deepcopy(value)
|
|
943
|
+
else:
|
|
944
|
+
expected = list if isinstance(next_part, int) else dict
|
|
945
|
+
if not isinstance(current.get(part), expected):
|
|
946
|
+
current[part] = [] if isinstance(next_part, int) else {}
|
|
947
|
+
current = current[part]
|
|
948
|
+
return root
|
|
949
|
+
|
|
950
|
+
@staticmethod
|
|
951
|
+
def _format_path(path: Tuple[JsonPathPart, ...]) -> str:
|
|
952
|
+
output = ""
|
|
953
|
+
for part in path:
|
|
954
|
+
if isinstance(part, int):
|
|
955
|
+
output += f"[{part}]"
|
|
956
|
+
else:
|
|
957
|
+
output += ("." if output else "") + part
|
|
958
|
+
return output
|
|
959
|
+
|
|
960
|
+
@classmethod
|
|
961
|
+
def build_mass_assignment_cases(
|
|
962
|
+
cls,
|
|
963
|
+
schema: Optional[Dict[str, Any]],
|
|
964
|
+
baseline: Optional[Any] = None,
|
|
965
|
+
) -> List[Dict[str, Any]]:
|
|
966
|
+
"""Return complete one-field-at-a-time mutation cases.
|
|
967
|
+
|
|
968
|
+
Each result contains ``field_path`` (dot/bracket notation), ``path``
|
|
969
|
+
(tuple form), ``value``, ``mutation`` (sparse body), and ``payload``
|
|
970
|
+
(the valid baseline plus only that mutation).
|
|
971
|
+
"""
|
|
972
|
+
effective_schema = schema if isinstance(schema, dict) else {}
|
|
973
|
+
base = (
|
|
974
|
+
copy.deepcopy(baseline)
|
|
975
|
+
if baseline is not None
|
|
976
|
+
else cls.build_baseline_body(effective_schema)
|
|
977
|
+
)
|
|
978
|
+
cases: List[Dict[str, Any]] = []
|
|
979
|
+
fields = []
|
|
980
|
+
seen_fields = set()
|
|
981
|
+
for path, field_schema in cls._iter_privilege_fields(effective_schema):
|
|
982
|
+
if path not in seen_fields:
|
|
983
|
+
fields.append((path, field_schema))
|
|
984
|
+
seen_fields.add(path)
|
|
985
|
+
if not fields:
|
|
986
|
+
fields = [
|
|
987
|
+
(("role",), {"type": "string", "enum": ["admin"]}),
|
|
988
|
+
(("is_admin",), {"type": "boolean"}),
|
|
989
|
+
(("privilege",), {"type": "string", "enum": ["root"]}),
|
|
990
|
+
(("user_type",), {"type": "string", "enum": ["superuser"]}),
|
|
991
|
+
]
|
|
992
|
+
|
|
993
|
+
for path, field_schema in fields:
|
|
994
|
+
field_name = str(next((part for part in reversed(path) if isinstance(part, str)), ""))
|
|
995
|
+
value = cls._mutation_value(field_name, field_schema)
|
|
996
|
+
mutation = cls._set_path({}, path, value)
|
|
997
|
+
payload = cls._set_path(base, path, value)
|
|
998
|
+
cases.append(
|
|
999
|
+
{
|
|
1000
|
+
"field_path": cls._format_path(path),
|
|
1001
|
+
"path": path,
|
|
1002
|
+
"value": value,
|
|
1003
|
+
"mutation": mutation,
|
|
1004
|
+
"payload": payload,
|
|
1005
|
+
}
|
|
1006
|
+
)
|
|
1007
|
+
return cases
|
|
1008
|
+
|
|
1009
|
+
@classmethod
|
|
1010
|
+
def build_mass_assignment_payloads(
|
|
1011
|
+
cls,
|
|
1012
|
+
schema: Optional[Dict[str, Any]],
|
|
1013
|
+
baseline: Optional[Any] = None,
|
|
1014
|
+
) -> List[Dict[str, Any]]:
|
|
1015
|
+
"""Convenience view returning just complete payloads for each case."""
|
|
1016
|
+
return [case["payload"] for case in cls.build_mass_assignment_cases(schema, baseline)]
|
|
1017
|
+
|
|
1018
|
+
@classmethod
|
|
1019
|
+
def build_mass_assignment_payload(cls, schema: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
1020
|
+
"""Return the historical combined sparse mutation payload.
|
|
1021
|
+
|
|
1022
|
+
New callers should prefer :meth:`build_mass_assignment_cases` so each
|
|
1023
|
+
dangerous field is tested independently.
|
|
1024
|
+
"""
|
|
1025
|
+
combined: Dict[str, Any] = {}
|
|
1026
|
+
for case in cls.build_mass_assignment_cases(schema, baseline={}):
|
|
1027
|
+
combined = cls._deep_merge(combined, case["mutation"])
|
|
1028
|
+
return combined
|
|
1029
|
+
|
|
1030
|
+
@classmethod
|
|
1031
|
+
def _deep_merge(cls, left: Dict[str, Any], right: Dict[str, Any]) -> Dict[str, Any]:
|
|
1032
|
+
merged = copy.deepcopy(left)
|
|
1033
|
+
for key, value in right.items():
|
|
1034
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
1035
|
+
merged[key] = cls._deep_merge(merged[key], value)
|
|
1036
|
+
elif isinstance(value, list) and isinstance(merged.get(key), list):
|
|
1037
|
+
result = copy.deepcopy(merged[key])
|
|
1038
|
+
for index, item in enumerate(value):
|
|
1039
|
+
if index < len(result) and isinstance(item, dict) and isinstance(result[index], dict):
|
|
1040
|
+
result[index] = cls._deep_merge(result[index], item)
|
|
1041
|
+
elif index < len(result):
|
|
1042
|
+
result[index] = copy.deepcopy(item)
|
|
1043
|
+
else:
|
|
1044
|
+
result.append(copy.deepcopy(item))
|
|
1045
|
+
merged[key] = result
|
|
1046
|
+
else:
|
|
1047
|
+
merged[key] = copy.deepcopy(value)
|
|
1048
|
+
return merged
|