cf-bootstrap-source-local 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.
- cf_bootstrap_source_local/__init__.py +20 -0
- cf_bootstrap_source_local/artifacts.py +191 -0
- cf_bootstrap_source_local/build.py +90 -0
- cf_bootstrap_source_local/capabilities.py +91 -0
- cf_bootstrap_source_local/inventory.py +255 -0
- cf_bootstrap_source_local/model.py +91 -0
- cf_bootstrap_source_local/native.py +163 -0
- cf_bootstrap_source_local/profiles.py +129 -0
- cf_bootstrap_source_local/resolver.py +573 -0
- cf_bootstrap_source_local/semantics/package.trig +13 -0
- cf_bootstrap_source_local-0.1.0.dist-info/METADATA +27 -0
- cf_bootstrap_source_local-0.1.0.dist-info/RECORD +14 -0
- cf_bootstrap_source_local-0.1.0.dist-info/WHEEL +4 -0
- cf_bootstrap_source_local-0.1.0.dist-info/entry_points.txt +8 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Deterministic direct-project inventory and index validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import tomllib
|
|
11
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
12
|
+
from packaging.specifiers import SpecifierSet
|
|
13
|
+
from packaging.version import Version
|
|
14
|
+
|
|
15
|
+
from .model import ResolverError, canonical_json, normalize_distribution
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Project:
|
|
20
|
+
name: str
|
|
21
|
+
normalized_name: str
|
|
22
|
+
version: str
|
|
23
|
+
path: Path
|
|
24
|
+
relative: str
|
|
25
|
+
dependencies: tuple[str, ...] = ()
|
|
26
|
+
optional_dependencies: tuple[tuple[str, tuple[str, ...]], ...] = ()
|
|
27
|
+
build_requirements: tuple[str, ...] = ()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class NativeProject:
|
|
32
|
+
crate_path: Path
|
|
33
|
+
relative: str
|
|
34
|
+
distribution: str
|
|
35
|
+
binary: str
|
|
36
|
+
version: str
|
|
37
|
+
platforms: tuple[str, ...]
|
|
38
|
+
wheel_tags: tuple[str, ...] = ()
|
|
39
|
+
executable: str | None = None
|
|
40
|
+
discovery_group: str | None = None
|
|
41
|
+
entry_point: str | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def normalized_name(self) -> str:
|
|
45
|
+
return normalize_distribution(self.distribution)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize(name: str) -> str:
|
|
49
|
+
return normalize_distribution(name)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate_symlinks(root: Path) -> None:
|
|
53
|
+
for current, directories, files in os.walk(root, followlinks=False):
|
|
54
|
+
for name in [*directories, *files]:
|
|
55
|
+
path = Path(current) / name
|
|
56
|
+
if path.is_symlink():
|
|
57
|
+
try:
|
|
58
|
+
path.resolve().relative_to(root.resolve())
|
|
59
|
+
except ValueError as error:
|
|
60
|
+
raise ResolverError("SOURCE_PROJECT_INVALID", "project symlink escapes source tree") from error
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _validate_package_conformance(path: Path, name: str, version: str) -> None:
|
|
64
|
+
try:
|
|
65
|
+
data = tomllib.loads((path / "pyproject.toml").read_text(encoding="utf-8"))
|
|
66
|
+
package = next(item.split("/")[-1] for item in data.get("tool", {}).get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {}).get("packages", []) if isinstance(item, str) and item.startswith("src/"))
|
|
67
|
+
except (OSError, KeyError, TypeError, StopIteration, tomllib.TOMLDecodeError) as error:
|
|
68
|
+
raise ResolverError("SOURCE_CONFORMANCE_INVALID", "project package layout is not conforming") from error
|
|
69
|
+
semantic = path / "src" / package / "semantics" / "package.trig"
|
|
70
|
+
init = path / "src" / package / "__init__.py"
|
|
71
|
+
if not semantic.is_file() or not init.is_file():
|
|
72
|
+
raise ResolverError("SOURCE_CONFORMANCE_INVALID", f"project {path.name} has no package semantic source")
|
|
73
|
+
text = semantic.read_text(encoding="utf-8")
|
|
74
|
+
if text.count("cfpkg:CfPackage") != 1 or str(name) not in text or str(version) not in text or f'rdf:value "{package}"' not in text or 'rdf:value "Python"' not in text:
|
|
75
|
+
raise ResolverError("SOURCE_CONFORMANCE_INVALID", f"project {path.name} package semantic manifest is invalid")
|
|
76
|
+
entries = data.get("project", {}).get("entry-points", {}).get("cogniflow.semantic_sources", {})
|
|
77
|
+
if not isinstance(entries, dict) or entries.get(name) != f"{package}:semantic_files" or "def semantic_files" not in init.read_text(encoding="utf-8"):
|
|
78
|
+
raise ResolverError("SOURCE_CONFORMANCE_INVALID", f"project {path.name} has no semantic source entry point")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def inventory(checkout: Path) -> tuple[tuple[Project, ...], str, str]:
|
|
82
|
+
root = checkout / "stonecastle"
|
|
83
|
+
if not root.is_dir():
|
|
84
|
+
raise ResolverError("SOURCE_INVALID", "checkout has no stonecastle directory")
|
|
85
|
+
projects: list[Project] = []
|
|
86
|
+
for child in sorted(root.iterdir(), key=lambda p: p.name.casefold()):
|
|
87
|
+
if not child.is_dir() or not (child / "pyproject.toml").is_file():
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
real = child.resolve()
|
|
91
|
+
real.relative_to(root.resolve())
|
|
92
|
+
data = tomllib.loads((child / "pyproject.toml").read_text(encoding="utf-8"))
|
|
93
|
+
project = data["project"]
|
|
94
|
+
name, version = project["name"], project["version"]
|
|
95
|
+
except (OSError, KeyError, TypeError, tomllib.TOMLDecodeError) as error:
|
|
96
|
+
raise ResolverError("SOURCE_PROJECT_INVALID", f"invalid project metadata: {child.name}") from error
|
|
97
|
+
if not isinstance(name, str) or not isinstance(version, str) or not name or not version:
|
|
98
|
+
raise ResolverError("SOURCE_PROJECT_INVALID", "project name and version must be strings")
|
|
99
|
+
normalized = normalize(name)
|
|
100
|
+
relative = child.relative_to(checkout).as_posix()
|
|
101
|
+
if any(item.normalized_name == normalized for item in projects):
|
|
102
|
+
raise ResolverError("SOURCE_PROJECT_INVALID", "duplicate normalized distribution name")
|
|
103
|
+
_validate_package_conformance(child, name, version)
|
|
104
|
+
dependencies = project.get("dependencies", [])
|
|
105
|
+
optional = data.get("project", {}).get("optional-dependencies", {})
|
|
106
|
+
build = data.get("build-system", {}).get("requires", [])
|
|
107
|
+
if not isinstance(dependencies, list) or any(not isinstance(item, str) for item in dependencies):
|
|
108
|
+
raise ResolverError("SOURCE_PROJECT_INVALID", f"project {child.name} dependencies are invalid")
|
|
109
|
+
if not isinstance(build, list) or any(not isinstance(item, str) for item in build):
|
|
110
|
+
raise ResolverError("SOURCE_PROJECT_INVALID", f"project {child.name} build requirements are invalid")
|
|
111
|
+
optional_items = tuple(sorted((str(group), tuple(values)) for group, values in optional.items() if isinstance(group, str) and isinstance(values, list) and all(isinstance(item, str) for item in values)))
|
|
112
|
+
projects.append(Project(name, normalized, version, child, relative, tuple(dependencies), optional_items, tuple(build)))
|
|
113
|
+
projects.sort(key=lambda item: (item.normalized_name, item.relative))
|
|
114
|
+
for project in projects:
|
|
115
|
+
_validate_symlinks(project.path)
|
|
116
|
+
index_path = root / "stonecastle.index.json"
|
|
117
|
+
try:
|
|
118
|
+
index = json.loads(index_path.read_text(encoding="utf-8"))
|
|
119
|
+
entries = [item for item in index["files"] if item.get("kind") == "project-metadata"]
|
|
120
|
+
except (OSError, KeyError, TypeError, json.JSONDecodeError) as error:
|
|
121
|
+
raise ResolverError("SOURCE_INDEX_INVALID", "stonecastle index is invalid") from error
|
|
122
|
+
expected = {item.relative + "/pyproject.toml" for item in projects}
|
|
123
|
+
all_index_paths = [item.get("path") for item in entries]
|
|
124
|
+
if any(not isinstance(path, str) or path.replace("\\", "/") != path or Path(path).parts[:1] != ("stonecastle",) or len(Path(path).parts) != 3 or Path(path).parts[-1] != "pyproject.toml" for path in all_index_paths):
|
|
125
|
+
raise ResolverError("SOURCE_INDEX_INVALID", "project metadata path is not a direct pyproject")
|
|
126
|
+
if len(all_index_paths) != len(set(all_index_paths)) or set(all_index_paths) != expected or any(not (checkout / path).is_file() for path in all_index_paths):
|
|
127
|
+
raise ResolverError("SOURCE_INDEX_INVALID", "index and direct project inventory differ")
|
|
128
|
+
source_data = []
|
|
129
|
+
ignored = {".git", ".venv", "__pycache__", ".pytest_cache", "build", "dist"}
|
|
130
|
+
for project in projects:
|
|
131
|
+
files = []
|
|
132
|
+
for path in sorted(project.path.rglob("*")):
|
|
133
|
+
if not path.is_file() or any(part in ignored or part.endswith(".egg-info") for part in path.relative_to(project.path).parts):
|
|
134
|
+
continue
|
|
135
|
+
files.append({"path": path.relative_to(project.path).as_posix(), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()})
|
|
136
|
+
source_data.append({"path": project.relative, "name": project.name, "version": project.version, "files": files})
|
|
137
|
+
for native in inventory_natives(checkout):
|
|
138
|
+
native_files = []
|
|
139
|
+
for path in sorted(native.crate_path.rglob("*")):
|
|
140
|
+
if path.is_file() and not any(part in {"target", ".git"} for part in path.relative_to(native.crate_path).parts):
|
|
141
|
+
native_files.append({"path": path.relative_to(native.crate_path).as_posix(), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()})
|
|
142
|
+
source_data.append({"path": native.relative, "distribution": native.distribution, "binary": native.binary, "version": native.version, "platforms": list(native.platforms), "wheel_tags": list(native.wheel_tags), "executable": native.executable, "discovery_group": native.discovery_group, "entry_point": native.entry_point, "files": native_files})
|
|
143
|
+
source_digest = "sha256:" + hashlib.sha256(canonical_json(source_data)).hexdigest()
|
|
144
|
+
index_digest = "sha256:" + hashlib.sha256(index_path.read_bytes()).hexdigest()
|
|
145
|
+
return tuple(projects), source_digest, index_digest
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def inventory_natives(checkout: Path, *, selected_kind: str = "native-service-artifact") -> tuple[NativeProject, ...]:
|
|
149
|
+
"""Read one indexed native category; target-instance natives are the default."""
|
|
150
|
+
if selected_kind not in {"native-service-artifact", "native-frontend-artifact"}:
|
|
151
|
+
raise ResolverError("NATIVE_PROJECT_INVALID", "native project category is invalid")
|
|
152
|
+
declarations = {
|
|
153
|
+
"stonecastle/cf_service_mcp_server": ("native-service-artifact", "cf_service_mcp_server", "cf-service-mcp-server", "cf-service-mcp-server"),
|
|
154
|
+
"stonecastle/cf_install_tui_ratatui": ("native-frontend-artifact", "cf_install_tui_ratatui", "cogniflow-tui", "cogniflow-tui"),
|
|
155
|
+
}
|
|
156
|
+
try:
|
|
157
|
+
index = json.loads((checkout / "stonecastle/stonecastle.index.json").read_text(encoding="utf-8"))
|
|
158
|
+
indexed = sorted(item["path"] for item in index.get("files", []) if item.get("kind") == "native-project-metadata")
|
|
159
|
+
expected_paths = sorted(f"{path}/native-artifact.toml" for path in declarations if (checkout / path / "native-artifact.toml").is_file())
|
|
160
|
+
if indexed != expected_paths:
|
|
161
|
+
raise ValueError
|
|
162
|
+
projects = []
|
|
163
|
+
for metadata_relative in indexed:
|
|
164
|
+
metadata_path = checkout / metadata_relative
|
|
165
|
+
data = tomllib.loads(metadata_path.read_text(encoding="utf-8"))
|
|
166
|
+
native = data["native"]
|
|
167
|
+
crate_relative = native["crate_path"]
|
|
168
|
+
expected = declarations.get(crate_relative)
|
|
169
|
+
if expected is None:
|
|
170
|
+
raise ValueError
|
|
171
|
+
crate = checkout / crate_relative
|
|
172
|
+
cargo = tomllib.loads((crate / "Cargo.toml").read_text(encoding="utf-8"))
|
|
173
|
+
package = cargo["package"]
|
|
174
|
+
kind, package_name, distribution, binary = expected
|
|
175
|
+
fields = {"kind", "crate_path", "distribution", "binary", "version_source", "platforms"}
|
|
176
|
+
wheel_tags: tuple[str, ...] = ()
|
|
177
|
+
executable = discovery_group = entry_point = None
|
|
178
|
+
if kind == "native-frontend-artifact":
|
|
179
|
+
fields |= {"wheel_tags", "executable", "discovery_group", "entry_point"}
|
|
180
|
+
wheel_tags = tuple(native.get("wheel_tags", ()))
|
|
181
|
+
executable = native.get("executable")
|
|
182
|
+
discovery_group = native.get("discovery_group")
|
|
183
|
+
entry_point = native.get("entry_point")
|
|
184
|
+
if wheel_tags != ("py3-none-manylinux_2_39_x86_64", "py3-none-win_amd64") or (executable, discovery_group, entry_point) != (binary, "cogniflow.frontends.v1", binary):
|
|
185
|
+
raise ValueError
|
|
186
|
+
if set(native) != fields or native["kind"] != kind or native["version_source"] != "cargo.package.version" or metadata_relative != f"{crate_relative}/native-artifact.toml" or not crate.is_dir() or not (crate / "Cargo.lock").is_file() or package["name"] != package_name or package["version"] != str(package["version"]) or native["distribution"] != distribution or native["binary"] != binary or tuple(native["platforms"]) != ("linux-x86_64", "windows-x86_64"):
|
|
187
|
+
raise ValueError
|
|
188
|
+
if selected_kind == native["kind"]:
|
|
189
|
+
projects.append(NativeProject(crate, crate_relative, distribution, binary, str(package["version"]), tuple(native["platforms"]), wheel_tags, executable, discovery_group, entry_point))
|
|
190
|
+
except (OSError, KeyError, TypeError, ValueError, tomllib.TOMLDecodeError) as error:
|
|
191
|
+
raise ResolverError("NATIVE_PROJECT_INVALID", "native project metadata is invalid") from error
|
|
192
|
+
return tuple(projects)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def inventory_stage0_frontends(checkout: Path) -> tuple[NativeProject, ...]:
|
|
196
|
+
"""Read Stage-0 presentation adapters without granting target authority."""
|
|
197
|
+
return inventory_natives(checkout, selected_kind="native-frontend-artifact")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def dependency_closure(projects: tuple[Project, ...], roots: tuple[str, ...], environment: dict[str, str] | None = None) -> tuple[tuple[Project, ...], tuple[str, ...]]:
|
|
201
|
+
"""Resolve first-party runtime dependencies without importing project code."""
|
|
202
|
+
by_name = {project.normalized_name: project for project in projects}
|
|
203
|
+
selected: dict[str, Project] = {}
|
|
204
|
+
external: dict[str, list[tuple[str, SpecifierSet]]] = {}
|
|
205
|
+
visiting: set[str] = set()
|
|
206
|
+
|
|
207
|
+
def visit(name: str) -> None:
|
|
208
|
+
normalized = normalize_distribution(name)
|
|
209
|
+
if normalized in visiting:
|
|
210
|
+
raise ResolverError("DEPENDENCY_INVALID", "first-party dependency cycle")
|
|
211
|
+
project = by_name.get(normalized)
|
|
212
|
+
if project is None:
|
|
213
|
+
if normalized.startswith("cf-"):
|
|
214
|
+
raise ResolverError("DEPENDENCY_INVALID", f"unresolved first-party dependency: {name}")
|
|
215
|
+
external[normalized] = (name, SpecifierSet())
|
|
216
|
+
return
|
|
217
|
+
if normalized in selected:
|
|
218
|
+
return
|
|
219
|
+
visiting.add(normalized)
|
|
220
|
+
selected[normalized] = project
|
|
221
|
+
for raw in project.dependencies:
|
|
222
|
+
try:
|
|
223
|
+
requirement = Requirement(raw)
|
|
224
|
+
except InvalidRequirement as error:
|
|
225
|
+
raise ResolverError("DEPENDENCY_INVALID", f"invalid PEP 508 requirement: {raw}") from error
|
|
226
|
+
if requirement.marker is not None and not requirement.marker.evaluate(environment):
|
|
227
|
+
continue
|
|
228
|
+
normalized_requirement = normalize_distribution(requirement.name)
|
|
229
|
+
if normalized_requirement in by_name:
|
|
230
|
+
visit(requirement.name)
|
|
231
|
+
continue
|
|
232
|
+
if normalized_requirement.startswith("cf-"):
|
|
233
|
+
raise ResolverError("DEPENDENCY_INVALID", f"unresolved first-party dependency: {requirement}")
|
|
234
|
+
canonical = str(requirement)
|
|
235
|
+
current = external.setdefault(normalized_requirement, [])
|
|
236
|
+
combined = SpecifierSet(",".join(str(item[1]) for item in [*current, (canonical, requirement.specifier)] if str(item[1])))
|
|
237
|
+
if current and not any(_specifier_has_candidate(combined) for _ in (None,)):
|
|
238
|
+
raise ResolverError("DEPENDENCY_INVALID", f"contradictory external requirements for {normalized_requirement}")
|
|
239
|
+
current.append((canonical, SpecifierSet(str(requirement.specifier))))
|
|
240
|
+
visiting.remove(normalized)
|
|
241
|
+
|
|
242
|
+
for root in roots:
|
|
243
|
+
if normalize_distribution(root) not in by_name:
|
|
244
|
+
raise ResolverError("DEPENDENCY_INVALID", f"selected provider root is not inventoried: {root}")
|
|
245
|
+
visit(root)
|
|
246
|
+
return tuple(sorted(selected.values(), key=lambda project: (project.normalized_name, project.relative))), tuple(item[0] for _, values in sorted(external.items()) for item in sorted(values))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _specifier_has_candidate(specifier: SpecifierSet) -> bool:
|
|
250
|
+
"""Reject an intersection that has no plausible released version."""
|
|
251
|
+
for major in range(0, 100):
|
|
252
|
+
for minor in range(0, 100):
|
|
253
|
+
if Version(f"{major}.{minor}") in specifier:
|
|
254
|
+
return True
|
|
255
|
+
return not str(specifier)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Strict protocol and request models for the local resolver."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
PROTOCOL = "cf.bootstrap.artifact-resolver.v1"
|
|
11
|
+
REQUEST_SCHEMA = "cf.bootstrap.artifact-resolution.request.v2"
|
|
12
|
+
RESULT_SCHEMA = "cf.bootstrap.local-artifact-resolution.v2"
|
|
13
|
+
ARTIFACT_SET_SCHEMA = "cf.bootstrap.artifact-set.v2"
|
|
14
|
+
PLAN_SCHEMA = "cf.bootstrap.plan.v2"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ResolverError(ValueError):
|
|
18
|
+
def __init__(self, code: str, message: str):
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.code = code
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def canonical_json(value: object) -> bytes:
|
|
24
|
+
return (json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_distribution(name: str) -> str:
|
|
28
|
+
"""Apply the canonical PyPA distribution-name normalization."""
|
|
29
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _path(value: object, label: str) -> Path:
|
|
33
|
+
if not isinstance(value, str) or not value or "\x00" in value or "://" in value:
|
|
34
|
+
raise ResolverError("SCHEMA_INVALID", f"{label} is invalid")
|
|
35
|
+
windows = PureWindowsPath(value)
|
|
36
|
+
posix = PurePosixPath(value)
|
|
37
|
+
if ".." in windows.parts or ".." in posix.parts:
|
|
38
|
+
raise ResolverError("SCHEMA_INVALID", f"{label} must be an absolute non-root path")
|
|
39
|
+
path = windows if windows.drive or "\\" in value else posix
|
|
40
|
+
if not path.is_absolute() or str(path) == path.anchor:
|
|
41
|
+
raise ResolverError("SCHEMA_INVALID", f"{label} must be an absolute non-root path")
|
|
42
|
+
return Path(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _contained(child: Path, parent: Path) -> bool:
|
|
46
|
+
try:
|
|
47
|
+
child.resolve().relative_to(parent.resolve())
|
|
48
|
+
return True
|
|
49
|
+
except ValueError:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_request(payload: object) -> tuple[dict[str, Any], Path]:
|
|
54
|
+
if not isinstance(payload, dict):
|
|
55
|
+
raise ResolverError("SCHEMA_INVALID", "request must be an object")
|
|
56
|
+
fields = {"schema", "bootstrap_plan", "artifact_output_root", "external_wheelhouse_path", "phase"}
|
|
57
|
+
if payload.get("phase") == "post-handoff-verify":
|
|
58
|
+
fields |= {"bound_artifact_set_digest", "bound_selection_digest", "bound_profile_digest", "bound_catalog_digest", "bound_repository_source_digest", "bound_index_digest"}
|
|
59
|
+
unknown = set(payload) - fields
|
|
60
|
+
if unknown:
|
|
61
|
+
raise ResolverError("UNKNOWN_REQUEST_FIELD", "request contains unknown fields")
|
|
62
|
+
if set(payload) != fields or payload.get("schema") != REQUEST_SCHEMA:
|
|
63
|
+
raise ResolverError("SCHEMA_INVALID", "request schema or required fields are invalid")
|
|
64
|
+
if payload.get("phase") not in {"bootstrap", "post-handoff", "post-handoff-verify"}:
|
|
65
|
+
raise ResolverError("SCHEMA_INVALID", "resolution phase is invalid")
|
|
66
|
+
plan = payload["bootstrap_plan"]
|
|
67
|
+
plan_fields = {"schema", "instance_id", "installation_root", "cogniflow_home", "artifact_source", "profile", "stages"}
|
|
68
|
+
if not isinstance(plan, dict) or set(plan) != plan_fields or plan.get("schema") != PLAN_SCHEMA:
|
|
69
|
+
raise ResolverError("SCHEMA_INVALID", "bootstrap plan schema is invalid")
|
|
70
|
+
profile = plan.get("profile")
|
|
71
|
+
if not isinstance(profile, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9+.-]*:[^\s]+", profile):
|
|
72
|
+
raise ResolverError("SCHEMA_INVALID", "plan profile is not an absolute IRI")
|
|
73
|
+
source = plan.get("artifact_source")
|
|
74
|
+
if not isinstance(source, dict) or set(source) != {"mode", "checkout_path"} or source.get("mode") != "local-repository":
|
|
75
|
+
raise ResolverError("SOURCE_INVALID", "plan source must be local-repository")
|
|
76
|
+
checkout = _path(source.get("checkout_path"), "checkout_path")
|
|
77
|
+
output = _path(payload["artifact_output_root"], "artifact_output_root")
|
|
78
|
+
wheelhouse = _path(payload["external_wheelhouse_path"], "external_wheelhouse_path")
|
|
79
|
+
if _contained(output, checkout) or _contained(wheelhouse, checkout):
|
|
80
|
+
raise ResolverError("SCHEMA_INVALID", "artifact paths must be outside checkout")
|
|
81
|
+
for key in ("cogniflow_home", "installation_root"):
|
|
82
|
+
protected = _path(plan[key], key)
|
|
83
|
+
if _contained(output, protected) or _contained(wheelhouse, protected):
|
|
84
|
+
raise ResolverError("SCHEMA_INVALID", "artifact paths are inside a protected path")
|
|
85
|
+
if not checkout.is_dir() or not wheelhouse.is_dir() or wheelhouse.is_symlink():
|
|
86
|
+
raise ResolverError("SOURCE_INVALID", "checkout or external wheelhouse does not exist")
|
|
87
|
+
if payload.get("phase") != "post-handoff-verify":
|
|
88
|
+
for item in wheelhouse.iterdir():
|
|
89
|
+
if item.is_symlink() or not item.is_file() or item.suffix != ".whl":
|
|
90
|
+
raise ResolverError("EXTERNAL_SOURCE_FORBIDDEN", "external wheelhouse must contain regular wheels only")
|
|
91
|
+
return plan, output
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Locked Rust build and deterministic platform wheel assembly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import csv
|
|
7
|
+
import hashlib
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import platform
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import zipfile
|
|
16
|
+
|
|
17
|
+
from .inventory import NativeProject
|
|
18
|
+
from .model import ResolverError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _platform() -> tuple[str, str, str]:
|
|
22
|
+
system = platform.system().lower()
|
|
23
|
+
machine = platform.machine().lower()
|
|
24
|
+
if system == "windows" and machine in {"amd64", "x86_64"}:
|
|
25
|
+
return "windows-x86_64", "win_amd64", ".exe"
|
|
26
|
+
if system == "linux" and machine in {"amd64", "x86_64"}:
|
|
27
|
+
return "linux-x86_64", "manylinux_2_39_x86_64", ""
|
|
28
|
+
raise ResolverError("UNSUPPORTED_PLATFORM", "native gateway supports only Windows and Linux x86_64")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _record(files: dict[str, bytes], record_name: str) -> bytes:
|
|
32
|
+
rows: list[list[str]] = []
|
|
33
|
+
for name in sorted(files):
|
|
34
|
+
digest = base64.urlsafe_b64encode(hashlib.sha256(files[name]).digest()).rstrip(b"=").decode("ascii")
|
|
35
|
+
rows.append([name, "sha256=" + digest, str(len(files[name]))])
|
|
36
|
+
rows.append([record_name, "", ""])
|
|
37
|
+
stream = io.StringIO(newline="")
|
|
38
|
+
csv.writer(stream, lineterminator="\n").writerows(rows)
|
|
39
|
+
return stream.getvalue().encode("utf-8")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def package_native(project: NativeProject, binary: Path, staging: Path, accepted_sha256: str | None = None, authority: dict[str, str] | None = None) -> dict[str, object]:
|
|
43
|
+
"""Assemble the canonical wheel from an authority-bound native executable."""
|
|
44
|
+
normalized_platform, platform_tag, suffix = _platform()
|
|
45
|
+
if normalized_platform not in project.platforms:
|
|
46
|
+
raise ResolverError("UNSUPPORTED_PLATFORM", "native metadata does not declare this platform")
|
|
47
|
+
if not binary.is_file() or binary.is_symlink():
|
|
48
|
+
raise ResolverError("NATIVE_BINARY_MISSING", "expected native executable is missing or unsafe")
|
|
49
|
+
payload = binary.read_bytes()
|
|
50
|
+
payload_sha256 = hashlib.sha256(payload).hexdigest()
|
|
51
|
+
if accepted_sha256 is not None and payload_sha256 != accepted_sha256:
|
|
52
|
+
raise ResolverError("NATIVE_AUTHORITY_MISMATCH", "installed native executable differs from accepted bootstrap authority")
|
|
53
|
+
if (suffix and not payload.startswith(b"MZ")) or (not suffix and not payload.startswith(b"\x7fELF")) or str(project.crate_path.resolve()).encode() in payload:
|
|
54
|
+
raise ResolverError("NATIVE_BINARY_INVALID", "native executable identity is invalid")
|
|
55
|
+
authority = {project.normalized_name: payload_sha256} if authority is None else dict(authority)
|
|
56
|
+
if (
|
|
57
|
+
authority.get(project.normalized_name) != payload_sha256
|
|
58
|
+
or any(
|
|
59
|
+
not isinstance(name, str)
|
|
60
|
+
or not name
|
|
61
|
+
or not isinstance(digest, str)
|
|
62
|
+
or len(digest) != 64
|
|
63
|
+
or any(char not in "0123456789abcdef" for char in digest)
|
|
64
|
+
for name, digest in authority.items()
|
|
65
|
+
)
|
|
66
|
+
):
|
|
67
|
+
raise ResolverError("NATIVE_AUTHORITY_INVALID", "native packaging authority is invalid")
|
|
68
|
+
wheel_name = project.normalized_name.replace("-", "_")
|
|
69
|
+
dist_info = f"{wheel_name}-{project.version}.dist-info"
|
|
70
|
+
executable_name = project.binary
|
|
71
|
+
if project.normalized_name == "cogniflow-tui":
|
|
72
|
+
executable_name += "-native"
|
|
73
|
+
executable = f"{wheel_name}-{project.version}.data/scripts/{executable_name}{suffix}"
|
|
74
|
+
metadata = (f"Metadata-Version: 2.1\nName: {project.distribution}\nVersion: {project.version}\n\n").encode()
|
|
75
|
+
wheel = (f"Wheel-Version: 1.0\nGenerator: stonecastle-native-wheel\nRoot-Is-Purelib: false\nTag: py3-none-{platform_tag}\n\n").encode()
|
|
76
|
+
files = {f"{dist_info}/METADATA": metadata, f"{dist_info}/WHEEL": wheel, executable: payload}
|
|
77
|
+
if project.normalized_name == "cogniflow-tui":
|
|
78
|
+
if (project.executable, project.discovery_group, project.entry_point) != (project.binary, "cogniflow.frontends.v1", project.binary):
|
|
79
|
+
raise ResolverError("NATIVE_METADATA_INVALID", "frontend registration metadata is invalid")
|
|
80
|
+
target = "cogniflow_tui:main"
|
|
81
|
+
files[f"{dist_info}/entry_points.txt"] = (
|
|
82
|
+
f"[console_scripts]\n{project.entry_point} = {target}\n"
|
|
83
|
+
f"[{project.discovery_group}]\n{project.entry_point} = {target}\n"
|
|
84
|
+
).encode("ascii")
|
|
85
|
+
files["cogniflow_tui.py"] = (
|
|
86
|
+
"from pathlib import Path\n"
|
|
87
|
+
"import os\n"
|
|
88
|
+
"import stat\n"
|
|
89
|
+
"import subprocess\n"
|
|
90
|
+
"import sys\n\n"
|
|
91
|
+
"def main():\n"
|
|
92
|
+
" suffix = '.exe' if sys.platform == 'win32' else ''\n"
|
|
93
|
+
" executable = Path(sys.executable).parent / ('cogniflow-tui-native' + suffix)\n"
|
|
94
|
+
" metadata = os.lstat(executable)\n"
|
|
95
|
+
" if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode) or (sys.platform == 'win32' and metadata.st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT):\n"
|
|
96
|
+
" raise RuntimeError('native frontend executable is missing or unsafe')\n"
|
|
97
|
+
" if sys.platform == 'win32':\n"
|
|
98
|
+
" return subprocess.run([str(executable), *sys.argv[1:]], stdin=None, stdout=None, stderr=None, shell=False, check=False).returncode\n"
|
|
99
|
+
" os.execv(str(executable), [str(executable), *sys.argv[1:]])\n"
|
|
100
|
+
).encode("ascii")
|
|
101
|
+
if project.normalized_name in {"cf-service-mcp-server", "cogniflow-tui"}:
|
|
102
|
+
records = [{"normalized_distribution": name, "sha256": digest} for name, digest in sorted(authority.items() if project.normalized_name == "cf-service-mcp-server" else [(project.normalized_name, payload_sha256)])]
|
|
103
|
+
files[f"{dist_info}/NATIVE-AUTHORITY.json"] = json.dumps({"schema": "stonecastle.native-authority.v1", "executables": records}, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
104
|
+
files[f"{dist_info}/RECORD"] = _record(files, f"{dist_info}/RECORD")
|
|
105
|
+
filename = f"{wheel_name}-{project.version}-py3-none-{platform_tag}.whl"
|
|
106
|
+
output = staging / "wheels" / filename
|
|
107
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
epoch = (1980, 1, 1, 0, 0, 0)
|
|
109
|
+
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
|
110
|
+
for name in sorted(files):
|
|
111
|
+
info = zipfile.ZipInfo(name, epoch)
|
|
112
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
113
|
+
info.external_attr = (0o100755 if "/scripts/" in name else 0o100644) << 16
|
|
114
|
+
archive.writestr(info, files[name])
|
|
115
|
+
return {"distribution": project.distribution, "normalized_distribution": project.normalized_name, "version": project.version, "filename": filename, "sha256": hashlib.sha256(output.read_bytes()).hexdigest(), "size_bytes": output.stat().st_size, "source_project": project.relative, "artifact_kind": "native", "wheel_tags": [f"py3-none-{platform_tag}"], "executable": executable, "discovery_group": project.discovery_group, "entry_point": project.entry_point}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _build_native_binary(project: NativeProject, staging: Path) -> Path:
|
|
119
|
+
normalized_platform, _, suffix = _platform()
|
|
120
|
+
if normalized_platform not in project.platforms:
|
|
121
|
+
raise ResolverError("UNSUPPORTED_PLATFORM", "native metadata does not declare this platform")
|
|
122
|
+
target_dir = staging / "cargo-target"
|
|
123
|
+
env = os.environ.copy()
|
|
124
|
+
cargo = env.get("STONECASTLE_CARGO") or shutil.which("cargo")
|
|
125
|
+
if not cargo:
|
|
126
|
+
raise ResolverError("BUILD_TOOL_UNAVAILABLE", "exact Cargo executable is unavailable")
|
|
127
|
+
cargo_candidate = Path(cargo)
|
|
128
|
+
cargo_path = cargo_candidate.resolve()
|
|
129
|
+
if cargo_path.name.lower().startswith("rustup"):
|
|
130
|
+
sibling = cargo_candidate.with_name("cargo.exe" if os.name == "nt" else "cargo")
|
|
131
|
+
cargo_path = sibling.absolute() if sibling.is_file() else cargo_path
|
|
132
|
+
if not cargo_path.is_file():
|
|
133
|
+
raise ResolverError("BUILD_TOOL_UNAVAILABLE", "exact Cargo executable is unavailable")
|
|
134
|
+
cargo_home = Path(env.get("CARGO_HOME", "")) if env.get("CARGO_HOME") else cargo_path.parent.parent
|
|
135
|
+
rustup_home = Path(env.get("RUSTUP_HOME", "")) if env.get("RUSTUP_HOME") else cargo_home.parent / ".rustup"
|
|
136
|
+
env.update({"CARGO_HOME": str(cargo_home), "RUSTUP_HOME": str(rustup_home), "RUSTUP_TOOLCHAIN": "1.96.0", "CARGO_TARGET_DIR": str(target_dir), "CARGO_INCREMENTAL": "0", "SOURCE_DATE_EPOCH": "315532800"})
|
|
137
|
+
env["RUSTFLAGS"] = (env.get("RUSTFLAGS", "") + " --remap-path-prefix=" + str(project.crate_path.resolve()) + "=/stonecastle").strip()
|
|
138
|
+
if suffix:
|
|
139
|
+
env["RUSTFLAGS"] += " -C link-arg=/Brepro"
|
|
140
|
+
env.pop("PYTHONPATH", None)
|
|
141
|
+
try:
|
|
142
|
+
result = subprocess.run([str(cargo_path), "build", "--release", "--locked"], cwd=project.crate_path, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=600, check=False)
|
|
143
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as error:
|
|
144
|
+
raise ResolverError("BUILD_TOOL_UNAVAILABLE", "locked Cargo build is unavailable") from error
|
|
145
|
+
if result.returncode != 0:
|
|
146
|
+
diagnostic = (result.stderr or result.stdout or "no Cargo diagnostic").strip().replace("\n", " ")[:500]
|
|
147
|
+
raise ResolverError("BUILD_FAILED", f"locked native gateway build failed: {diagnostic}")
|
|
148
|
+
return target_dir / "release" / (project.binary + suffix)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_natives(projects: tuple[NativeProject, ...], staging: Path) -> list[dict[str, object]]:
|
|
152
|
+
"""Compile the complete native set before emitting authority-bound wheels."""
|
|
153
|
+
binaries = [(project, _build_native_binary(project, staging)) for project in projects]
|
|
154
|
+
authority = {project.normalized_name: hashlib.sha256(binary.read_bytes()).hexdigest() for project, binary in binaries}
|
|
155
|
+
if len(authority) != len(projects):
|
|
156
|
+
raise ResolverError("NATIVE_AUTHORITY_INVALID", "native project authority is ambiguous")
|
|
157
|
+
packaged = [package_native(project, binary, staging, authority=authority) for project, binary in binaries]
|
|
158
|
+
from .artifacts import validate_native_wheel
|
|
159
|
+
return [validate_native_wheel(staging / "wheels" / str(record["filename"]), project) for (project, _binary), record in zip(binaries, packaged, strict=True)]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_native(project: NativeProject, staging: Path) -> dict[str, object]:
|
|
163
|
+
return build_natives((project,), staging)[0]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Fail-closed loading and validation of the indexed TriG profile catalog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from rdflib import Dataset, Literal, RDF, SKOS, URIRef
|
|
11
|
+
from packaging.version import Version, InvalidVersion
|
|
12
|
+
|
|
13
|
+
from .model import ResolverError, canonical_json
|
|
14
|
+
|
|
15
|
+
PROFILE_NS = "urn:cf:profile:"
|
|
16
|
+
INSTALLATION_PROFILE = URIRef(PROFILE_NS + "InstallationProfile")
|
|
17
|
+
IDENTIFIER = URIRef(PROFILE_NS + "hasProfileIdentifier")
|
|
18
|
+
IDENTIFIER_TYPE = URIRef(PROFILE_NS + "ProfileIdentifier")
|
|
19
|
+
VERSION = URIRef(PROFILE_NS + "hasProfileVersion")
|
|
20
|
+
VERSION_TYPE = URIRef(PROFILE_NS + "ProfileVersion")
|
|
21
|
+
CAPABILITY = URIRef(PROFILE_NS + "hasCapabilityRequest")
|
|
22
|
+
CAPABILITY_TYPE = URIRef(PROFILE_NS + "CapabilityRequest")
|
|
23
|
+
POST_INSTALL_VERIFICATION = URIRef(PROFILE_NS + "hasPostInstallVerificationCapability")
|
|
24
|
+
POST_INSTALL_VERIFICATION_TYPE = URIRef(PROFILE_NS + "PostInstallVerificationCapability")
|
|
25
|
+
SUPPORTED_VERSION = Version("1.0.0")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _attribute_values(dataset: Dataset, subject: URIRef, relation: URIRef, attribute_type: URIRef) -> list[object]:
|
|
29
|
+
nodes = sorted({obj for _, _, obj, _ in dataset.quads((subject, relation, None, None))}, key=str)
|
|
30
|
+
values: list[object] = []
|
|
31
|
+
for node in nodes:
|
|
32
|
+
if isinstance(node, Literal):
|
|
33
|
+
raise ResolverError("PROFILE_INVALID", "profile relations must target typed attribute nodes")
|
|
34
|
+
predicates = {predicate for _, predicate, _, _ in dataset.quads((node, None, None, None))}
|
|
35
|
+
types = {obj for _, _, obj, _ in dataset.quads((node, RDF.type, None, None))}
|
|
36
|
+
node_values = {obj for _, _, obj, _ in dataset.quads((node, RDF.value, None, None))}
|
|
37
|
+
if predicates != {RDF.type, RDF.value} or types != {attribute_type} or len(node_values) != 1:
|
|
38
|
+
raise ResolverError("PROFILE_INVALID", "profile attribute structure is outside the canonical model")
|
|
39
|
+
values.append(next(iter(node_values)))
|
|
40
|
+
return values
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _profile(path: Path, requested_iri: str | None = None) -> dict[str, object]:
|
|
44
|
+
dataset = Dataset()
|
|
45
|
+
try:
|
|
46
|
+
dataset.parse(path, format="trig")
|
|
47
|
+
except Exception as error:
|
|
48
|
+
raise ResolverError("PROFILE_INVALID", "profile document is not valid TriG") from error
|
|
49
|
+
subjects = sorted({subject for subject, _, _, _ in dataset.quads((None, RDF.type, INSTALLATION_PROFILE, None)) if isinstance(subject, URIRef)}, key=str)
|
|
50
|
+
if len(subjects) != 1:
|
|
51
|
+
raise ResolverError("PROFILE_INVALID", "profile document must contain exactly one InstallationProfile subject")
|
|
52
|
+
subject = subjects[0]
|
|
53
|
+
if requested_iri is not None and str(subject) != requested_iri:
|
|
54
|
+
raise ResolverError("PROFILE_INVALID", "profile subject does not match requested profile IRI")
|
|
55
|
+
allowed_predicates = {RDF.type, SKOS.prefLabel, SKOS.definition, IDENTIFIER, VERSION, CAPABILITY, POST_INSTALL_VERIFICATION}
|
|
56
|
+
predicates = {predicate for _, predicate, _, _ in dataset.quads((subject, None, None, None))}
|
|
57
|
+
types = {obj for _, _, obj, _ in dataset.quads((subject, RDF.type, None, None))}
|
|
58
|
+
labels = {obj for _, _, obj, _ in dataset.quads((subject, SKOS.prefLabel, None, None))}
|
|
59
|
+
definitions = {obj for _, _, obj, _ in dataset.quads((subject, SKOS.definition, None, None))}
|
|
60
|
+
if predicates - allowed_predicates or types != {INSTALLATION_PROFILE}:
|
|
61
|
+
raise ResolverError("PROFILE_INVALID", "profile structure is outside the canonical model")
|
|
62
|
+
if len(labels) != 1 or len(definitions) != 1 or any(not isinstance(item, Literal) or not item.value for item in labels | definitions):
|
|
63
|
+
raise ResolverError("PROFILE_INVALID", "profile documentation is outside the canonical model")
|
|
64
|
+
identifiers = _attribute_values(dataset, subject, IDENTIFIER, IDENTIFIER_TYPE)
|
|
65
|
+
versions = _attribute_values(dataset, subject, VERSION, VERSION_TYPE)
|
|
66
|
+
capabilities = sorted(_attribute_values(dataset, subject, CAPABILITY, CAPABILITY_TYPE), key=str)
|
|
67
|
+
verification = _attribute_values(dataset, subject, POST_INSTALL_VERIFICATION, POST_INSTALL_VERIFICATION_TYPE)
|
|
68
|
+
attribute_nodes = {obj for relation in (IDENTIFIER, VERSION, CAPABILITY, POST_INSTALL_VERIFICATION) for _, _, obj, _ in dataset.quads((subject, relation, None, None))}
|
|
69
|
+
document_subjects = {item for item, _, _, _ in dataset.quads((None, None, None, None))}
|
|
70
|
+
if document_subjects != {subject, *attribute_nodes}:
|
|
71
|
+
raise ResolverError("PROFILE_INVALID", "profile document contains subjects outside the canonical model")
|
|
72
|
+
if len(identifiers) != 1 or len(versions) != 1 or not capabilities or any(not isinstance(item, URIRef) for item in capabilities):
|
|
73
|
+
raise ResolverError("PROFILE_INVALID", "profile identity, version, or capabilities are invalid")
|
|
74
|
+
identifier = identifiers[0]
|
|
75
|
+
version = versions[0]
|
|
76
|
+
if not isinstance(identifier, Literal) or not isinstance(version, Literal) or not identifier.value or not version.value:
|
|
77
|
+
raise ResolverError("PROFILE_INVALID", "profile identity and version must be literals")
|
|
78
|
+
try:
|
|
79
|
+
parsed_version = Version(str(version))
|
|
80
|
+
except InvalidVersion as error:
|
|
81
|
+
raise ResolverError("PROFILE_INVALID", "profile version is not a supported semantic version") from error
|
|
82
|
+
if parsed_version != SUPPORTED_VERSION:
|
|
83
|
+
raise ResolverError("PROFILE_INVALID", "profile semantic version is unsupported")
|
|
84
|
+
capability_iris = [str(item) for item in capabilities]
|
|
85
|
+
if capability_iris != sorted(capability_iris) or len(capability_iris) != len(set(capability_iris)):
|
|
86
|
+
raise ResolverError("PROFILE_INVALID", "capabilities are not canonical and unique")
|
|
87
|
+
if any(not re.fullmatch(r"urn:cf:service:[A-Za-z][A-Za-z0-9]*", item) for item in capability_iris):
|
|
88
|
+
raise ResolverError("PROFILE_INVALID", "profile capability contains package or path data")
|
|
89
|
+
canonical_bytes = path.read_bytes().replace(b"\r\n", b"\n")
|
|
90
|
+
if len(verification) > 1 or any(not isinstance(item, URIRef) for item in verification):
|
|
91
|
+
raise ResolverError("PROFILE_INVALID", "post-install verification capability is ambiguous")
|
|
92
|
+
if verification and str(verification[0]) not in capability_iris:
|
|
93
|
+
raise ResolverError("PROFILE_INVALID", "post-install verification capability is not requested")
|
|
94
|
+
return {"profile": str(subject), "identifier": str(identifier), "version": str(version), "capabilities": capability_iris, "post_install_verification_capability": str(verification[0]) if verification else None, "sha256": hashlib.sha256(canonical_bytes).hexdigest()}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_catalog(checkout: Path, profile_iri: str) -> dict[str, object]:
|
|
98
|
+
checkout = checkout.resolve()
|
|
99
|
+
root = (checkout / "stonecastle" / "profiles").resolve()
|
|
100
|
+
if not root.is_dir() or not re.fullmatch(r"urn:cf:profile:[A-Za-z0-9.-]+", profile_iri):
|
|
101
|
+
raise ResolverError("PROFILE_INVALID", "profile catalog root or profile IRI is invalid")
|
|
102
|
+
catalog_path = root / "profiles.index.json"
|
|
103
|
+
try:
|
|
104
|
+
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
|
105
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
106
|
+
raise ResolverError("PROFILE_INVALID", "profile catalog index is invalid") from error
|
|
107
|
+
if set(catalog) != {"schema", "profiles"} or catalog["schema"] != "cf.installation-profile-catalog.v1" or not isinstance(catalog["profiles"], list):
|
|
108
|
+
raise ResolverError("PROFILE_INVALID", "profile catalog schema is invalid")
|
|
109
|
+
actual_paths = sorted(path.relative_to(checkout).as_posix() for path in root.glob("*.trig"))
|
|
110
|
+
indexed_paths = sorted(item.get("path") for item in catalog["profiles"] if isinstance(item, dict))
|
|
111
|
+
if actual_paths != indexed_paths or any(not isinstance(path, str) or not path.startswith("stonecastle/profiles/") or ".." in Path(path).parts for path in indexed_paths):
|
|
112
|
+
raise ResolverError("PROFILE_INVALID", "profile catalog does not exactly cover indexed profiles")
|
|
113
|
+
entries = []
|
|
114
|
+
for item in catalog["profiles"]:
|
|
115
|
+
if set(item) != {"profile", "path", "sha256"}:
|
|
116
|
+
raise ResolverError("PROFILE_INVALID", "profile catalog entry fields are invalid")
|
|
117
|
+
path = checkout / item["path"]
|
|
118
|
+
if not path.is_file() or path.is_symlink():
|
|
119
|
+
raise ResolverError("PROFILE_INVALID", "catalog profile path is missing or redirected")
|
|
120
|
+
parsed = _profile(path, str(item["profile"]))
|
|
121
|
+
if parsed["sha256"] != item["sha256"]:
|
|
122
|
+
raise ResolverError("PROFILE_INVALID", "profile digest mismatch")
|
|
123
|
+
entries.append({**parsed, "path": item["path"]})
|
|
124
|
+
if len({item["profile"] for item in entries}) != len(entries) or len({item["identifier"] for item in entries}) != len(entries):
|
|
125
|
+
raise ResolverError("PROFILE_AMBIGUOUS", "profile catalog contains duplicate identity")
|
|
126
|
+
matches = [item for item in entries if item["profile"] == profile_iri]
|
|
127
|
+
if not matches:
|
|
128
|
+
raise ResolverError("PROFILE_NOT_FOUND", "requested profile is not in the catalog")
|
|
129
|
+
return {"profile": matches[0], "catalog": catalog, "catalog_digest": "sha256:" + hashlib.sha256(canonical_json(catalog)).hexdigest()}
|