amkernel 0.1.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.
- amkernel/__init__.py +7 -0
- amkernel/__main__.py +5 -0
- amkernel/artifact_store.py +1363 -0
- amkernel/cli.py +172 -0
- amkernel/distribution.py +597 -0
- amkernel/output.py +444 -0
- amkernel/protocol.py +1464 -0
- amkernel/py.typed +0 -0
- amkernel/runner.py +1222 -0
- amkernel/runtime.py +581 -0
- amkernel/server.py +774 -0
- amkernel/skill/SKILL.md +124 -0
- amkernel/skill/references/amkernel-appium.md +46 -0
- amkernel/skill/references/official-skills.json +232 -0
- amkernel/skill/references/official-sources.json +21 -0
- amkernel/skill/scripts/verify_official_skills.py +252 -0
- amkernel/structured_output.py +328 -0
- amkernel/supervisor.py +1453 -0
- amkernel/workspace.py +305 -0
- amkernel-0.1.0.dist-info/METADATA +105 -0
- amkernel-0.1.0.dist-info/RECORD +23 -0
- amkernel-0.1.0.dist-info/WHEEL +4 -0
- amkernel-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail closed unless pinned Appium skill sources and copies match the manifest."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
+
DEFAULT_MANIFEST = SKILL_ROOT / "references" / "official-skills.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _digest(path: Path) -> str:
|
|
20
|
+
value = hashlib.sha256()
|
|
21
|
+
with path.open("rb") as stream:
|
|
22
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
23
|
+
value.update(chunk)
|
|
24
|
+
return value.hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _tree_entries(root: Path) -> tuple[set[str], set[str], set[str]]:
|
|
28
|
+
files: set[str] = set()
|
|
29
|
+
symlinks: set[str] = set()
|
|
30
|
+
invalid: set[str] = set()
|
|
31
|
+
if not root.is_dir():
|
|
32
|
+
return files, symlinks, invalid
|
|
33
|
+
for path in root.rglob("*"):
|
|
34
|
+
relative = path.relative_to(root)
|
|
35
|
+
if ".git" in relative.parts:
|
|
36
|
+
continue
|
|
37
|
+
name = relative.as_posix()
|
|
38
|
+
mode = path.lstat().st_mode
|
|
39
|
+
if stat.S_ISLNK(mode):
|
|
40
|
+
symlinks.add(name)
|
|
41
|
+
elif stat.S_ISREG(mode):
|
|
42
|
+
files.add(name)
|
|
43
|
+
elif not stat.S_ISDIR(mode):
|
|
44
|
+
invalid.add(name)
|
|
45
|
+
return files, symlinks, invalid
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _verify_tree(root: Path, expected: dict[str, str], label: str) -> list[str]:
|
|
49
|
+
errors: list[str] = []
|
|
50
|
+
if not root.is_dir() or root.is_symlink():
|
|
51
|
+
return [f"{label}: missing or linked directory: {root}"]
|
|
52
|
+
|
|
53
|
+
actual, symlinks, invalid = _tree_entries(root)
|
|
54
|
+
for relative in sorted(symlinks):
|
|
55
|
+
errors.append(f"{label}: symbolic links are forbidden: {relative}")
|
|
56
|
+
for relative in sorted(invalid):
|
|
57
|
+
errors.append(f"{label}: non-regular entries are forbidden: {relative}")
|
|
58
|
+
expected_paths = set(expected)
|
|
59
|
+
for relative in sorted(expected_paths - actual):
|
|
60
|
+
errors.append(f"{label}: missing file: {relative}")
|
|
61
|
+
for relative in sorted(actual - expected_paths):
|
|
62
|
+
errors.append(f"{label}: unexpected file: {relative}")
|
|
63
|
+
for relative in sorted(actual & expected_paths):
|
|
64
|
+
actual_digest = _digest(root / relative)
|
|
65
|
+
if actual_digest != expected[relative]:
|
|
66
|
+
errors.append(
|
|
67
|
+
f"{label}: sha256 mismatch: {relative}: "
|
|
68
|
+
f"expected {expected[relative]}, got {actual_digest}"
|
|
69
|
+
)
|
|
70
|
+
return errors
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _mapping(value: Any, label: str) -> dict[str, Any]:
|
|
74
|
+
if not isinstance(value, dict):
|
|
75
|
+
raise ValueError(f"{label} must be an object")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _file_map(value: Any, label: str) -> dict[str, str]:
|
|
80
|
+
mapping = _mapping(value, label)
|
|
81
|
+
result: dict[str, str] = {}
|
|
82
|
+
for path, digest in mapping.items():
|
|
83
|
+
if not isinstance(path, str) or not isinstance(digest, str):
|
|
84
|
+
raise ValueError(f"{label} keys and values must be strings")
|
|
85
|
+
if Path(path).is_absolute() or ".." in Path(path).parts:
|
|
86
|
+
raise ValueError(f"{label} contains unsafe path: {path!r}")
|
|
87
|
+
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
|
|
88
|
+
raise ValueError(f"{label} contains invalid sha256 for {path!r}")
|
|
89
|
+
result[path] = digest
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _location(value: Any, label: str) -> Path:
|
|
94
|
+
if not isinstance(value, str):
|
|
95
|
+
raise ValueError(f"{label} must be a string")
|
|
96
|
+
path = Path(value)
|
|
97
|
+
if path.is_absolute() or ".." in path.parts:
|
|
98
|
+
raise ValueError(f"{label} contains unsafe path: {value!r}")
|
|
99
|
+
return path
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _git(root: Path, *arguments: str) -> subprocess.CompletedProcess[str]:
|
|
103
|
+
environment = dict(os.environ)
|
|
104
|
+
environment["GIT_CONFIG_GLOBAL"] = os.devnull
|
|
105
|
+
environment["GIT_CONFIG_SYSTEM"] = os.devnull
|
|
106
|
+
return subprocess.run(
|
|
107
|
+
["git", "-C", str(root), *arguments],
|
|
108
|
+
check=False,
|
|
109
|
+
capture_output=True,
|
|
110
|
+
text=True,
|
|
111
|
+
env=environment,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _validated_root(
|
|
116
|
+
project_root: Path, location: Path, label: str
|
|
117
|
+
) -> tuple[Path | None, list[str]]:
|
|
118
|
+
current = project_root
|
|
119
|
+
try:
|
|
120
|
+
project_mode = project_root.lstat().st_mode
|
|
121
|
+
except OSError as error:
|
|
122
|
+
return None, [f"{label}: inaccessible project root: {error}"]
|
|
123
|
+
if not stat.S_ISDIR(project_mode):
|
|
124
|
+
return None, [f"{label}: project root is not a real directory: {project_root}"]
|
|
125
|
+
|
|
126
|
+
for component in location.parts:
|
|
127
|
+
current = current / component
|
|
128
|
+
try:
|
|
129
|
+
mode = current.lstat().st_mode
|
|
130
|
+
except OSError as error:
|
|
131
|
+
return None, [f"{label}: inaccessible location component {current}: {error}"]
|
|
132
|
+
if stat.S_ISLNK(mode):
|
|
133
|
+
return None, [f"{label}: linked location component is forbidden: {current}"]
|
|
134
|
+
if not stat.S_ISDIR(mode):
|
|
135
|
+
return None, [f"{label}: location component is not a directory: {current}"]
|
|
136
|
+
|
|
137
|
+
resolved_project = project_root.resolve(strict=True)
|
|
138
|
+
resolved_current = current.resolve(strict=True)
|
|
139
|
+
try:
|
|
140
|
+
resolved_current.relative_to(resolved_project)
|
|
141
|
+
except ValueError:
|
|
142
|
+
return None, [f"{label}: location escapes project root: {current}"]
|
|
143
|
+
return current, []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _canonical_repository(value: str) -> str:
|
|
147
|
+
return value.removesuffix(".git").rstrip("/")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _verify_provenance(root: Path, repository: str, commit: str) -> list[str]:
|
|
151
|
+
errors: list[str] = []
|
|
152
|
+
head = _git(root, "rev-parse", "--verify", "HEAD")
|
|
153
|
+
if head.returncode != 0:
|
|
154
|
+
return [f"official source: not a Git checkout: {root}"]
|
|
155
|
+
if head.stdout.strip() != commit:
|
|
156
|
+
errors.append(
|
|
157
|
+
f"official source: HEAD mismatch: expected {commit}, got {head.stdout.strip()}"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
branch = _git(root, "symbolic-ref", "-q", "HEAD")
|
|
161
|
+
if branch.returncode == 0:
|
|
162
|
+
errors.append(f"official source: HEAD must be detached, got {branch.stdout.strip()}")
|
|
163
|
+
|
|
164
|
+
origin = _git(root, "config", "--get", "remote.origin.url")
|
|
165
|
+
if origin.returncode != 0:
|
|
166
|
+
errors.append("official source: missing origin URL")
|
|
167
|
+
elif _canonical_repository(origin.stdout.strip()) != _canonical_repository(repository):
|
|
168
|
+
errors.append(
|
|
169
|
+
f"official source: origin mismatch: expected {repository}, got {origin.stdout.strip()}"
|
|
170
|
+
)
|
|
171
|
+
return errors
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def verify(manifest_path: Path, project_root: Path) -> list[str]:
|
|
175
|
+
try:
|
|
176
|
+
document = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
177
|
+
manifest = _mapping(document, "manifest")
|
|
178
|
+
if manifest.get("schemaVersion") != 1:
|
|
179
|
+
raise ValueError("schemaVersion must be 1")
|
|
180
|
+
|
|
181
|
+
repository = manifest.get("repository")
|
|
182
|
+
commit = manifest.get("commit")
|
|
183
|
+
if not isinstance(repository, str):
|
|
184
|
+
raise ValueError("repository must be a string")
|
|
185
|
+
if (
|
|
186
|
+
not isinstance(commit, str)
|
|
187
|
+
or len(commit) != 40
|
|
188
|
+
or any(char not in "0123456789abcdef" for char in commit)
|
|
189
|
+
):
|
|
190
|
+
raise ValueError("commit must be a full lowercase Git object ID")
|
|
191
|
+
|
|
192
|
+
source = _mapping(manifest.get("source"), "source")
|
|
193
|
+
source_location = _location(source.get("generatedLocation"), "source.generatedLocation")
|
|
194
|
+
source_root, errors = _validated_root(project_root, source_location, "official source")
|
|
195
|
+
source_files = _file_map(source.get("files"), "source.files")
|
|
196
|
+
if source_root is not None:
|
|
197
|
+
errors.extend(_verify_provenance(source_root, repository, commit))
|
|
198
|
+
errors.extend(_verify_tree(source_root, source_files, "official source"))
|
|
199
|
+
|
|
200
|
+
installed = manifest.get("installedSkills")
|
|
201
|
+
if not isinstance(installed, list) or not installed:
|
|
202
|
+
raise ValueError("installedSkills must be a non-empty array")
|
|
203
|
+
for index, item in enumerate(installed):
|
|
204
|
+
skill = _mapping(item, f"installedSkills[{index}]")
|
|
205
|
+
name = skill.get("name")
|
|
206
|
+
if not isinstance(name, str):
|
|
207
|
+
raise ValueError(f"installedSkills[{index}].name must be a string")
|
|
208
|
+
runtime_location = _location(
|
|
209
|
+
skill.get("generatedLocation"),
|
|
210
|
+
f"installedSkills[{index}].generatedLocation",
|
|
211
|
+
)
|
|
212
|
+
agent_location = _location(
|
|
213
|
+
skill.get("selectedAgentLocation"),
|
|
214
|
+
f"installedSkills[{index}].selectedAgentLocation",
|
|
215
|
+
)
|
|
216
|
+
if skill.get("selectedAgentMode") != "copy":
|
|
217
|
+
raise ValueError(f"installedSkills[{index}].selectedAgentMode must be copy")
|
|
218
|
+
files = _file_map(skill.get("files"), f"installedSkills[{index}].files")
|
|
219
|
+
runtime_root, runtime_errors = _validated_root(
|
|
220
|
+
project_root, runtime_location, f"runtime skill {name}"
|
|
221
|
+
)
|
|
222
|
+
errors.extend(runtime_errors)
|
|
223
|
+
if runtime_root is not None:
|
|
224
|
+
errors.extend(_verify_tree(runtime_root, files, f"runtime skill {name}"))
|
|
225
|
+
agent_root, agent_errors = _validated_root(
|
|
226
|
+
project_root, agent_location, f"selected-agent skill {name}"
|
|
227
|
+
)
|
|
228
|
+
errors.extend(agent_errors)
|
|
229
|
+
if agent_root is not None:
|
|
230
|
+
errors.extend(_verify_tree(agent_root, files, f"selected-agent skill {name}"))
|
|
231
|
+
return errors
|
|
232
|
+
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as error:
|
|
233
|
+
return [f"manifest verification failed: {error}"]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main() -> int:
|
|
237
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
238
|
+
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
|
|
239
|
+
parser.add_argument("--project-root", type=Path, default=Path.cwd())
|
|
240
|
+
arguments = parser.parse_args()
|
|
241
|
+
|
|
242
|
+
errors = verify(arguments.manifest.resolve(), arguments.project_root.resolve())
|
|
243
|
+
if errors:
|
|
244
|
+
for error in errors:
|
|
245
|
+
print(error)
|
|
246
|
+
return 1
|
|
247
|
+
print("Pinned Appium source and installed skill copies verified.")
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Strict structured-output coercion and complete raster validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import mmap
|
|
8
|
+
import struct
|
|
9
|
+
import warnings
|
|
10
|
+
import zlib
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import BinaryIO, Final, Literal, cast
|
|
13
|
+
|
|
14
|
+
from PIL import Image
|
|
15
|
+
from PIL.Image import DecompressionBombError
|
|
16
|
+
|
|
17
|
+
from amkernel.protocol import (
|
|
18
|
+
IMAGE_MIME_TYPES,
|
|
19
|
+
MAX_IMAGE_DIMENSION,
|
|
20
|
+
MAX_IMAGE_FRAMES,
|
|
21
|
+
MAX_IMAGE_PIXEL_FRAMES,
|
|
22
|
+
SAFE_MIME_TYPES,
|
|
23
|
+
JsonValue,
|
|
24
|
+
ProtocolViolation,
|
|
25
|
+
snapshot_json,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
ImageMime = Literal["image/png", "image/jpeg", "image/gif", "image/webp"]
|
|
29
|
+
ImageData = bytes | mmap.mmap
|
|
30
|
+
_IMAGE_FORMATS: Final[dict[str, ImageMime]] = {
|
|
31
|
+
"PNG": "image/png",
|
|
32
|
+
"JPEG": "image/jpeg",
|
|
33
|
+
"GIF": "image/gif",
|
|
34
|
+
"WEBP": "image/webp",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StructuredOutputError(ValueError):
|
|
39
|
+
"""A user structured-output value violates the public helper contract."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DisplayError(StructuredOutputError):
|
|
43
|
+
"""A display value is invalid or exceeds a display bound."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ArtifactError(StructuredOutputError):
|
|
47
|
+
"""An explicit artifact value is invalid or exceeds an artifact bound."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class ImageInfo:
|
|
52
|
+
width: int
|
|
53
|
+
height: int
|
|
54
|
+
frames: int
|
|
55
|
+
pixel_frames: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _MmapImageReader:
|
|
59
|
+
"""Expose one mmap with ordinary file seek semantics without copying it whole."""
|
|
60
|
+
|
|
61
|
+
__slots__ = ("_data", "_position")
|
|
62
|
+
|
|
63
|
+
def __init__(self, data: mmap.mmap) -> None:
|
|
64
|
+
self._data = data
|
|
65
|
+
self._position = 0
|
|
66
|
+
|
|
67
|
+
def read(self, size: int = -1) -> bytes:
|
|
68
|
+
if self._position >= len(self._data):
|
|
69
|
+
return b""
|
|
70
|
+
end = len(self._data) if size < 0 else min(len(self._data), self._position + size)
|
|
71
|
+
value = self._data[self._position : end]
|
|
72
|
+
self._position = end
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
|
|
76
|
+
if whence == io.SEEK_SET:
|
|
77
|
+
position = offset
|
|
78
|
+
elif whence == io.SEEK_CUR:
|
|
79
|
+
position = self._position + offset
|
|
80
|
+
elif whence == io.SEEK_END:
|
|
81
|
+
position = len(self._data) + offset
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError("unsupported seek origin")
|
|
84
|
+
if position < 0:
|
|
85
|
+
raise ValueError("negative seek position")
|
|
86
|
+
self._position = position
|
|
87
|
+
return position
|
|
88
|
+
|
|
89
|
+
def tell(self) -> int:
|
|
90
|
+
return self._position
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def strict_json(value: object, maximum_bytes: int) -> JsonValue:
|
|
94
|
+
"""Detach a strict JSON value and translate protocol failures for user code."""
|
|
95
|
+
try:
|
|
96
|
+
return snapshot_json(cast(JsonValue, value), maximum_bytes)
|
|
97
|
+
except ProtocolViolation as error:
|
|
98
|
+
raise StructuredOutputError(str(error)) from error
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def bytes_like(value: object) -> bytes:
|
|
102
|
+
"""Copy one ordinary contiguous bytes-like value."""
|
|
103
|
+
if isinstance(value, bytes):
|
|
104
|
+
return value
|
|
105
|
+
if isinstance(value, (bytearray, memoryview)):
|
|
106
|
+
try:
|
|
107
|
+
return bytes(value)
|
|
108
|
+
except (TypeError, ValueError) as error:
|
|
109
|
+
raise StructuredOutputError("content must be a contiguous bytes-like value") from error
|
|
110
|
+
raise StructuredOutputError("content must be bytes-like")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _reject_json_constant(value: str) -> None:
|
|
114
|
+
raise ValueError(f"invalid JSON constant: {value}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_safe_artifact(data: ImageData, mime_type: str) -> ImageInfo | None:
|
|
118
|
+
"""Validate content before advertising one allowlisted serving MIME."""
|
|
119
|
+
if mime_type not in SAFE_MIME_TYPES:
|
|
120
|
+
return None
|
|
121
|
+
if mime_type in IMAGE_MIME_TYPES:
|
|
122
|
+
return validate_image(data, mime_type)
|
|
123
|
+
raw = data[:] if isinstance(data, mmap.mmap) else data
|
|
124
|
+
try:
|
|
125
|
+
text = raw.decode("utf-8", errors="strict")
|
|
126
|
+
if mime_type == "application/json":
|
|
127
|
+
value = json.loads(text, parse_constant=_reject_json_constant)
|
|
128
|
+
snapshot_json(cast(JsonValue, value), len(raw))
|
|
129
|
+
except (UnicodeError, ValueError, RecursionError, ProtocolViolation) as error:
|
|
130
|
+
raise StructuredOutputError("artifact content does not match its safe MIME type") from error
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def validate_image(data: ImageData, claimed_mime: str) -> ImageInfo:
|
|
135
|
+
"""Prove exact container extent and fully decode every bounded raster frame."""
|
|
136
|
+
if claimed_mime not in IMAGE_MIME_TYPES:
|
|
137
|
+
raise StructuredOutputError("unsupported image MIME type")
|
|
138
|
+
try:
|
|
139
|
+
_validate_container(data, cast(ImageMime, claimed_mime))
|
|
140
|
+
with warnings.catch_warnings():
|
|
141
|
+
warnings.simplefilter("error")
|
|
142
|
+
source = (
|
|
143
|
+
cast(BinaryIO, _MmapImageReader(data))
|
|
144
|
+
if isinstance(data, mmap.mmap)
|
|
145
|
+
else io.BytesIO(data)
|
|
146
|
+
)
|
|
147
|
+
with Image.open(source) as image:
|
|
148
|
+
observed_mime = _IMAGE_FORMATS.get(image.format or "")
|
|
149
|
+
if observed_mime != claimed_mime:
|
|
150
|
+
raise StructuredOutputError("image MIME type does not match the decoded format")
|
|
151
|
+
frames = 0
|
|
152
|
+
pixel_frames = 0
|
|
153
|
+
first_width = 0
|
|
154
|
+
first_height = 0
|
|
155
|
+
while True:
|
|
156
|
+
try:
|
|
157
|
+
image.seek(frames)
|
|
158
|
+
except EOFError:
|
|
159
|
+
break
|
|
160
|
+
width, height = image.size
|
|
161
|
+
if not (
|
|
162
|
+
1 <= width <= MAX_IMAGE_DIMENSION and 1 <= height <= MAX_IMAGE_DIMENSION
|
|
163
|
+
):
|
|
164
|
+
raise StructuredOutputError("image dimensions exceed the supported bound")
|
|
165
|
+
if frames == 0:
|
|
166
|
+
first_width, first_height = width, height
|
|
167
|
+
frames += 1
|
|
168
|
+
if frames > MAX_IMAGE_FRAMES:
|
|
169
|
+
raise StructuredOutputError("image frame count exceeds the supported bound")
|
|
170
|
+
pixel_frames += width * height
|
|
171
|
+
if pixel_frames > MAX_IMAGE_PIXEL_FRAMES:
|
|
172
|
+
raise StructuredOutputError(
|
|
173
|
+
"image cumulative pixels exceed the supported bound"
|
|
174
|
+
)
|
|
175
|
+
image.load()
|
|
176
|
+
if frames == 0:
|
|
177
|
+
raise StructuredOutputError("image contains no decodable frame")
|
|
178
|
+
return ImageInfo(first_width, first_height, frames, pixel_frames)
|
|
179
|
+
except StructuredOutputError:
|
|
180
|
+
raise
|
|
181
|
+
except (OSError, ValueError, SyntaxError, Warning, DecompressionBombError) as error:
|
|
182
|
+
raise StructuredOutputError("image validation failed") from error
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _validate_container(data: ImageData, mime: ImageMime) -> None:
|
|
186
|
+
if mime == "image/png":
|
|
187
|
+
_validate_png(data)
|
|
188
|
+
elif mime == "image/jpeg":
|
|
189
|
+
_validate_jpeg(data)
|
|
190
|
+
elif mime == "image/gif":
|
|
191
|
+
_validate_gif(data)
|
|
192
|
+
else:
|
|
193
|
+
_validate_webp(data)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _validate_png(data: ImageData) -> None:
|
|
197
|
+
if data[:8] != b"\x89PNG\r\n\x1a\n":
|
|
198
|
+
raise StructuredOutputError("invalid PNG container")
|
|
199
|
+
offset = 8
|
|
200
|
+
first = True
|
|
201
|
+
while offset + 12 <= len(data):
|
|
202
|
+
length = int.from_bytes(data[offset : offset + 4], "big")
|
|
203
|
+
kind = data[offset + 4 : offset + 8]
|
|
204
|
+
end = offset + 12 + length
|
|
205
|
+
if end > len(data):
|
|
206
|
+
raise StructuredOutputError("truncated PNG container")
|
|
207
|
+
expected_crc = int.from_bytes(data[offset + 8 + length : end], "big")
|
|
208
|
+
observed_crc = zlib.crc32(data[offset + 4 : offset + 8 + length]) & 0xFFFFFFFF
|
|
209
|
+
if observed_crc != expected_crc:
|
|
210
|
+
raise StructuredOutputError("PNG chunk checksum mismatch")
|
|
211
|
+
if first and kind != b"IHDR":
|
|
212
|
+
raise StructuredOutputError("PNG IHDR is not first")
|
|
213
|
+
first = False
|
|
214
|
+
offset = end
|
|
215
|
+
if kind == b"IEND":
|
|
216
|
+
if length != 0 or offset != len(data):
|
|
217
|
+
raise StructuredOutputError("PNG does not end exactly at IEND")
|
|
218
|
+
return
|
|
219
|
+
raise StructuredOutputError("PNG has no terminal IEND")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _validate_jpeg(data: ImageData) -> None:
|
|
223
|
+
if len(data) < 4 or data[:2] != b"\xff\xd8":
|
|
224
|
+
raise StructuredOutputError("invalid JPEG container")
|
|
225
|
+
offset = 2
|
|
226
|
+
in_scan = False
|
|
227
|
+
while offset < len(data):
|
|
228
|
+
if not in_scan:
|
|
229
|
+
if data[offset] != 0xFF:
|
|
230
|
+
raise StructuredOutputError("invalid JPEG marker stream")
|
|
231
|
+
while offset < len(data) and data[offset] == 0xFF:
|
|
232
|
+
offset += 1
|
|
233
|
+
if offset >= len(data):
|
|
234
|
+
break
|
|
235
|
+
marker = data[offset]
|
|
236
|
+
offset += 1
|
|
237
|
+
else:
|
|
238
|
+
marker_start = data.find(b"\xff", offset)
|
|
239
|
+
if marker_start < 0:
|
|
240
|
+
break
|
|
241
|
+
offset = marker_start + 1
|
|
242
|
+
while offset < len(data) and data[offset] == 0xFF:
|
|
243
|
+
offset += 1
|
|
244
|
+
if offset >= len(data):
|
|
245
|
+
break
|
|
246
|
+
marker = data[offset]
|
|
247
|
+
offset += 1
|
|
248
|
+
if marker == 0x00 or 0xD0 <= marker <= 0xD7:
|
|
249
|
+
continue
|
|
250
|
+
in_scan = False
|
|
251
|
+
if marker == 0xD9:
|
|
252
|
+
if offset != len(data):
|
|
253
|
+
raise StructuredOutputError("JPEG does not end exactly at EOI")
|
|
254
|
+
return
|
|
255
|
+
if marker == 0xD8 or 0xD0 <= marker <= 0xD7 or marker == 0x01:
|
|
256
|
+
continue
|
|
257
|
+
if offset + 2 > len(data):
|
|
258
|
+
break
|
|
259
|
+
length = int.from_bytes(data[offset : offset + 2], "big")
|
|
260
|
+
if length < 2 or offset + length > len(data):
|
|
261
|
+
raise StructuredOutputError("truncated JPEG segment")
|
|
262
|
+
offset += length
|
|
263
|
+
if marker == 0xDA:
|
|
264
|
+
in_scan = True
|
|
265
|
+
raise StructuredOutputError("JPEG has no terminal EOI")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _validate_gif(data: ImageData) -> None:
|
|
269
|
+
if len(data) < 13 or data[:6] not in {b"GIF87a", b"GIF89a"}:
|
|
270
|
+
raise StructuredOutputError("invalid GIF container")
|
|
271
|
+
packed = data[10]
|
|
272
|
+
offset = 13 + (3 * (2 << (packed & 7)) if packed & 0x80 else 0)
|
|
273
|
+
while offset < len(data):
|
|
274
|
+
introducer = data[offset]
|
|
275
|
+
offset += 1
|
|
276
|
+
if introducer == 0x3B:
|
|
277
|
+
if offset != len(data):
|
|
278
|
+
raise StructuredOutputError("GIF does not end exactly at trailer")
|
|
279
|
+
return
|
|
280
|
+
if introducer == 0x21:
|
|
281
|
+
if offset >= len(data):
|
|
282
|
+
break
|
|
283
|
+
offset += 1
|
|
284
|
+
offset = _skip_subblocks(data, offset)
|
|
285
|
+
elif introducer == 0x2C:
|
|
286
|
+
if offset + 9 > len(data):
|
|
287
|
+
break
|
|
288
|
+
local_packed = data[offset + 8]
|
|
289
|
+
offset += 9
|
|
290
|
+
if local_packed & 0x80:
|
|
291
|
+
offset += 3 * (2 << (local_packed & 7))
|
|
292
|
+
if offset >= len(data):
|
|
293
|
+
break
|
|
294
|
+
offset += 1
|
|
295
|
+
offset = _skip_subblocks(data, offset)
|
|
296
|
+
else:
|
|
297
|
+
raise StructuredOutputError("invalid GIF block")
|
|
298
|
+
raise StructuredOutputError("GIF has no terminal trailer")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _skip_subblocks(data: ImageData, offset: int) -> int:
|
|
302
|
+
while offset < len(data):
|
|
303
|
+
size = data[offset]
|
|
304
|
+
offset += 1
|
|
305
|
+
if size == 0:
|
|
306
|
+
return offset
|
|
307
|
+
offset += size
|
|
308
|
+
if offset > len(data):
|
|
309
|
+
break
|
|
310
|
+
raise StructuredOutputError("truncated GIF sub-block")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _validate_webp(data: ImageData) -> None:
|
|
314
|
+
if len(data) < 12 or data[:4] != b"RIFF" or data[8:12] != b"WEBP":
|
|
315
|
+
raise StructuredOutputError("invalid WebP container")
|
|
316
|
+
declared = struct.unpack_from("<I", data, 4)[0]
|
|
317
|
+
if declared + 8 != len(data):
|
|
318
|
+
raise StructuredOutputError("WebP RIFF length does not end exactly at EOF")
|
|
319
|
+
offset = 12
|
|
320
|
+
while offset < len(data):
|
|
321
|
+
if offset + 8 > len(data):
|
|
322
|
+
raise StructuredOutputError("truncated WebP chunk")
|
|
323
|
+
size = struct.unpack_from("<I", data, offset + 4)[0]
|
|
324
|
+
offset += 8 + size + (size & 1)
|
|
325
|
+
if offset > len(data):
|
|
326
|
+
raise StructuredOutputError("truncated WebP chunk")
|
|
327
|
+
if offset != len(data):
|
|
328
|
+
raise StructuredOutputError("invalid WebP chunk alignment")
|