protoloom 0.1.4__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.
- protoloom/__init__.py +22 -0
- protoloom/bench/__init__.py +27 -0
- protoloom/bench/corpus.py +332 -0
- protoloom/bench/jsonio.py +60 -0
- protoloom/bench/metrics.py +299 -0
- protoloom/bench/runner.py +256 -0
- protoloom/bench/upstream.py +385 -0
- protoloom/cli.py +956 -0
- protoloom/container/__init__.py +3 -0
- protoloom/container/apk.py +204 -0
- protoloom/container/detect.py +134 -0
- protoloom/container/dex.py +698 -0
- protoloom/container/elf.py +189 -0
- protoloom/container/macho.py +172 -0
- protoloom/container/read.py +33 -0
- protoloom/decode/__init__.py +4 -0
- protoloom/decode/descpb.py +79 -0
- protoloom/decode/fieldtype.py +69 -0
- protoloom/decode/infostring.py +183 -0
- protoloom/decode/lite.py +447 -0
- protoloom/decode/names.py +78 -0
- protoloom/decode/wire.py +308 -0
- protoloom/doctor.py +101 -0
- protoloom/emit/__init__.py +3 -0
- protoloom/emit/dashboard.py +191 -0
- protoloom/emit/descset.py +74 -0
- protoloom/emit/jsonout.py +98 -0
- protoloom/emit/proto.py +363 -0
- protoloom/emit/report.py +80 -0
- protoloom/extract/__init__.py +18 -0
- protoloom/extract/descriptor.py +122 -0
- protoloom/extract/gotags.py +242 -0
- protoloom/extract/gozip.py +39 -0
- protoloom/extract/jadx.py +211 -0
- protoloom/extract/lite.py +977 -0
- protoloom/extract/wire.py +709 -0
- protoloom/model.py +82 -0
- protoloom/py.typed +0 -0
- protoloom/reconcile.py +291 -0
- protoloom/tui/__init__.py +0 -0
- protoloom/tui/application.py +381 -0
- protoloom/tui/jobs.py +155 -0
- protoloom/tui/render.py +120 -0
- protoloom/tui/results.py +136 -0
- protoloom/tui/state.py +51 -0
- protoloom/validate/__init__.py +14 -0
- protoloom/validate/compile.py +107 -0
- protoloom/validate/roundtrip.py +83 -0
- protoloom-0.1.4.dist-info/METADATA +337 -0
- protoloom-0.1.4.dist-info/RECORD +53 -0
- protoloom-0.1.4.dist-info/WHEEL +4 -0
- protoloom-0.1.4.dist-info/entry_points.txt +2 -0
- protoloom-0.1.4.dist-info/licenses/LICENSE +202 -0
protoloom/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from protoloom.model import (
|
|
2
|
+
Confidence,
|
|
3
|
+
EnumType,
|
|
4
|
+
EnumValue,
|
|
5
|
+
Evidence,
|
|
6
|
+
Field,
|
|
7
|
+
Message,
|
|
8
|
+
RecoveredSchema,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.4"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Confidence",
|
|
15
|
+
"EnumType",
|
|
16
|
+
"EnumValue",
|
|
17
|
+
"Evidence",
|
|
18
|
+
"Field",
|
|
19
|
+
"Message",
|
|
20
|
+
"RecoveredSchema",
|
|
21
|
+
"__version__",
|
|
22
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from protoloom.bench.metrics import (
|
|
2
|
+
METRIC_NAMES,
|
|
3
|
+
AggregateReport,
|
|
4
|
+
BenchmarkEnum,
|
|
5
|
+
BenchmarkField,
|
|
6
|
+
BenchmarkMessage,
|
|
7
|
+
BenchmarkSchema,
|
|
8
|
+
MetricReport,
|
|
9
|
+
Score,
|
|
10
|
+
aggregate_reports,
|
|
11
|
+
score_target,
|
|
12
|
+
type_fidelity_ceiling,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"METRIC_NAMES",
|
|
17
|
+
"AggregateReport",
|
|
18
|
+
"BenchmarkEnum",
|
|
19
|
+
"BenchmarkField",
|
|
20
|
+
"BenchmarkMessage",
|
|
21
|
+
"BenchmarkSchema",
|
|
22
|
+
"MetricReport",
|
|
23
|
+
"Score",
|
|
24
|
+
"aggregate_reports",
|
|
25
|
+
"score_target",
|
|
26
|
+
"type_fidelity_ceiling",
|
|
27
|
+
]
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import itertools
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import tempfile
|
|
6
|
+
import unicodedata
|
|
7
|
+
import urllib.request
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from math import prod
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, BinaryIO, cast
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
from protoloom.bench.jsonio import read_json
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CorpusError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
MAX_CORPUS_NAME_BYTES = 255
|
|
23
|
+
MAX_CORPUS_ARTIFACT_SIZE = 1024 * 1024 * 1024
|
|
24
|
+
MAX_MATRIX_AXES = 32
|
|
25
|
+
MAX_COMPILATION_JOBS = 100_000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _https_url(value: str) -> str:
|
|
29
|
+
parsed = urlparse(value)
|
|
30
|
+
if parsed.scheme != "https" or not parsed.netloc or parsed.username is not None:
|
|
31
|
+
raise CorpusError(f"artifact URL must be unauthenticated HTTPS: {value}")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _validate_name(name: str, label: str) -> None:
|
|
36
|
+
if (
|
|
37
|
+
name in {"", ".", ".."}
|
|
38
|
+
or Path(name).name != name
|
|
39
|
+
or len(name.encode("utf-8")) > MAX_CORPUS_NAME_BYTES
|
|
40
|
+
or any(unicodedata.category(character).startswith("C") for character in name)
|
|
41
|
+
):
|
|
42
|
+
raise CorpusError(f"{label} name is unsafe")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Artifact:
|
|
47
|
+
name: str
|
|
48
|
+
sha256: str
|
|
49
|
+
path: str | None = None
|
|
50
|
+
url: str | None = None
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
_validate_name(self.name, "artifact")
|
|
54
|
+
if (self.path is None) == (self.url is None):
|
|
55
|
+
raise CorpusError(f"artifact {self.name!r} needs exactly one source")
|
|
56
|
+
if len(self.sha256) != 64 or any(
|
|
57
|
+
character not in "0123456789abcdef" for character in self.sha256
|
|
58
|
+
):
|
|
59
|
+
raise CorpusError(f"artifact {self.name!r} has an invalid SHA-256")
|
|
60
|
+
if self.url is not None:
|
|
61
|
+
_https_url(self.url)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class CorpusTarget:
|
|
66
|
+
name: str
|
|
67
|
+
truth: Artifact
|
|
68
|
+
recovered: Artifact
|
|
69
|
+
|
|
70
|
+
def __post_init__(self) -> None:
|
|
71
|
+
_validate_name(self.name, "target")
|
|
72
|
+
if self.truth.name == self.recovered.name:
|
|
73
|
+
raise CorpusError(f"target {self.name!r} has duplicate artifact names")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class CompilationJob:
|
|
78
|
+
target: CorpusTarget
|
|
79
|
+
variant: Mapping[str, str]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class CorpusManifest:
|
|
84
|
+
name: str
|
|
85
|
+
targets: tuple[CorpusTarget, ...]
|
|
86
|
+
matrix: Mapping[str, tuple[str, ...]]
|
|
87
|
+
root: Path
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
_validate_name(self.name, "corpus")
|
|
91
|
+
if not self.targets:
|
|
92
|
+
raise CorpusError("manifest must contain at least one target")
|
|
93
|
+
if len({target.name for target in self.targets}) != len(self.targets):
|
|
94
|
+
raise CorpusError("target names must be unique")
|
|
95
|
+
if len(self.matrix) > MAX_MATRIX_AXES:
|
|
96
|
+
raise CorpusError(f"matrix contains more than {MAX_MATRIX_AXES} axes")
|
|
97
|
+
if any(not values for values in self.matrix.values()):
|
|
98
|
+
raise CorpusError("matrix axes cannot be empty")
|
|
99
|
+
if any(len(set(values)) != len(values) for values in self.matrix.values()):
|
|
100
|
+
raise CorpusError("matrix axis values must be unique")
|
|
101
|
+
jobs = len(self.targets) * prod(len(values) for values in self.matrix.values())
|
|
102
|
+
if jobs > MAX_COMPILATION_JOBS:
|
|
103
|
+
raise CorpusError(f"matrix expands beyond {MAX_COMPILATION_JOBS} jobs")
|
|
104
|
+
|
|
105
|
+
def variants(self) -> tuple[Mapping[str, str], ...]:
|
|
106
|
+
keys = tuple(self.matrix)
|
|
107
|
+
products = itertools.product(*(self.matrix[key] for key in keys))
|
|
108
|
+
return tuple(dict(zip(keys, values, strict=True)) for values in products)
|
|
109
|
+
|
|
110
|
+
def compilation_jobs(self) -> tuple[CompilationJob, ...]:
|
|
111
|
+
variants = self.variants()
|
|
112
|
+
return tuple(
|
|
113
|
+
CompilationJob(target, variant)
|
|
114
|
+
for target in self.targets
|
|
115
|
+
for variant in variants
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def drive_compilation_matrix(
|
|
120
|
+
manifest: CorpusManifest, build: Callable[[CompilationJob], None]
|
|
121
|
+
) -> None:
|
|
122
|
+
for job in manifest.compilation_jobs():
|
|
123
|
+
build(job)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_manifest(path: Path) -> CorpusManifest:
|
|
127
|
+
try:
|
|
128
|
+
raw = read_json(path)
|
|
129
|
+
if not isinstance(raw, dict):
|
|
130
|
+
raise CorpusError("manifest root must be an object")
|
|
131
|
+
_reject_unknown(raw, {"name", "targets", "matrix"}, "manifest")
|
|
132
|
+
targets = tuple(_target(item) for item in _list(raw, "targets"))
|
|
133
|
+
if not targets:
|
|
134
|
+
raise CorpusError("manifest must contain at least one target")
|
|
135
|
+
names = [target.name for target in targets]
|
|
136
|
+
if len(set(names)) != len(names):
|
|
137
|
+
raise CorpusError("target names must be unique")
|
|
138
|
+
matrix_raw = raw.get("matrix", {})
|
|
139
|
+
if not isinstance(matrix_raw, dict):
|
|
140
|
+
raise CorpusError("matrix must be an object")
|
|
141
|
+
matrix: dict[str, tuple[str, ...]] = {}
|
|
142
|
+
for key, values in matrix_raw.items():
|
|
143
|
+
axis = _safe_string(key, "matrix axis")
|
|
144
|
+
matrix[axis] = tuple(
|
|
145
|
+
_safe_string(value, "matrix value")
|
|
146
|
+
for value in _as_list(values, "matrix value")
|
|
147
|
+
)
|
|
148
|
+
if any(not values for values in matrix.values()):
|
|
149
|
+
raise CorpusError("matrix axes cannot be empty")
|
|
150
|
+
return CorpusManifest(
|
|
151
|
+
_string(raw["name"], "corpus name"),
|
|
152
|
+
targets,
|
|
153
|
+
matrix,
|
|
154
|
+
path.parent.resolve(),
|
|
155
|
+
)
|
|
156
|
+
except CorpusError:
|
|
157
|
+
raise
|
|
158
|
+
except (
|
|
159
|
+
KeyError,
|
|
160
|
+
TypeError,
|
|
161
|
+
ValueError,
|
|
162
|
+
UnicodeError,
|
|
163
|
+
OSError,
|
|
164
|
+
RecursionError,
|
|
165
|
+
) as error:
|
|
166
|
+
raise CorpusError(f"invalid corpus manifest: {path}") from error
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def materialize(
|
|
170
|
+
manifest: CorpusManifest,
|
|
171
|
+
destination: Path,
|
|
172
|
+
*,
|
|
173
|
+
max_artifact_size: int = MAX_CORPUS_ARTIFACT_SIZE,
|
|
174
|
+
) -> Mapping[str, Path]:
|
|
175
|
+
if max_artifact_size <= 0:
|
|
176
|
+
raise ValueError("maximum artifact size must be positive")
|
|
177
|
+
if destination.is_symlink():
|
|
178
|
+
raise CorpusError(f"corpus cache is a symlink: {destination}")
|
|
179
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
resolved: dict[str, Path] = {}
|
|
181
|
+
for target in manifest.targets:
|
|
182
|
+
target_root = destination / target.name
|
|
183
|
+
if target_root.is_symlink():
|
|
184
|
+
raise CorpusError(f"target cache is a symlink: {target_root}")
|
|
185
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
for artifact in (target.truth, target.recovered):
|
|
187
|
+
key = f"{target.name}/{artifact.name}"
|
|
188
|
+
output = target_root / artifact.name
|
|
189
|
+
if output.is_symlink():
|
|
190
|
+
raise CorpusError(f"artifact cache is a symlink: {output}")
|
|
191
|
+
if (
|
|
192
|
+
not output.exists()
|
|
193
|
+
or sha256(output, max_artifact_size) != artifact.sha256
|
|
194
|
+
):
|
|
195
|
+
_copy_artifact(manifest.root, artifact, output, max_artifact_size)
|
|
196
|
+
digest = sha256(output, max_artifact_size)
|
|
197
|
+
if digest != artifact.sha256:
|
|
198
|
+
output.unlink(missing_ok=True)
|
|
199
|
+
raise CorpusError(
|
|
200
|
+
f"SHA-256 mismatch for {key}: expected "
|
|
201
|
+
f"{artifact.sha256}, got {digest}"
|
|
202
|
+
)
|
|
203
|
+
resolved[key] = output
|
|
204
|
+
return resolved
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def sha256(path: Path, max_size: int = MAX_CORPUS_ARTIFACT_SIZE) -> str:
|
|
208
|
+
if max_size <= 0:
|
|
209
|
+
raise ValueError("maximum artifact size must be positive")
|
|
210
|
+
digest = hashlib.sha256()
|
|
211
|
+
total = 0
|
|
212
|
+
with path.open("rb") as stream:
|
|
213
|
+
_require_regular(stream, path)
|
|
214
|
+
if os.fstat(stream.fileno()).st_size > max_size:
|
|
215
|
+
raise CorpusError(f"artifact exceeds {max_size} bytes: {path}")
|
|
216
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
217
|
+
total += len(chunk)
|
|
218
|
+
if total > max_size:
|
|
219
|
+
raise CorpusError(f"artifact exceeds {max_size} bytes: {path}")
|
|
220
|
+
digest.update(chunk)
|
|
221
|
+
return digest.hexdigest()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _copy_artifact(root: Path, artifact: Artifact, output: Path, max_size: int) -> None:
|
|
225
|
+
temporary: Path | None = None
|
|
226
|
+
try:
|
|
227
|
+
with tempfile.NamedTemporaryFile(
|
|
228
|
+
mode="wb", dir=output.parent, prefix=f".{output.name}.", delete=False
|
|
229
|
+
) as writer:
|
|
230
|
+
temporary = Path(writer.name)
|
|
231
|
+
sink = cast(BinaryIO, writer)
|
|
232
|
+
if artifact.path is not None:
|
|
233
|
+
source = (root / artifact.path).resolve()
|
|
234
|
+
if not source.is_relative_to(root):
|
|
235
|
+
raise CorpusError(
|
|
236
|
+
f"artifact path escapes corpus root: {artifact.path}"
|
|
237
|
+
)
|
|
238
|
+
with source.open("rb") as reader:
|
|
239
|
+
_require_regular(reader, source)
|
|
240
|
+
_copy_bounded(reader, sink, max_size)
|
|
241
|
+
else:
|
|
242
|
+
assert artifact.url is not None
|
|
243
|
+
with urllib.request.urlopen(artifact.url, timeout=30) as response:
|
|
244
|
+
_https_url(response.geturl())
|
|
245
|
+
_copy_bounded(response, sink, max_size)
|
|
246
|
+
writer.flush()
|
|
247
|
+
os.fsync(writer.fileno())
|
|
248
|
+
temporary.replace(output)
|
|
249
|
+
_sync_directory(output.parent)
|
|
250
|
+
finally:
|
|
251
|
+
if temporary is not None:
|
|
252
|
+
temporary.unlink(missing_ok=True)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _sync_directory(path: Path) -> None:
|
|
256
|
+
flags = os.O_RDONLY
|
|
257
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
258
|
+
flags |= os.O_DIRECTORY
|
|
259
|
+
descriptor = os.open(path, flags)
|
|
260
|
+
try:
|
|
261
|
+
os.fsync(descriptor)
|
|
262
|
+
finally:
|
|
263
|
+
os.close(descriptor)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _require_regular(stream: BinaryIO, path: Path) -> None:
|
|
267
|
+
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
|
268
|
+
raise CorpusError(f"artifact is not a regular file: {path}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _copy_bounded(reader: BinaryIO, writer: BinaryIO, max_size: int) -> None:
|
|
272
|
+
copied = 0
|
|
273
|
+
while chunk := reader.read(min(1024 * 1024, max_size - copied + 1)):
|
|
274
|
+
copied += len(chunk)
|
|
275
|
+
if copied > max_size:
|
|
276
|
+
raise CorpusError(f"artifact exceeds {max_size} bytes")
|
|
277
|
+
writer.write(chunk)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _target(value: object) -> CorpusTarget:
|
|
281
|
+
if not isinstance(value, dict):
|
|
282
|
+
raise CorpusError("target must be an object")
|
|
283
|
+
_reject_unknown(value, {"name", "truth", "recovered"}, "target")
|
|
284
|
+
return CorpusTarget(
|
|
285
|
+
_string(value["name"], "target name"),
|
|
286
|
+
_artifact(_mapping(value["truth"], "truth")),
|
|
287
|
+
_artifact(_mapping(value["recovered"], "recovered")),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _artifact(value: Mapping[str, Any]) -> Artifact:
|
|
292
|
+
_reject_unknown(value, {"name", "sha256", "path", "url"}, "artifact")
|
|
293
|
+
return Artifact(
|
|
294
|
+
name=_string(value["name"], "artifact name"),
|
|
295
|
+
sha256=_string(value["sha256"], "artifact SHA-256"),
|
|
296
|
+
path=_string(value["path"], "artifact path") if "path" in value else None,
|
|
297
|
+
url=_string(value["url"], "artifact URL") if "url" in value else None,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _mapping(value: object, label: str) -> Mapping[str, Any]:
|
|
302
|
+
if not isinstance(value, dict):
|
|
303
|
+
raise CorpusError(f"{label} must be an object")
|
|
304
|
+
return value
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _list(value: Mapping[str, Any], key: str) -> list[object]:
|
|
308
|
+
return _as_list(value[key], key)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _as_list(value: object, label: str) -> list[Any]:
|
|
312
|
+
if not isinstance(value, list):
|
|
313
|
+
raise CorpusError(f"{label} must be an array")
|
|
314
|
+
return value
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _string(value: object, label: str) -> str:
|
|
318
|
+
if not isinstance(value, str):
|
|
319
|
+
raise CorpusError(f"{label} must be a string")
|
|
320
|
+
return value
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _safe_string(value: object, label: str) -> str:
|
|
324
|
+
text = _string(value, label)
|
|
325
|
+
_validate_name(text, label)
|
|
326
|
+
return text
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _reject_unknown(value: Mapping[str, Any], allowed: set[str], label: str) -> None:
|
|
330
|
+
unknown = sorted(set(value) - allowed)
|
|
331
|
+
if unknown:
|
|
332
|
+
raise CorpusError(f"{label} has unknown field: {unknown[0]}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
MAX_BENCH_JSON_SIZE = 16 * 1024 * 1024
|
|
9
|
+
MAX_JSON_NUMBER_CHARACTERS = 1000
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
13
|
+
result: dict[str, Any] = {}
|
|
14
|
+
for key, value in pairs:
|
|
15
|
+
if key in result:
|
|
16
|
+
raise ValueError(f"duplicate JSON key: {key}")
|
|
17
|
+
result[key] = value
|
|
18
|
+
return result
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _reject_constant(value: str) -> None:
|
|
22
|
+
raise ValueError(f"non-finite JSON number: {value}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _bounded_number(value: str) -> str:
|
|
26
|
+
if len(value) > MAX_JSON_NUMBER_CHARACTERS:
|
|
27
|
+
raise ValueError(f"JSON number exceeds {MAX_JSON_NUMBER_CHARACTERS} characters")
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _bounded_int(value: str) -> int:
|
|
32
|
+
return int(_bounded_number(value))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _finite_float(value: str) -> float:
|
|
36
|
+
result = float(_bounded_number(value))
|
|
37
|
+
if not math.isfinite(result):
|
|
38
|
+
raise ValueError(f"JSON number exceeds finite range: {value}")
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_json(path: Path, max_size: int = MAX_BENCH_JSON_SIZE) -> Any:
|
|
43
|
+
if max_size <= 0:
|
|
44
|
+
raise ValueError("maximum JSON size must be positive")
|
|
45
|
+
with path.open("rb") as stream:
|
|
46
|
+
status = os.fstat(stream.fileno())
|
|
47
|
+
if not stat.S_ISREG(status.st_mode):
|
|
48
|
+
raise ValueError(f"JSON input is not a regular file: {path}")
|
|
49
|
+
if status.st_size > max_size:
|
|
50
|
+
raise ValueError(f"JSON input exceeds {max_size} bytes: {path}")
|
|
51
|
+
payload = stream.read(max_size + 1)
|
|
52
|
+
if len(payload) > max_size:
|
|
53
|
+
raise ValueError(f"JSON input exceeds {max_size} bytes: {path}")
|
|
54
|
+
return json.loads(
|
|
55
|
+
payload,
|
|
56
|
+
object_pairs_hook=_unique_object,
|
|
57
|
+
parse_constant=_reject_constant,
|
|
58
|
+
parse_float=_finite_float,
|
|
59
|
+
parse_int=_bounded_int,
|
|
60
|
+
)
|