immich-api 0.2.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.
- immich_api/__init__.py +54 -0
- immich_api/__main__.py +4 -0
- immich_api/album_metadata.py +119 -0
- immich_api/api/__init__.py +3 -0
- immich_api/api/api_client_system.py +7 -0
- immich_api/auth.py +94 -0
- immich_api/connector_manifest.yml +212 -0
- immich_api/connectors/__init__.py +0 -0
- immich_api/connectors/mcp_source_presets.json +3 -0
- immich_api/connectors/tool_schema_fingerprints.json +1 -0
- immich_api/error_authority.py +248 -0
- immich_api/error_rendering.py +105 -0
- immich_api/immich_input_models.py +12 -0
- immich_api/immich_response_models.py +15 -0
- immich_api/kg_ingest.py +75 -0
- immich_api/mcp/__init__.py +21 -0
- immich_api/mcp/album_sample.py +38 -0
- immich_api/mcp_config.json +3 -0
- immich_api/mcp_server.py +50 -0
- immich_api/ontology/__init__.py +6 -0
- immich_api/ontology/immich.ttl +119 -0
- immich_api/ontology/schema-provenance.json +483 -0
- immich_api/ontology/shapes/immich.ttl +69 -0
- immich_api/openapi_auth_scheme.py +77 -0
- immich_api/openapi_contract.json +80122 -0
- immich_api/openapi_execution.py +30 -0
- immich_api/openapi_inventory.json +16154 -0
- immich_api/openapi_request.py +119 -0
- immich_api/openapi_response.py +76 -0
- immich_api/openapi_runtime.py +203 -0
- immich_api/parity/__init__.py +17 -0
- immich_api/parity/check_openapi_parity.py +173 -0
- immich_api/parity/openapi_discovery.py +95 -0
- immich_api/parity/openapi_parity.py +239 -0
- immich_api/parity/openapi_provider_evidence.py +143 -0
- immich_api/parity/openapi_provider_overlay.py +239 -0
- immich_api/parity/openapi_provider_responses.py +132 -0
- immich_api/parity/openapi_schema.py +206 -0
- immich_api/parity/openapi_schema_budget.py +104 -0
- immich_api/parity/openapi_schema_inputs.py +89 -0
- immich_api/parity/openapi_schema_types.py +163 -0
- immich_api/parity/openapi_security.py +157 -0
- immich_api/parity/openapi_source_parity.py +98 -0
- immich_api/parity/openapi_transport_parity.py +169 -0
- immich_api/parity/unicode_patterns.py +105 -0
- immich_api/parity/unicode_properties.json +4719 -0
- immich_api/prompts/__init__.py +0 -0
- immich_api/prompts/immich_album_review.json +18 -0
- immich_api/prompts/immich_asset_metadata.json +18 -0
- immich_api/prompts/immich_person_record.json +18 -0
- immich_api/prompts/immich_specialist.json +17 -0
- immich_api/prompts/main_agent.json +24 -0
- immich_api/skills/__init__.py +0 -0
- immich_api/skills/immich-album-review/SKILL.md +17 -0
- immich_api/skills/immich-asset-metadata/SKILL.md +17 -0
- immich_api/skills/immich-health/SKILL.md +17 -0
- immich_api/skills/immich-person-record/SKILL.md +17 -0
- immich_api-0.2.0.dist-info/METADATA +242 -0
- immich_api-0.2.0.dist-info/RECORD +63 -0
- immich_api-0.2.0.dist-info/WHEEL +5 -0
- immich_api-0.2.0.dist-info/entry_points.txt +14 -0
- immich_api-0.2.0.dist-info/licenses/LICENSE +21 -0
- immich_api-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Complete source inventories and API/MCP contract parity, without provider calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
if __package__:
|
|
10
|
+
from .openapi_provider_overlay import apply_provider_overlay
|
|
11
|
+
from .openapi_schema import compile_contract, domain_schemas
|
|
12
|
+
from .openapi_security import validate_requirements, validate_security
|
|
13
|
+
from .openapi_source_parity import source_route, verify_source_routes
|
|
14
|
+
else:
|
|
15
|
+
from openapi_provider_overlay import apply_provider_overlay
|
|
16
|
+
from openapi_schema import compile_contract, domain_schemas
|
|
17
|
+
from openapi_security import validate_requirements, validate_security
|
|
18
|
+
from openapi_source_parity import source_route, verify_source_routes
|
|
19
|
+
|
|
20
|
+
HTTP_METHODS = frozenset(
|
|
21
|
+
{"get", "put", "post", "delete", "options", "head", "patch", "trace"}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def digest(value):
|
|
26
|
+
return hashlib.sha256(
|
|
27
|
+
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
28
|
+
).hexdigest()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _operation_entry(document, path, *, method, operation):
|
|
32
|
+
return {
|
|
33
|
+
**source_route(document, path, operation),
|
|
34
|
+
"method": method.upper(),
|
|
35
|
+
"source_path": path,
|
|
36
|
+
"security": operation.get("security", document.get("security", [])),
|
|
37
|
+
"security_declaration": (
|
|
38
|
+
"operation"
|
|
39
|
+
if "security" in operation
|
|
40
|
+
else "root"
|
|
41
|
+
if "security" in document
|
|
42
|
+
else "unknown"
|
|
43
|
+
),
|
|
44
|
+
"extensions": {
|
|
45
|
+
key: value for key, value in operation.items() if key.startswith("x-")
|
|
46
|
+
},
|
|
47
|
+
"deprecated": operation.get("deprecated", False),
|
|
48
|
+
"tags": operation.get("tags", []),
|
|
49
|
+
"operation_sha256": digest(operation),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def operation_inventory(document, source_sha256):
|
|
54
|
+
"""Enumerate the entire pinned source, independently of compiler filtering."""
|
|
55
|
+
document = copy.deepcopy(document)
|
|
56
|
+
schemes = validate_security(document)
|
|
57
|
+
operations = {}
|
|
58
|
+
for path, item in sorted(document["paths"].items()):
|
|
59
|
+
for method, operation in sorted(item.items()):
|
|
60
|
+
if method not in HTTP_METHODS:
|
|
61
|
+
continue
|
|
62
|
+
action = operation["operationId"]
|
|
63
|
+
if action in operations:
|
|
64
|
+
raise ValueError(f"Duplicate source operation: {action}")
|
|
65
|
+
operations[action] = _operation_entry(
|
|
66
|
+
document, path, method=method, operation=operation
|
|
67
|
+
)
|
|
68
|
+
operations[action]["path_parameters_sha256"] = digest(
|
|
69
|
+
item.get("parameters", [])
|
|
70
|
+
)
|
|
71
|
+
if not operations:
|
|
72
|
+
raise ValueError("Source declares no HTTP operations")
|
|
73
|
+
return {
|
|
74
|
+
"version": 1,
|
|
75
|
+
"source_sha256": source_sha256,
|
|
76
|
+
"source_info": document.get("info", {}),
|
|
77
|
+
"openapi_version": document["openapi"],
|
|
78
|
+
"security_schemes": schemes,
|
|
79
|
+
"path_count": len(document["paths"]),
|
|
80
|
+
"operation_count": len(operations),
|
|
81
|
+
"operations": dict(sorted(operations.items())),
|
|
82
|
+
"scope": "All operations in this pinned OpenAPI source; upstream suite completeness requires separate evidence.",
|
|
83
|
+
"live_verified": False,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def require_declared_authorization(inventory):
|
|
88
|
+
"""Schema omission is not proof that an operation is public."""
|
|
89
|
+
for operation in inventory["operations"].values():
|
|
90
|
+
validate_requirements(
|
|
91
|
+
operation["security"],
|
|
92
|
+
inventory["security_schemes"],
|
|
93
|
+
openapi_version=inventory["openapi_version"],
|
|
94
|
+
)
|
|
95
|
+
classified = {
|
|
96
|
+
item["operation_id"]
|
|
97
|
+
for item in inventory.get("provider_overlay", {}).get(
|
|
98
|
+
"authorization_classifications", []
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
unknown = [
|
|
102
|
+
action
|
|
103
|
+
for action, operation in inventory["operations"].items()
|
|
104
|
+
if operation["security_declaration"] == "unknown" and action not in classified
|
|
105
|
+
]
|
|
106
|
+
if unknown:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"Authorization classification requires reviewed provider evidence: {unknown}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def require_complete_coverage(contract, api_actions, mcp_actions):
|
|
113
|
+
"""Call with successful probe IDs, not discovery IDs, for transport coverage."""
|
|
114
|
+
expected = set(contract["operations"])
|
|
115
|
+
for label, actions in (("API", list(api_actions)), ("MCP", list(mcp_actions))):
|
|
116
|
+
if len(actions) != len(set(actions)):
|
|
117
|
+
raise ValueError(f"Duplicate {label} coverage IDs")
|
|
118
|
+
missing, extra = (
|
|
119
|
+
sorted(expected - set(actions)),
|
|
120
|
+
sorted(set(actions) - expected),
|
|
121
|
+
)
|
|
122
|
+
if missing or extra:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"Incomplete {label} coverage: missing={missing}, extra={extra}"
|
|
125
|
+
)
|
|
126
|
+
return {"api_operations": len(expected), "mcp_operations": len(expected)}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def source_context(
|
|
130
|
+
document,
|
|
131
|
+
source_sha256,
|
|
132
|
+
*,
|
|
133
|
+
source_bytes=None,
|
|
134
|
+
overlay_bytes=None,
|
|
135
|
+
expected_overlay_sha256=None,
|
|
136
|
+
evidence_root=None,
|
|
137
|
+
):
|
|
138
|
+
inventory = operation_inventory(document, source_sha256)
|
|
139
|
+
if overlay_bytes is None:
|
|
140
|
+
if expected_overlay_sha256 is not None or evidence_root is not None:
|
|
141
|
+
raise ValueError("Provider evidence options require an exact overlay")
|
|
142
|
+
return document, inventory
|
|
143
|
+
if (
|
|
144
|
+
not isinstance(source_bytes, bytes)
|
|
145
|
+
or hashlib.sha256(source_bytes).hexdigest() != source_sha256
|
|
146
|
+
or json.loads(source_bytes) != document
|
|
147
|
+
):
|
|
148
|
+
raise ValueError("Provider correction requires the exact raw source bytes")
|
|
149
|
+
effective, receipt = apply_provider_overlay(
|
|
150
|
+
source_bytes,
|
|
151
|
+
overlay_bytes,
|
|
152
|
+
expected_overlay_sha256=expected_overlay_sha256,
|
|
153
|
+
evidence_root=evidence_root,
|
|
154
|
+
)
|
|
155
|
+
inventory["provider_overlay"] = receipt
|
|
156
|
+
return effective, inventory
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def verify_contract(document, contract, source_sha256, **provider_options):
|
|
160
|
+
"""Reject source omissions and exact method, route, parameter or schema drift."""
|
|
161
|
+
effective, inventory = source_context(document, source_sha256, **provider_options)
|
|
162
|
+
# A compiler must not be able to mutate the independent source inventory.
|
|
163
|
+
expected = compile_contract(
|
|
164
|
+
copy.deepcopy(effective),
|
|
165
|
+
response_corrections=inventory.get("provider_overlay", {}).get(
|
|
166
|
+
"response_corrections", []
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
if set(inventory["operations"]) != set(expected["operations"]):
|
|
170
|
+
raise ValueError("Compiler omitted or invented source operations")
|
|
171
|
+
expected["source_sha256"] = source_sha256
|
|
172
|
+
if "provider_overlay" in inventory:
|
|
173
|
+
expected["provider_overlay"] = inventory["provider_overlay"]
|
|
174
|
+
verify_source_routes(inventory, expected)
|
|
175
|
+
verify_source_routes(inventory, contract)
|
|
176
|
+
expected["schemas"] = {
|
|
177
|
+
domain: domain_schemas(expected, domain) for domain in expected["domains"]
|
|
178
|
+
}
|
|
179
|
+
# JSON round-trip accounts only for tuples in the compiler's return value.
|
|
180
|
+
if contract != json.loads(json.dumps(expected)):
|
|
181
|
+
raise ValueError("API contract differs from complete source compilation")
|
|
182
|
+
mapped = [action for actions in contract["domains"].values() for action in actions]
|
|
183
|
+
require_complete_coverage(contract, mapped, mapped)
|
|
184
|
+
for domain, actions in contract["domains"].items():
|
|
185
|
+
if any(
|
|
186
|
+
contract["operations"][action]["domain"] != domain for action in actions
|
|
187
|
+
):
|
|
188
|
+
raise ValueError("Operation is unreachable through its declared domain")
|
|
189
|
+
return inventory
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def verify_mcp(contract, tools):
|
|
193
|
+
"""Compare actual native tools/list results, including every action branch."""
|
|
194
|
+
try:
|
|
195
|
+
from agent_connector_sdk.manifest.tool_schema import read_field
|
|
196
|
+
except ImportError as exc:
|
|
197
|
+
raise RuntimeError("MCP parity requires the reviewed SDK environment") from exc
|
|
198
|
+
names = [read_field(tool, "name") for tool in tools]
|
|
199
|
+
if len(names) != len(set(names)):
|
|
200
|
+
raise ValueError("Duplicate MCP tool names")
|
|
201
|
+
by_name = dict(zip(names, tools, strict=True))
|
|
202
|
+
covered = []
|
|
203
|
+
for domain, actions in contract["domains"].items():
|
|
204
|
+
name = f"{domain}_operations"
|
|
205
|
+
if name not in by_name:
|
|
206
|
+
raise ValueError(f"Unreachable MCP domain: {domain}")
|
|
207
|
+
_verify_tool_schema(by_name[name], contract["schemas"][domain], name)
|
|
208
|
+
covered.extend(actions)
|
|
209
|
+
require_complete_coverage(contract, contract["operations"], covered)
|
|
210
|
+
return {"domains": len(contract["domains"]), "exposed_operations": len(covered)}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _verify_tool_schema(tool, schemas, name):
|
|
214
|
+
from agent_connector_sdk.manifest.tool_schema import (
|
|
215
|
+
canonical_input_schema,
|
|
216
|
+
canonical_output_schema,
|
|
217
|
+
)
|
|
218
|
+
from fastmcp.utilities.json_schema import dereference_refs
|
|
219
|
+
from jsonschema import Draft202012Validator
|
|
220
|
+
from jsonschema.exceptions import SchemaError
|
|
221
|
+
|
|
222
|
+
inputs, outputs = schemas
|
|
223
|
+
expected = {"inputSchema": inputs, "outputSchema": outputs}
|
|
224
|
+
# Use FastMCP's actual bounded serve-time transformation. It preserves
|
|
225
|
+
# reference siblings and retains references for recursive/large graphs.
|
|
226
|
+
expanded = {
|
|
227
|
+
"inputSchema": dereference_refs(inputs),
|
|
228
|
+
"outputSchema": dereference_refs(outputs),
|
|
229
|
+
}
|
|
230
|
+
for canonical in (canonical_input_schema, canonical_output_schema):
|
|
231
|
+
actual = canonical(tool)
|
|
232
|
+
try:
|
|
233
|
+
Draft202012Validator.check_schema(actual)
|
|
234
|
+
except SchemaError as exc:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"Native MCP schema is unsupported by client validation: {name}"
|
|
237
|
+
) from exc
|
|
238
|
+
if actual != canonical(expected) and actual != canonical(expanded):
|
|
239
|
+
raise ValueError(f"MCP schema differs from API contract: {name}")
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Verify local provider evidence without following paths outside its capture root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
from contextlib import ExitStack
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_EVIDENCE = {"path": str, "sha256": str}
|
|
14
|
+
_EVIDENCE_OPTIONAL = {"git_blob": str, "source_commit": str}
|
|
15
|
+
_MAX_EVIDENCE_BYTES = 16 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _pairs(pairs):
|
|
19
|
+
result = {}
|
|
20
|
+
for key, value in pairs:
|
|
21
|
+
if key in result:
|
|
22
|
+
raise ValueError("Duplicate JSON key")
|
|
23
|
+
result[key] = value
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _hash(value):
|
|
28
|
+
data = (
|
|
29
|
+
value
|
|
30
|
+
if isinstance(value, bytes)
|
|
31
|
+
else json.dumps(
|
|
32
|
+
value,
|
|
33
|
+
sort_keys=True,
|
|
34
|
+
separators=(",", ":"),
|
|
35
|
+
ensure_ascii=True,
|
|
36
|
+
allow_nan=False,
|
|
37
|
+
).encode()
|
|
38
|
+
)
|
|
39
|
+
return hashlib.sha256(data).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fields(value, required, optional=None):
|
|
43
|
+
rules = required | (optional or {})
|
|
44
|
+
if (
|
|
45
|
+
not isinstance(value, dict)
|
|
46
|
+
or not required.keys() <= value.keys()
|
|
47
|
+
or value.keys() - rules.keys()
|
|
48
|
+
):
|
|
49
|
+
raise ValueError("Unsupported or missing overlay fields")
|
|
50
|
+
for name, item in value.items():
|
|
51
|
+
if type(item) is not rules[name] or (
|
|
52
|
+
isinstance(item, str) and not item.strip()
|
|
53
|
+
):
|
|
54
|
+
raise ValueError("Invalid overlay field type or empty string")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read_file(root, relative):
|
|
58
|
+
try:
|
|
59
|
+
return _safe_read(root, relative)
|
|
60
|
+
except OSError as exc:
|
|
61
|
+
raise ValueError("Evidence file is missing, unsafe, or unreadable") from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _safe_read(root, relative):
|
|
65
|
+
if os.name != "posix":
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"Secure evidence verification requires POSIX descriptor operations"
|
|
68
|
+
)
|
|
69
|
+
parts = relative.split("/")
|
|
70
|
+
if "\\" in relative or any(part in {"", ".", ".."} for part in parts):
|
|
71
|
+
raise ValueError("Evidence path must be a contained relative path")
|
|
72
|
+
parts = [*Path(root).absolute().parts[1:], *parts]
|
|
73
|
+
with ExitStack() as stack:
|
|
74
|
+
directory = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
|
|
75
|
+
stack.callback(os.close, directory)
|
|
76
|
+
for part in parts[:-1]:
|
|
77
|
+
directory = os.open(
|
|
78
|
+
part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory
|
|
79
|
+
)
|
|
80
|
+
stack.callback(os.close, directory)
|
|
81
|
+
fd = os.open(
|
|
82
|
+
parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory
|
|
83
|
+
)
|
|
84
|
+
stream = stack.enter_context(os.fdopen(fd, "rb"))
|
|
85
|
+
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
|
86
|
+
raise ValueError("Evidence must be a regular non-symlink file")
|
|
87
|
+
data = stream.read(_MAX_EVIDENCE_BYTES + 1)
|
|
88
|
+
if data[_MAX_EVIDENCE_BYTES:]:
|
|
89
|
+
raise ValueError("Evidence file exceeds the size limit")
|
|
90
|
+
return data
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _evidence(records, root):
|
|
94
|
+
verified = {}
|
|
95
|
+
for record in records:
|
|
96
|
+
_fields(record, _EVIDENCE, _EVIDENCE_OPTIONAL)
|
|
97
|
+
path = record["path"]
|
|
98
|
+
if path in verified:
|
|
99
|
+
raise ValueError("Duplicate evidence path")
|
|
100
|
+
data = _read_file(root, path)
|
|
101
|
+
if _hash(data) != record["sha256"]:
|
|
102
|
+
raise ValueError("Evidence SHA256 mismatch")
|
|
103
|
+
if "source_commit" in record and not re.fullmatch(
|
|
104
|
+
r"[0-9a-f]{40}(?:[0-9a-f]{24})?", record["source_commit"]
|
|
105
|
+
):
|
|
106
|
+
raise ValueError("Evidence source commit must be an immutable Git ID")
|
|
107
|
+
blob = b"blob " + str(len(data)).encode() + b"\0" + data
|
|
108
|
+
blobs = {hashlib.sha1(blob, usedforsecurity=False).hexdigest(), _hash(blob)}
|
|
109
|
+
if "git_blob" in record and record["git_blob"] not in blobs:
|
|
110
|
+
raise ValueError("Evidence Git blob mismatch")
|
|
111
|
+
verified[path] = (record, len(data.splitlines()), blobs)
|
|
112
|
+
return verified
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _reference(reference, evidence):
|
|
116
|
+
_fields(
|
|
117
|
+
reference,
|
|
118
|
+
_EVIDENCE,
|
|
119
|
+
_EVIDENCE_OPTIONAL | {"line_start": int, "line_end": int, "url": str},
|
|
120
|
+
)
|
|
121
|
+
record, lines, blobs = evidence.get(reference["path"], ({}, 0, set()))
|
|
122
|
+
if reference["sha256"] != record.get("sha256"):
|
|
123
|
+
raise ValueError("Missing or stale authorization evidence")
|
|
124
|
+
if ("git_blob" in reference and reference["git_blob"] not in blobs) or (
|
|
125
|
+
"source_commit" in reference
|
|
126
|
+
and reference["source_commit"] != record.get("source_commit")
|
|
127
|
+
):
|
|
128
|
+
raise ValueError("Authorization evidence Git identity mismatch")
|
|
129
|
+
start, end = reference.get("line_start", 1), reference.get("line_end", lines)
|
|
130
|
+
if not 1 <= start <= end <= lines:
|
|
131
|
+
raise ValueError("Authorization evidence line range is invalid")
|
|
132
|
+
return _hash(reference)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _references(references, evidence):
|
|
136
|
+
if not references:
|
|
137
|
+
raise ValueError("Authorization classification requires provider evidence")
|
|
138
|
+
seen = set()
|
|
139
|
+
for reference in references:
|
|
140
|
+
digest = _reference(reference, evidence)
|
|
141
|
+
if digest in seen:
|
|
142
|
+
raise ValueError("Duplicate authorization evidence")
|
|
143
|
+
seen.add(digest)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Apply reviewed additive schema corrections; retain auth evidence as a sidecar.
|
|
2
|
+
|
|
3
|
+
Version 1 supports named component object schemas and explicit public decorators.
|
|
4
|
+
Version 2 additionally permits a reviewed GET empty-200 to header-data 307 correction.
|
|
5
|
+
It cannot repair routes, remove constraints, create credentials, or grant readiness.
|
|
6
|
+
Component/operation hashes use compact sorted JSON; the effective document uses
|
|
7
|
+
sorted, indented UTF-8 JSON with a final newline. Both use ensure_ascii=True.
|
|
8
|
+
Evidence verification requires POSIX descriptor operations and fails closed elsewhere.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
if __package__:
|
|
18
|
+
from .openapi_provider_evidence import (
|
|
19
|
+
_evidence,
|
|
20
|
+
_fields,
|
|
21
|
+
_hash,
|
|
22
|
+
_pairs,
|
|
23
|
+
_references,
|
|
24
|
+
)
|
|
25
|
+
from .openapi_provider_responses import apply_response_corrections
|
|
26
|
+
from .openapi_security import validate_security
|
|
27
|
+
else:
|
|
28
|
+
from openapi_provider_evidence import _evidence, _fields, _hash, _pairs, _references
|
|
29
|
+
from openapi_provider_responses import apply_response_corrections
|
|
30
|
+
from openapi_security import validate_security
|
|
31
|
+
|
|
32
|
+
_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
|
|
33
|
+
_BINDING = dict.fromkeys(
|
|
34
|
+
("operation_id", "method", "path", "source_operation_sha256"), str
|
|
35
|
+
)
|
|
36
|
+
_CORRECTION = dict.fromkeys(
|
|
37
|
+
(
|
|
38
|
+
"op",
|
|
39
|
+
"schema_pointer",
|
|
40
|
+
"property",
|
|
41
|
+
"before_sha256",
|
|
42
|
+
"after_sha256",
|
|
43
|
+
"source_commit",
|
|
44
|
+
"source_file",
|
|
45
|
+
"source_file_sha256",
|
|
46
|
+
),
|
|
47
|
+
str,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _target(document, correction):
|
|
52
|
+
_fields(correction, _CORRECTION)
|
|
53
|
+
pointer = correction["schema_pointer"]
|
|
54
|
+
if correction["op"] != "require-property" or not re.fullmatch(
|
|
55
|
+
r"/components/schemas/(?:[^~/]|~[01])+", pointer
|
|
56
|
+
):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"Only named component require-property corrections are supported"
|
|
59
|
+
)
|
|
60
|
+
name = pointer.removeprefix("/components/schemas/")
|
|
61
|
+
name = name.replace("~1", "/").replace("~0", "~")
|
|
62
|
+
schemas = document.get("components", {}).get("schemas", {})
|
|
63
|
+
if not isinstance(schemas, dict):
|
|
64
|
+
raise ValueError("Schema components must be an object")
|
|
65
|
+
target = schemas.get(name)
|
|
66
|
+
if (
|
|
67
|
+
not isinstance(target, dict)
|
|
68
|
+
or target.get("type", "object") != "object"
|
|
69
|
+
or "$ref" in target
|
|
70
|
+
or not isinstance(target.get("properties"), dict)
|
|
71
|
+
):
|
|
72
|
+
raise ValueError("Correction must target an existing object schema")
|
|
73
|
+
return target
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _require_property(document, correction, evidence):
|
|
77
|
+
target = _target(document, correction)
|
|
78
|
+
prop, required = correction["property"], target.get("required", [])
|
|
79
|
+
if not isinstance(required, list) or any(
|
|
80
|
+
not isinstance(item, str) for item in required
|
|
81
|
+
):
|
|
82
|
+
raise ValueError("Malformed source required array")
|
|
83
|
+
if (
|
|
84
|
+
prop not in target["properties"]
|
|
85
|
+
or prop in required
|
|
86
|
+
or len(required) != len(set(required))
|
|
87
|
+
):
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"Correction must add one existing, previously optional property"
|
|
90
|
+
)
|
|
91
|
+
record = evidence.get(correction["source_file"], ({},))[0]
|
|
92
|
+
if (record.get("sha256"), record.get("source_commit")) != (
|
|
93
|
+
correction["source_file_sha256"],
|
|
94
|
+
correction["source_commit"],
|
|
95
|
+
):
|
|
96
|
+
raise ValueError("Correction is not bound to verified provider evidence")
|
|
97
|
+
if _hash(target) != correction["before_sha256"]:
|
|
98
|
+
raise ValueError("Stale correction schema hash")
|
|
99
|
+
target["required"] = [*required, prop]
|
|
100
|
+
if _hash(target) != correction["after_sha256"]:
|
|
101
|
+
raise ValueError("Correction after schema hash mismatch")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _operations(document):
|
|
105
|
+
result = {}
|
|
106
|
+
for path, item in document.get("paths", {}).items():
|
|
107
|
+
if "$ref" in item:
|
|
108
|
+
raise ValueError("Path references require an explicit adapter")
|
|
109
|
+
for method, operation in item.items():
|
|
110
|
+
if method not in _METHODS:
|
|
111
|
+
continue
|
|
112
|
+
action = operation.get("operationId")
|
|
113
|
+
if not isinstance(action, str) or not action or action in result:
|
|
114
|
+
raise ValueError("Missing or duplicate source operation ID")
|
|
115
|
+
result[action] = {
|
|
116
|
+
"operation_id": action,
|
|
117
|
+
"method": method.upper(),
|
|
118
|
+
"path": path,
|
|
119
|
+
"source_operation_sha256": _hash(operation),
|
|
120
|
+
"unknown": "security" not in operation and "security" not in document,
|
|
121
|
+
}
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _classification(entry, operations, evidence):
|
|
126
|
+
_fields(
|
|
127
|
+
entry,
|
|
128
|
+
_BINDING
|
|
129
|
+
| {
|
|
130
|
+
"reviewed_guard_classification": str,
|
|
131
|
+
"precondition_category": str,
|
|
132
|
+
"preconditions": str,
|
|
133
|
+
"source_evidence": list,
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
source = operations.get(entry["operation_id"], {})
|
|
137
|
+
if not source.get("unknown") or any(entry[key] != source[key] for key in _BINDING):
|
|
138
|
+
raise ValueError(
|
|
139
|
+
"Authorization classification has a stale, extra, or already declared operation"
|
|
140
|
+
)
|
|
141
|
+
if entry[
|
|
142
|
+
"reviewed_guard_classification"
|
|
143
|
+
] != "explicit-public-decorator" or not re.fullmatch(
|
|
144
|
+
r"[a-z][a-z0-9-]{0,63}", entry["precondition_category"]
|
|
145
|
+
):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"Unsupported authorization classification or precondition category"
|
|
148
|
+
)
|
|
149
|
+
_references(entry["source_evidence"], evidence)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _apply(document, overlay, evidence):
|
|
153
|
+
operations, corrections, classified = _operations(document), set(), set()
|
|
154
|
+
for correction in overlay["corrections"]:
|
|
155
|
+
_fields(correction, _CORRECTION)
|
|
156
|
+
key = (correction["schema_pointer"], correction["property"])
|
|
157
|
+
if key in corrections:
|
|
158
|
+
raise ValueError("Duplicate schema correction")
|
|
159
|
+
_require_property(document, correction, evidence)
|
|
160
|
+
corrections.add(key)
|
|
161
|
+
for entry in overlay["authorization_classifications"]:
|
|
162
|
+
_classification(entry, operations, evidence)
|
|
163
|
+
action = entry["operation_id"]
|
|
164
|
+
if action in classified:
|
|
165
|
+
raise ValueError("Duplicate authorization classification")
|
|
166
|
+
classified.add(action)
|
|
167
|
+
apply_response_corrections(
|
|
168
|
+
document,
|
|
169
|
+
overlay.get("response_corrections", []),
|
|
170
|
+
operations=operations,
|
|
171
|
+
evidence=evidence,
|
|
172
|
+
)
|
|
173
|
+
return sorted(
|
|
174
|
+
action
|
|
175
|
+
for action, operation in operations.items()
|
|
176
|
+
if operation["unknown"] and action not in classified
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _overlay_fields(overlay):
|
|
181
|
+
version = overlay.get("version") if isinstance(overlay, dict) else None
|
|
182
|
+
response_fields = {"response_corrections": list} if version == 2 else {}
|
|
183
|
+
_fields(
|
|
184
|
+
overlay,
|
|
185
|
+
{
|
|
186
|
+
"version": int,
|
|
187
|
+
"raw_source_sha256": str,
|
|
188
|
+
"effective_source_sha256": str,
|
|
189
|
+
"evidence_files": list,
|
|
190
|
+
"corrections": list,
|
|
191
|
+
"authorization_classifications": list,
|
|
192
|
+
}
|
|
193
|
+
| response_fields,
|
|
194
|
+
)
|
|
195
|
+
return overlay
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def apply_provider_overlay(
|
|
199
|
+
raw_bytes, overlay_bytes, *, expected_overlay_sha256, evidence_root
|
|
200
|
+
):
|
|
201
|
+
"""Return ``(effective_document, receipt)``; a matching digest is not readiness.
|
|
202
|
+
|
|
203
|
+
The caller supplies the independently approved exact overlay digest. Evidence
|
|
204
|
+
files are read locally beneath evidence_root without following symlinks.
|
|
205
|
+
"""
|
|
206
|
+
if (
|
|
207
|
+
not isinstance(raw_bytes, bytes)
|
|
208
|
+
or not isinstance(overlay_bytes, bytes)
|
|
209
|
+
or _hash(overlay_bytes) != expected_overlay_sha256
|
|
210
|
+
):
|
|
211
|
+
raise ValueError("Expected reviewed overlay SHA256 mismatch or non-byte input")
|
|
212
|
+
document = json.loads(raw_bytes, object_pairs_hook=_pairs)
|
|
213
|
+
overlay = _overlay_fields(json.loads(overlay_bytes, object_pairs_hook=_pairs))
|
|
214
|
+
if (
|
|
215
|
+
overlay["version"] not in {1, 2}
|
|
216
|
+
or _hash(raw_bytes) != overlay["raw_source_sha256"]
|
|
217
|
+
):
|
|
218
|
+
raise ValueError("Unsupported overlay version or raw source SHA256 mismatch")
|
|
219
|
+
validate_security(document)
|
|
220
|
+
effective = copy.deepcopy(document)
|
|
221
|
+
evidence = _evidence(overlay["evidence_files"], evidence_root)
|
|
222
|
+
unknown = _apply(effective, overlay, evidence)
|
|
223
|
+
encoded = (
|
|
224
|
+
json.dumps(
|
|
225
|
+
effective, sort_keys=True, indent=2, ensure_ascii=True, allow_nan=False
|
|
226
|
+
)
|
|
227
|
+
+ "\n"
|
|
228
|
+
).encode()
|
|
229
|
+
if _hash(encoded) != overlay["effective_source_sha256"]:
|
|
230
|
+
raise ValueError("Effective source SHA256 mismatch")
|
|
231
|
+
receipt = {
|
|
232
|
+
**overlay,
|
|
233
|
+
"overlay_sha256": _hash(overlay_bytes),
|
|
234
|
+
"remaining_unknown_operation_ids": unknown,
|
|
235
|
+
"ready": False,
|
|
236
|
+
"transport_parity_verified": False,
|
|
237
|
+
"live_verified": False,
|
|
238
|
+
}
|
|
239
|
+
return effective, receipt
|