diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 89b1c76..bf63ec0 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -20,12 +20,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install stable Rust toolchain - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + uses: dtolnay/rust-toolchain@18f0487477426dce244608110aa2660c2893deb7 # stable with: toolchain: stable diff --git a/CHANGELOG.md b/CHANGELOG.md index cff4cba..2af9953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 2026-11-03 — v1.0.2 + +Release train v1.0.2 (catalog schema 1, catalog_version 1.0.2). + +### Dependency updates + +- Updated the workspace lockfile with semver-compatible patches: `serde_json` 1.0.154, `syn` 3.0.9 and `unicode-ident` 1.0.27. +- Updated the security-audit workflow to `actions/checkout` 7.0.1 and the pinned `dtolnay/rust-toolchain` commit. +- No extension code or manifests changed; crate versions remain 0.2.0. The catalog index is unchanged. + +### Addon catalog index + +- Added root `index.json`, a machine-readable catalog of the five installable extensions (`config-roulette`, `layout-override`, `wasm-night-mode`, `wasm-lang-labels`, `wasm-updates-footer`), consumed by `xfetch extension search`. +- Native Rust extensions publish their Cargo version and description; wasm guests publish the metadata from their adjacent `xfetch-extension.json`, with `guest_runtime` set to `core` or `component`. +- Added `scripts/generate_catalog.py`, a standard-library-only deterministic generator with `--check` support for CI and local verification. +- No extension code or manifests changed. Crate versions remain `0.2.0`. + ## 2026-10-24 — Dependency security automation - Added `.github/workflows/security-audit.yml`: runs `cargo audit` 0.22.2 with `--deny warnings` on push, pull request and a weekly schedule, with actions pinned by commit SHA and no persisted checkout credentials. diff --git a/Cargo.lock b/Cargo.lock index a8c6f27..f048b84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,9 +64,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -77,9 +77,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -88,9 +88,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "xfetch-extension-api" diff --git a/index.json b/index.json new file mode 100644 index 0000000..8b4b1b9 --- /dev/null +++ b/index.json @@ -0,0 +1,72 @@ +{ + "schema_version": 1, + "catalog_version": "1.0.2", + "entries": [ + { + "name": "config-roulette", + "description": "Xfetch extension that randomly cycles a config value from a list on each run", + "version": "0.2.0", + "kind": "config_provider", + "runtime": "native", + "path": "extensions/config-roulette", + "install": "config-roulette", + "capabilities": {} + }, + { + "name": "layout-override", + "description": "Xfetch extension that overrides layout settings from the config before rendering", + "version": "0.2.0", + "kind": "config_provider", + "runtime": "native", + "path": "extensions/layout-override", + "install": "layout-override", + "capabilities": {} + }, + { + "name": "wasm-lang-labels", + "description": "Localizes module labels from LANG/LC_ALL (Python WebAssembly component)", + "version": "0.2.0", + "kind": "config_provider", + "runtime": "wasm", + "guest_runtime": "component", + "path": "extensions/wasm-lang-labels", + "install": "wasm-lang-labels", + "capabilities": { + "env": [ + "LANG", + "LC_ALL", + "LC_MESSAGES" + ] + } + }, + { + "name": "wasm-night-mode", + "description": "Dims colors outside the daytime window (WebAssembly config provider)", + "version": "0.2.0", + "kind": "config_provider", + "runtime": "wasm", + "guest_runtime": "core", + "path": "extensions/wasm-night-mode", + "install": "wasm-night-mode", + "capabilities": {} + }, + { + "name": "wasm-updates-footer", + "description": "Appends the pending package-update count to the footer (Go WebAssembly)", + "version": "0.2.0", + "kind": "config_provider", + "runtime": "wasm", + "guest_runtime": "core", + "path": "extensions/wasm-updates-footer", + "install": "wasm-updates-footer", + "capabilities": { + "exec": { + "allow": [ + "checkupdates", + "pacman" + ] + } + } + } + ] +} diff --git a/scripts/generate_catalog.py b/scripts/generate_catalog.py new file mode 100755 index 0000000..1a2ef37 --- /dev/null +++ b/scripts/generate_catalog.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Generate the machine-readable addon catalog for the extensions repository. + +Scans ``extensions/*/`` and writes ``index.json`` at the repository root. Native +Rust extensions take their version and description from ``Cargo.toml``; wasm +guests take theirs from the adjacent ``xfetch-extension.json`` manifest. + +Usage: + python3 scripts/generate_catalog.py + python3 scripts/generate_catalog.py --check + +Only the Python standard library is used. +""" + +from __future__ import annotations + +import argparse +import difflib +import json +import sys +import tomllib +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +EXTENSIONS_DIR = ROOT / "extensions" +OUTPUT = ROOT / "index.json" + +SCHEMA_VERSION = 1 +CATALOG_VERSION = "1.0.2" +WASM_RUNTIMES = {"core", "component"} + + +def fail(message: str) -> None: + raise SystemExit(f"generate_catalog: {message}") + + +def read_toml(path: Path) -> dict[str, Any]: + try: + with path.open("rb") as handle: + return tomllib.load(handle) + except OSError as exc: + fail(f"cannot read {path.relative_to(ROOT)}: {exc}") + except tomllib.TOMLDecodeError as exc: + fail(f"invalid TOML in {path.relative_to(ROOT)}: {exc}") + + +def read_json(path: Path) -> dict[str, Any]: + try: + with path.open(encoding="utf-8") as handle: + data = json.load(handle) + except OSError as exc: + fail(f"cannot read {path.relative_to(ROOT)}: {exc}") + except json.JSONDecodeError as exc: + fail(f"invalid JSON in {path.relative_to(ROOT)}: {exc}") + if not isinstance(data, dict): + fail(f"{path.relative_to(ROOT)} must contain a JSON object") + return data + + +def require_string(data: dict[str, Any], key: str, source: Path) -> str: + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + fail(f"{source.relative_to(ROOT)} is missing a non-empty {key!r}") + return value + + +def native_entry(directory: Path) -> dict[str, Any]: + cargo_path = directory / "Cargo.toml" + package = read_toml(cargo_path).get("package") + if not isinstance(package, dict): + fail(f"{cargo_path.relative_to(ROOT)} has no [package] table") + + name = directory.name + return { + "name": name, + "description": require_string(package, "description", cargo_path), + "version": require_string(package, "version", cargo_path), + "kind": "config_provider", + "runtime": "native", + "path": f"extensions/{name}", + "install": name, + "capabilities": {}, + } + + +def wasm_entry(directory: Path, manifest_path: Path) -> dict[str, Any]: + manifest = read_json(manifest_path) + name = directory.name + manifest_name = require_string(manifest, "name", manifest_path) + if manifest_name != name: + fail( + f"{manifest_path.relative_to(ROOT)} declares name {manifest_name!r}, " + f"but its directory is {name!r}" + ) + + guest_runtime = require_string(manifest, "runtime", manifest_path) + if guest_runtime not in WASM_RUNTIMES: + fail( + f"{manifest_path.relative_to(ROOT)} has runtime {guest_runtime!r}; " + f"expected one of {sorted(WASM_RUNTIMES)}" + ) + + version = require_string(manifest, "version", manifest_path) + cargo_path = directory / "Cargo.toml" + if cargo_path.is_file(): + package = read_toml(cargo_path).get("package") + if not isinstance(package, dict): + fail(f"{cargo_path.relative_to(ROOT)} has no [package] table") + cargo_version = require_string(package, "version", cargo_path) + if cargo_version != version: + fail( + f"{cargo_path.relative_to(ROOT)} version {cargo_version!r} does not " + f"match {manifest_path.relative_to(ROOT)} version {version!r}" + ) + + capabilities = manifest.get("capabilities", {}) + if not isinstance(capabilities, dict): + fail(f"{manifest_path.relative_to(ROOT)} capabilities must be an object") + + kind = manifest.get("kind", "config_provider") + if not isinstance(kind, str) or not kind: + fail(f"{manifest_path.relative_to(ROOT)} has an invalid kind") + + return { + "name": name, + "description": require_string(manifest, "description", manifest_path), + "version": version, + "kind": kind, + "runtime": "wasm", + "guest_runtime": guest_runtime, + "path": f"extensions/{name}", + "install": name, + "capabilities": capabilities, + } + + +def build_catalog() -> dict[str, Any]: + if not EXTENSIONS_DIR.is_dir(): + fail(f"extensions directory not found at {EXTENSIONS_DIR}") + + entries: list[dict[str, Any]] = [] + for directory in sorted(EXTENSIONS_DIR.iterdir(), key=lambda path: path.name): + if not directory.is_dir() or directory.name.startswith("."): + continue + + manifest_path = directory / "xfetch-extension.json" + cargo_path = directory / "Cargo.toml" + if manifest_path.is_file(): + entries.append(wasm_entry(directory, manifest_path)) + elif cargo_path.is_file(): + entries.append(native_entry(directory)) + else: + fail( + f"extensions/{directory.name} has neither " + "xfetch-extension.json nor Cargo.toml" + ) + + entries.sort(key=lambda entry: entry["name"]) + names = [entry["name"] for entry in entries] + if len(names) != len(set(names)): + fail(f"duplicate extension names in catalog: {names}") + + return { + "schema_version": SCHEMA_VERSION, + "catalog_version": CATALOG_VERSION, + "entries": entries, + } + + +def render(catalog: dict[str, Any]) -> str: + return json.dumps(catalog, indent=2, ensure_ascii=False) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Generate or verify the extensions repository catalog." + ) + parser.add_argument( + "--check", + action="store_true", + help="fail if index.json is missing or differs from the generated catalog", + ) + args = parser.parse_args() + + expected = render(build_catalog()) + + if args.check: + if not OUTPUT.is_file(): + print(f"generate_catalog: {OUTPUT.name} is missing", file=sys.stderr) + return 1 + actual = OUTPUT.read_text(encoding="utf-8") + if actual != expected: + diff = difflib.unified_diff( + actual.splitlines(keepends=True), + expected.splitlines(keepends=True), + fromfile="index.json (current)", + tofile="index.json (generated)", + ) + sys.stderr.writelines(diff) + print( + "generate_catalog: index.json is stale; " + "run python3 scripts/generate_catalog.py", + file=sys.stderr, + ) + return 1 + print("index.json is up to date") + return 0 + + OUTPUT.write_text(expected, encoding="utf-8") + print(f"wrote {OUTPUT.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())