|
5 | 5 |
|
6 | 6 | from __future__ import annotations |
7 | 7 |
|
| 8 | +import hashlib |
8 | 9 | import json |
9 | 10 | import re |
10 | 11 | import zipfile |
@@ -177,7 +178,37 @@ def render_plugin_check_text(payload: Dict[str, Any]) -> str: |
177 | 178 | return "\n".join(lines) |
178 | 179 |
|
179 | 180 |
|
180 | | -def package_external_plugin_path(path: str, output_path: str, *, force: bool = False) -> Path: |
| 181 | + |
| 182 | +def _sha256_file(path: Path) -> str: |
| 183 | + digest = hashlib.sha256() |
| 184 | + with path.open("rb") as handle: |
| 185 | + for chunk in iter(lambda: handle.read(1024 * 1024), b""): |
| 186 | + digest.update(chunk) |
| 187 | + return digest.hexdigest() |
| 188 | + |
| 189 | + |
| 190 | +def _sha256_bytes(data: bytes) -> str: |
| 191 | + return hashlib.sha256(data).hexdigest() |
| 192 | + |
| 193 | + |
| 194 | +def _package_file_metadata(files: list[Path], root: Path) -> list[Dict[str, Any]]: |
| 195 | + return [ |
| 196 | + { |
| 197 | + "path": item.relative_to(root).as_posix(), |
| 198 | + "size": item.stat().st_size, |
| 199 | + "sha256": _sha256_file(item), |
| 200 | + } |
| 201 | + for item in files |
| 202 | + ] |
| 203 | + |
| 204 | + |
| 205 | +def package_external_plugin_path( |
| 206 | + path: str, |
| 207 | + output_path: str, |
| 208 | + *, |
| 209 | + force: bool = False, |
| 210 | + automax_requires: str = ">=0.1.0", |
| 211 | +) -> Path: |
181 | 212 | """Package external plugin sources and metadata into a ZIP archive.""" |
182 | 213 | source = Path(path).expanduser().resolve() |
183 | 214 | output = Path(output_path).expanduser().resolve() |
@@ -206,12 +237,127 @@ def package_external_plugin_path(path: str, output_path: str, *, force: bool = F |
206 | 237 | output.parent.mkdir(parents=True, exist_ok=True) |
207 | 238 | manifest = { |
208 | 239 | "format": "automax-external-plugin-package-v1", |
| 240 | + "automax_requires": automax_requires, |
209 | 241 | "source": str(source), |
210 | 242 | "plugin_count": payload["checked"], |
211 | 243 | "plugins": [plugin["name"] for plugin in payload["plugins"]], |
| 244 | + "files": _package_file_metadata(files, root), |
212 | 245 | } |
213 | 246 | with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: |
214 | 247 | archive.writestr("automax-plugin.json", json.dumps(manifest, indent=2, sort_keys=True) + "\n") |
215 | 248 | for item in files: |
216 | 249 | archive.write(item, item.relative_to(root).as_posix()) |
217 | 250 | return output |
| 251 | + |
| 252 | + |
| 253 | +def verify_external_plugin_package(package_path: str) -> Dict[str, Any]: |
| 254 | + """Verify one packaged external plugin ZIP manifest and file checksums.""" |
| 255 | + package = Path(package_path).expanduser().resolve() |
| 256 | + failures: list[str] = [] |
| 257 | + manifest: Dict[str, Any] = {} |
| 258 | + |
| 259 | + if not package.exists(): |
| 260 | + raise PluginRegistryError(f"plugin package not found: {package}") |
| 261 | + if not package.is_file(): |
| 262 | + raise PluginRegistryError(f"plugin package is not a file: {package}") |
| 263 | + |
| 264 | + try: |
| 265 | + with zipfile.ZipFile(package) as archive: |
| 266 | + names = archive.namelist() |
| 267 | + if "automax-plugin.json" not in names: |
| 268 | + failures.append("automax-plugin.json manifest is missing") |
| 269 | + else: |
| 270 | + try: |
| 271 | + manifest = json.loads(archive.read("automax-plugin.json").decode("utf-8")) |
| 272 | + except (UnicodeDecodeError, json.JSONDecodeError) as exc: |
| 273 | + failures.append(f"automax-plugin.json is invalid: {exc}") |
| 274 | + |
| 275 | + if manifest: |
| 276 | + if manifest.get("format") != "automax-external-plugin-package-v1": |
| 277 | + failures.append("manifest format must be automax-external-plugin-package-v1") |
| 278 | + plugins = manifest.get("plugins") or [] |
| 279 | + if not isinstance(plugins, list) or not all(isinstance(item, str) for item in plugins): |
| 280 | + failures.append("manifest plugins must be a list of plugin names") |
| 281 | + plugins = [] |
| 282 | + for name in plugins: |
| 283 | + if not PLUGIN_NAME_RE.match(name): |
| 284 | + failures.append(f"{name}: manifest plugin name must be canonical") |
| 285 | + if manifest.get("plugin_count") != len(plugins): |
| 286 | + failures.append("manifest plugin_count does not match plugins") |
| 287 | + if not manifest.get("automax_requires"): |
| 288 | + failures.append("manifest automax_requires is required") |
| 289 | + |
| 290 | + file_entries = manifest.get("files") or [] |
| 291 | + if not isinstance(file_entries, list): |
| 292 | + failures.append("manifest files must be a list") |
| 293 | + file_entries = [] |
| 294 | + seen: set[str] = set() |
| 295 | + for entry in file_entries: |
| 296 | + if not isinstance(entry, dict): |
| 297 | + failures.append("manifest file entries must be objects") |
| 298 | + continue |
| 299 | + rel = str(entry.get("path") or "") |
| 300 | + expected_hash = str(entry.get("sha256") or "") |
| 301 | + expected_size = entry.get("size") |
| 302 | + if not rel or rel.startswith("/") or ".." in Path(rel).parts: |
| 303 | + failures.append(f"invalid package file path: {rel or '<empty>'}") |
| 304 | + continue |
| 305 | + if rel in seen: |
| 306 | + failures.append(f"duplicate package file entry: {rel}") |
| 307 | + continue |
| 308 | + seen.add(rel) |
| 309 | + if rel not in names: |
| 310 | + failures.append(f"packaged file missing from archive: {rel}") |
| 311 | + continue |
| 312 | + data = archive.read(rel) |
| 313 | + if expected_hash != _sha256_bytes(data): |
| 314 | + failures.append(f"checksum mismatch: {rel}") |
| 315 | + if not isinstance(expected_size, int) or expected_size != len(data): |
| 316 | + failures.append(f"size mismatch: {rel}") |
| 317 | + |
| 318 | + packaged_sources = sorted( |
| 319 | + name for name in names |
| 320 | + if name.endswith(".py") and not name.startswith("__pycache__/") |
| 321 | + ) |
| 322 | + declared_sources = sorted( |
| 323 | + str(entry.get("path")) |
| 324 | + for entry in file_entries |
| 325 | + if isinstance(entry, dict) and str(entry.get("path") or "").endswith(".py") |
| 326 | + ) |
| 327 | + if packaged_sources != declared_sources: |
| 328 | + failures.append("manifest files do not match packaged Python sources") |
| 329 | + except zipfile.BadZipFile as exc: |
| 330 | + failures.append(f"invalid ZIP package: {exc}") |
| 331 | + |
| 332 | + return { |
| 333 | + "ok": not failures, |
| 334 | + "package": str(package), |
| 335 | + "format": manifest.get("format") if manifest else None, |
| 336 | + "automax_requires": manifest.get("automax_requires") if manifest else None, |
| 337 | + "plugin_count": manifest.get("plugin_count") if manifest else 0, |
| 338 | + "plugins": manifest.get("plugins", []) if manifest else [], |
| 339 | + "files": manifest.get("files", []) if manifest else [], |
| 340 | + "failure_count": len(failures), |
| 341 | + "failures": failures, |
| 342 | + } |
| 343 | + |
| 344 | + |
| 345 | +def render_plugin_package_verify_text(payload: Dict[str, Any]) -> str: |
| 346 | + """Render package verification results as text.""" |
| 347 | + lines = ["External plugin package verification:"] |
| 348 | + lines.append(f" package: {payload['package']}") |
| 349 | + lines.append(f" plugins: {payload['plugin_count']}") |
| 350 | + lines.append(f" files: {len(payload['files'])}") |
| 351 | + lines.append(f" failures: {payload['failure_count']}") |
| 352 | + if payload.get("automax_requires"): |
| 353 | + lines.append(f" requires: {payload['automax_requires']}") |
| 354 | + if payload["plugins"]: |
| 355 | + lines.append("Plugins:") |
| 356 | + for name in payload["plugins"]: |
| 357 | + lines.append(f" - {name}") |
| 358 | + if payload["failures"]: |
| 359 | + lines.append("Failures:") |
| 360 | + for failure in payload["failures"]: |
| 361 | + lines.append(f" - {failure}") |
| 362 | + lines.append(f"Result: {'OK' if payload['ok'] else 'FAILED'}") |
| 363 | + return "\n".join(lines) |
0 commit comments