From c75559589e41721ab5a3de8e336d8476f4290535 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Aug 2026 20:21:59 -0700 Subject: [PATCH] libghostty: add ABI manifest schema The ABI manifest previously had no machine-readable grammar or test that the public export conformed to it. Define a Draft 2020-12 schema and add a build check that executes ghostty_type_json for native and wasm libraries before validation. Run both forms in CI and publish the schema with the generated API docs. --- .github/workflows/test.yml | 9 + Doxyfile | 3 +- build.zig | 10 + include/ghostty/vt/types.h | 3 + nix/devShell.nix | 2 + src/build/docker/lib-c-docs/Dockerfile | 1 + src/terminal/c/types-schema-verify.py | 114 ++++++++ src/terminal/c/types.schema.json | 348 +++++++++++++++++++++++++ src/terminal/c/types.zig | 3 +- 9 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 src/terminal/c/types-schema-verify.py create mode 100644 src/terminal/c/types.schema.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e45793f22..a5216072c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -701,6 +701,12 @@ jobs: -Dtarget=wasm32-freestanding \ -Doptimize=ReleaseSmall + - name: Validate WASM ABI manifest + run: | + nix develop -c zig build test-lib-vt-schema \ + -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseSmall + - name: Optimize ReleaseSmall WASM run: | nix develop -c wasm-opt -O3 \ @@ -1470,6 +1476,9 @@ jobs: - name: Test run: nix develop -c zig build test-lib-vt + - name: Validate native ABI manifest + run: nix develop -c zig build test-lib-vt-schema + test-kaitai: if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' needs: skip diff --git a/Doxyfile b/Doxyfile index 1703e6fac..77eeb3d3c 100644 --- a/Doxyfile +++ b/Doxyfile @@ -57,7 +57,8 @@ GENERATE_HTML = YES HTML_OUTPUT = zig-out/share/ghostty/doc/libghostty HTML_EXTRA_STYLESHEET = dist/doxygen/ghostty.css HTML_EXTRA_FILES = dist/doxygen/favicon.png \ - dist/doxygen/mobile-nav.js + dist/doxygen/mobile-nav.js \ + src/terminal/c/types.schema.json HTML_COLORSTYLE = DARK HTML_CODE_FOLDING = NO HTML_HEADER = dist/doxygen/header.html diff --git a/build.zig b/build.zig index 1c56cadf8..5ce7e816b 100644 --- a/build.zig +++ b/build.zig @@ -73,6 +73,10 @@ pub fn build(b: *std.Build) !void { "test-lib-vt-build", "Build libghostty-vt tests without running them (compile check)", ); + const test_lib_vt_schema_step = b.step( + "test-lib-vt-schema", + "Validate the libghostty-vt ABI type manifest", + ); const test_valgrind_step = b.step( "test-valgrind", "Run tests under valgrind", @@ -136,6 +140,12 @@ pub fn build(b: *std.Build) !void { }; libghostty_vt_shared.install(b.getInstallStep()); + const type_schema_test = b.addSystemCommand(&.{"python3"}); + type_schema_test.addFileArg(b.path("src/terminal/c/types-schema-verify.py")); + type_schema_test.addFileArg(b.path("src/terminal/c/types.schema.json")); + type_schema_test.addFileArg(libghostty_vt_shared.output); + test_lib_vt_schema_step.dependOn(&type_schema_test.step); + // libghostty-vt static lib const libghostty_vt_static = try buildpkg.GhosttyLibVt.initStatic( b, diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index bc1c6a69b..787cc0148 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -342,6 +342,9 @@ typedef struct { * this manifest rather than hardcoding them. Consumers should reject unknown * schema versions and verify the descriptors they require at initialization. * + * The formal format is defined by the + * libghostty-vt ABI manifest JSON Schema. + * * Example (abbreviated): * @code{.json} * { diff --git a/nix/devShell.nix b/nix/devShell.nix index c94892414..fba128a21 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -91,8 +91,10 @@ inherit pkgs lib stdenv; }; python = python3.withPackages (python-pkgs: [ + python-pkgs.jsonschema python-pkgs.kaitaistruct python-pkgs.ucs-detect + python-pkgs.wasmtime ]); in mkShell { diff --git a/src/build/docker/lib-c-docs/Dockerfile b/src/build/docker/lib-c-docs/Dockerfile index a3cfdcc98..b5d176160 100644 --- a/src/build/docker/lib-c-docs/Dockerfile +++ b/src/build/docker/lib-c-docs/Dockerfile @@ -14,6 +14,7 @@ WORKDIR /ghostty COPY include/ ./include/ COPY images/ ./images/ COPY dist/doxygen/ ./dist/doxygen/ +COPY src/terminal/c/types.schema.json ./src/terminal/c/types.schema.json COPY example/ ./example/ COPY Doxyfile ./ COPY DoxygenLayout.xml ./ diff --git a/src/terminal/c/types-schema-verify.py b/src/terminal/c/types-schema-verify.py new file mode 100644 index 000000000..1a8e064dc --- /dev/null +++ b/src/terminal/c/types-schema-verify.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Validate the ABI manifest exported by a native or WebAssembly library.""" + +from __future__ import annotations + +import argparse +import ctypes +import json +import sys +from pathlib import Path +from typing import Any + +try: + import jsonschema +except ImportError as error: + raise SystemExit( + "missing ABI schema verifier dependencies; run this inside " + "`nix develop`" + ) from error + + +def load_native_manifest(path: Path) -> bytes: + """Call ghostty_type_json in a native shared library.""" + library = ctypes.CDLL(str(path.resolve())) + type_json = library.ghostty_type_json + type_json.argtypes = () + type_json.restype = ctypes.c_char_p + + result = type_json() + if result is None: + raise RuntimeError("ghostty_type_json returned NULL") + return result + + +def load_wasm_manifest(path: Path) -> bytes: + """Call ghostty_type_json and read its result from WebAssembly memory.""" + try: + import wasmtime + except ImportError as error: + raise SystemExit( + "missing WebAssembly verifier dependencies; run this inside " + "`nix develop`" + ) from error + + engine = wasmtime.Engine() + module = wasmtime.Module.from_file(engine, str(path)) + store = wasmtime.Store(engine) + instance = wasmtime.Instance(store, module, []) + exports = instance.exports(store) + memory = exports["memory"] + type_json = exports["ghostty_type_json"] + + pointer = type_json(store) + data = memory.read(store, pointer, memory.data_len(store)) + terminator = data.find(b"\0") + if terminator < 0: + raise RuntimeError("ghostty_type_json result is not NUL terminated") + return bytes(data[:terminator]) + + +def load_manifest(path: Path) -> dict[str, Any]: + """Execute the public export and decode its JSON result.""" + encoded = ( + load_wasm_manifest(path) + if path.suffix.lower() == ".wasm" + else load_native_manifest(path) + ) + value = json.loads(encoded) + if not isinstance(value, dict): + raise ValueError("ghostty_type_json did not return a JSON object") + return value + + +def format_path(parts: list[object]) -> str: + """Format a jsonschema error path for command-line output.""" + return "/" + "/".join(str(part) for part in parts) + + +def validate(schema_path: Path, library_path: Path) -> None: + """Validate the schema itself and the manifest returned by the library.""" + schema = json.loads(schema_path.read_bytes()) + validator_type = jsonschema.validators.validator_for(schema) + validator_type.check_schema(schema) + + manifest = load_manifest(library_path) + errors = sorted( + validator_type(schema).iter_errors(manifest), + key=lambda error: list(error.absolute_path), + ) + if errors: + for error in errors: + print( + f"{format_path(list(error.absolute_path))}: {error.message}", + file=sys.stderr, + ) + raise SystemExit(f"ABI manifest failed {schema_path}") + + abi = manifest["abi"] + print( + f"validated {abi['target']}-{abi['os']} ABI manifest " + f"({len(manifest['types'])} types)" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("schema", type=Path) + parser.add_argument("library", type=Path) + args = parser.parse_args() + validate(args.schema, args.library) + + +if __name__ == "__main__": + main() diff --git a/src/terminal/c/types.schema.json b/src/terminal/c/types.schema.json new file mode 100644 index 000000000..7020cb172 --- /dev/null +++ b/src/terminal/c/types.schema.json @@ -0,0 +1,348 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "libghostty-vt ABI type manifest", + "description": "Schema for the JSON returned by ghostty_type_json.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "abi", "library_version", "commit", "dirty", "types"], + "properties": { + "schema": { + "const": 1 + }, + "abi": { + "$ref": "#/$defs/abi" + }, + "library_version": { + "type": "string", + "minLength": 1 + }, + "commit": { + "type": ["string", "null"], + "minLength": 1 + }, + "dirty": { + "type": ["boolean", "null"] + }, + "types": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^Ghostty[A-Za-z0-9_]+$" + }, + "additionalProperties": { + "$ref": "#/$defs/typeDescriptor" + } + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "positiveInteger": { + "type": "integer", + "minimum": 1 + }, + "typeReference": { + "type": "string", + "pattern": "^(Ghostty[A-Za-z0-9_]+|bool|f32|f64|function|i8|i16|i32|i64|opaque|u8|u16|u32|u64|void)$" + }, + "abi": { + "type": "object", + "additionalProperties": false, + "required": [ + "target", + "os", + "environment", + "pointer_size", + "usize_size", + "endian" + ], + "properties": { + "target": { + "type": "string", + "minLength": 1 + }, + "os": { + "type": "string", + "minLength": 1 + }, + "environment": { + "type": "string", + "minLength": 1 + }, + "pointer_size": { + "$ref": "#/$defs/positiveInteger" + }, + "usize_size": { + "$ref": "#/$defs/positiveInteger" + }, + "endian": { + "enum": ["little", "big"] + } + } + }, + "fieldBase": { + "type": "object", + "required": ["offset", "size", "type"], + "properties": { + "offset": { + "$ref": "#/$defs/nonNegativeInteger" + }, + "size": { + "$ref": "#/$defs/nonNegativeInteger" + } + } + }, + "plainField": { + "allOf": [ + { + "$ref": "#/$defs/fieldBase" + }, + { + "properties": { + "type": { + "$ref": "#/$defs/typeReference" + } + } + } + ], + "unevaluatedProperties": false + }, + "arrayField": { + "allOf": [ + { + "$ref": "#/$defs/fieldBase" + }, + { + "required": ["elem", "count"], + "properties": { + "type": { + "const": "array" + }, + "elem": { + "$ref": "#/$defs/typeReference" + }, + "count": { + "$ref": "#/$defs/nonNegativeInteger" + } + } + } + ], + "unevaluatedProperties": false + }, + "pointerField": { + "allOf": [ + { + "$ref": "#/$defs/fieldBase" + }, + { + "required": ["elem", "const"], + "properties": { + "type": { + "const": "pointer" + }, + "elem": { + "$ref": "#/$defs/typeReference" + }, + "const": { + "type": "boolean" + }, + "nullable": { + "const": true + } + } + } + ], + "unevaluatedProperties": false + }, + "taggedUnionField": { + "allOf": [ + { + "$ref": "#/$defs/fieldBase" + }, + { + "required": ["tag", "arms"], + "properties": { + "type": { + "$ref": "#/$defs/typeReference" + }, + "tag": { + "type": "string", + "minLength": 1 + }, + "arms": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "additionalProperties": { + "type": ["string", "null"] + } + } + } + } + ], + "unevaluatedProperties": false + }, + "field": { + "oneOf": [ + { + "$ref": "#/$defs/plainField" + }, + { + "$ref": "#/$defs/arrayField" + }, + { + "$ref": "#/$defs/pointerField" + }, + { + "$ref": "#/$defs/taggedUnionField" + } + ] + }, + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/field" + } + }, + "descriptorBase": { + "type": "object", + "required": ["kind", "size", "align"], + "properties": { + "size": { + "$ref": "#/$defs/nonNegativeInteger" + }, + "align": { + "$ref": "#/$defs/positiveInteger" + } + } + }, + "structDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["fields"], + "properties": { + "kind": { + "const": "struct" + }, + "fields": { + "$ref": "#/$defs/fields" + } + } + } + ], + "unevaluatedProperties": false + }, + "unionDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["fields"], + "properties": { + "kind": { + "const": "union" + }, + "fields": { + "$ref": "#/$defs/fields" + } + } + } + ], + "unevaluatedProperties": false + }, + "enumDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["underlying", "prefix", "values"], + "properties": { + "kind": { + "const": "enum" + }, + "underlying": { + "const": "i32" + }, + "prefix": { + "type": "string", + "minLength": 1 + }, + "values": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "additionalProperties": { + "type": "integer" + } + } + } + } + ], + "unevaluatedProperties": false + }, + "aliasDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["type"], + "properties": { + "kind": { + "const": "alias" + }, + "type": { + "$ref": "#/$defs/typeReference" + } + } + } + ], + "unevaluatedProperties": false + }, + "opaqueDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "properties": { + "kind": { + "const": "opaque" + } + } + } + ], + "unevaluatedProperties": false + }, + "typeDescriptor": { + "oneOf": [ + { + "$ref": "#/$defs/structDescriptor" + }, + { + "$ref": "#/$defs/unionDescriptor" + }, + { + "$ref": "#/$defs/enumDescriptor" + }, + { + "$ref": "#/$defs/aliasDescriptor" + }, + { + "$ref": "#/$defs/opaqueDescriptor" + } + ] + } + } +} diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 88759ef1c..2242b1d50 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -2,7 +2,8 @@ //! //! The manifest is embedded in the library and returned by //! `ghostty_type_json`. It is intended for FFI consumers that cannot use the -//! C headers directly, most notably WebAssembly hosts. +//! C headers directly, most notably WebAssembly hosts. Its format is defined +//! by `types.schema.json`. const std = @import("std"); const builtin = @import("builtin"); const build_options = @import("terminal_options");