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.
Files changed (53) hide show
  1. protoloom/__init__.py +22 -0
  2. protoloom/bench/__init__.py +27 -0
  3. protoloom/bench/corpus.py +332 -0
  4. protoloom/bench/jsonio.py +60 -0
  5. protoloom/bench/metrics.py +299 -0
  6. protoloom/bench/runner.py +256 -0
  7. protoloom/bench/upstream.py +385 -0
  8. protoloom/cli.py +956 -0
  9. protoloom/container/__init__.py +3 -0
  10. protoloom/container/apk.py +204 -0
  11. protoloom/container/detect.py +134 -0
  12. protoloom/container/dex.py +698 -0
  13. protoloom/container/elf.py +189 -0
  14. protoloom/container/macho.py +172 -0
  15. protoloom/container/read.py +33 -0
  16. protoloom/decode/__init__.py +4 -0
  17. protoloom/decode/descpb.py +79 -0
  18. protoloom/decode/fieldtype.py +69 -0
  19. protoloom/decode/infostring.py +183 -0
  20. protoloom/decode/lite.py +447 -0
  21. protoloom/decode/names.py +78 -0
  22. protoloom/decode/wire.py +308 -0
  23. protoloom/doctor.py +101 -0
  24. protoloom/emit/__init__.py +3 -0
  25. protoloom/emit/dashboard.py +191 -0
  26. protoloom/emit/descset.py +74 -0
  27. protoloom/emit/jsonout.py +98 -0
  28. protoloom/emit/proto.py +363 -0
  29. protoloom/emit/report.py +80 -0
  30. protoloom/extract/__init__.py +18 -0
  31. protoloom/extract/descriptor.py +122 -0
  32. protoloom/extract/gotags.py +242 -0
  33. protoloom/extract/gozip.py +39 -0
  34. protoloom/extract/jadx.py +211 -0
  35. protoloom/extract/lite.py +977 -0
  36. protoloom/extract/wire.py +709 -0
  37. protoloom/model.py +82 -0
  38. protoloom/py.typed +0 -0
  39. protoloom/reconcile.py +291 -0
  40. protoloom/tui/__init__.py +0 -0
  41. protoloom/tui/application.py +381 -0
  42. protoloom/tui/jobs.py +155 -0
  43. protoloom/tui/render.py +120 -0
  44. protoloom/tui/results.py +136 -0
  45. protoloom/tui/state.py +51 -0
  46. protoloom/validate/__init__.py +14 -0
  47. protoloom/validate/compile.py +107 -0
  48. protoloom/validate/roundtrip.py +83 -0
  49. protoloom-0.1.4.dist-info/METADATA +337 -0
  50. protoloom-0.1.4.dist-info/RECORD +53 -0
  51. protoloom-0.1.4.dist-info/WHEEL +4 -0
  52. protoloom-0.1.4.dist-info/entry_points.txt +2 -0
  53. 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
+ )