From 90f9d6f5306e31b4e284d7f2a561bb9bfd4a5a3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:32:45 +0000 Subject: [PATCH 1/3] Initial plan From 9cbbddcd8a44ceb57f14a1c9656b337e53a88a3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:36:08 +0000 Subject: [PATCH 2/3] Add schema-backed NLBoot evidence validation Agent-Logs-Url: https://github.com/SourceOS-Linux/sourceos-devtools/sessions/f0ce8450-0f13-493b-8777-04596307e084 Co-authored-by: mdheller <21163552+mdheller@users.noreply.github.com> --- README.md | 6 +- fixtures/invalid_nlboot_evidence.json | 12 +++ .../schemas/nlboot-evidence.v1.schema.json | 75 +++++++++++++++++++ sourceosctl/cli.py | 12 +++ sourceosctl/commands/nlboot.py | 65 ++++++++++++++++ tests/test_cli.py | 67 ++++++++++++++++- 6 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 fixtures/invalid_nlboot_evidence.json create mode 100644 fixtures/schemas/nlboot-evidence.v1.schema.json diff --git a/README.md b/README.md index 3a4f54a..36a841e 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ sourceosctl [--version] [] [options] | `sourceosctl doctor` | Run environment health checks (read-only) | | `sourceosctl profiles list` | List available SourceOS profiles (read-only) | | `sourceosctl nlboot evidence inspect ` | Inspect a NLBoot evidence JSON file (read-only) | +| `sourceosctl nlboot evidence inspect --validate ` | Inspect and validate a NLBoot evidence file against its bundled schema (read-only) | +| `sourceosctl nlboot evidence validate ` | Validate a NLBoot evidence file against its bundled JSON Schema (read-only) | | `sourceosctl release inspect ` | Inspect a release artifact JSON file (read-only) | | `sourceosctl fingerprint collect --dry-run` | Print environment fingerprint fields (dry-run only) | | `sourceosctl ai labs list` | List available AI labs (read-only) | @@ -63,6 +65,8 @@ python3 bin/sourceosctl --help python3 bin/sourceosctl doctor python3 bin/sourceosctl profiles list python3 bin/sourceosctl nlboot evidence inspect fixtures/sample_nlboot_evidence.json +python3 bin/sourceosctl nlboot evidence inspect --validate fixtures/sample_nlboot_evidence.json +python3 bin/sourceosctl nlboot evidence validate fixtures/sample_nlboot_evidence.json python3 bin/sourceosctl release inspect fixtures/sample_release.json python3 bin/sourceosctl fingerprint collect --dry-run python3 bin/sourceosctl ai labs list @@ -101,7 +105,7 @@ M1 is repo maturity and install surface definition: make validate ``` -The validation target runs the unit test suite and checks repository metadata. All 21 tests must pass. +The validation target runs the unit test suite and checks repository metadata. All tests must pass. ```bash make test # run tests only diff --git a/fixtures/invalid_nlboot_evidence.json b/fixtures/invalid_nlboot_evidence.json new file mode 100644 index 0000000..bbe7093 --- /dev/null +++ b/fixtures/invalid_nlboot_evidence.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": "nlboot-evidence.v1", + "kind": "NLBootEvidence", + "timestamp": "NOT-A-TIMESTAMP", + "target": { + "arch": "x86_64" + }, + "boot": { + "loader": "systemd-boot" + }, + "status": "invalid-status" +} diff --git a/fixtures/schemas/nlboot-evidence.v1.schema.json b/fixtures/schemas/nlboot-evidence.v1.schema.json new file mode 100644 index 0000000..3bfd410 --- /dev/null +++ b/fixtures/schemas/nlboot-evidence.v1.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "nlboot-evidence.v1", + "title": "NLBootEvidence", + "description": "Schema for NLBoot evidence records (version 1).", + "type": "object", + "required": ["schemaVersion", "kind", "timestamp", "target", "boot", "status", "signature"], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "string", + "const": "nlboot-evidence.v1", + "description": "Schema version identifier." + }, + "kind": { + "type": "string", + "const": "NLBootEvidence", + "description": "Record kind discriminator." + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp of the evidence record.", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" + }, + "target": { + "type": "object", + "description": "Boot target description.", + "required": ["host", "arch"], + "additionalProperties": false, + "properties": { + "host": { + "type": "string", + "description": "Hostname of the boot target." + }, + "arch": { + "type": "string", + "description": "CPU architecture (e.g. x86_64, aarch64)." + }, + "uefi": { + "type": "boolean", + "description": "Whether the target uses UEFI boot." + } + } + }, + "boot": { + "type": "object", + "description": "Boot configuration details.", + "required": ["loader", "kernel"], + "additionalProperties": false, + "properties": { + "loader": { + "type": "string", + "description": "Boot loader used (e.g. systemd-boot, grub)." + }, + "kernel": { + "type": "string", + "description": "Kernel version string." + }, + "cmdline": { + "type": "string", + "description": "Kernel command line arguments." + } + } + }, + "status": { + "type": "string", + "description": "Verification status of the boot evidence.", + "enum": ["verified", "failed", "unknown"] + }, + "signature": { + "type": "string", + "description": "Evidence signature (stub; not cryptographically verified in this tool)." + } + } +} diff --git a/sourceosctl/cli.py b/sourceosctl/cli.py index 6f7e734..a659011 100644 --- a/sourceosctl/cli.py +++ b/sourceosctl/cli.py @@ -52,8 +52,20 @@ def build_parser() -> argparse.ArgumentParser: "inspect", help="Inspect a NLBoot evidence file" ) nlboot_inspect_p.add_argument("path", help="Path to NLBoot evidence JSON file") + nlboot_inspect_p.add_argument( + "--validate", + action="store_true", + default=False, + help="Validate the evidence file against its bundled JSON Schema", + ) nlboot_inspect_p.set_defaults(func=nlboot.inspect_evidence) + nlboot_validate_p = nlboot_evidence_sub.add_parser( + "validate", help="Validate a NLBoot evidence file against its bundled schema" + ) + nlboot_validate_p.add_argument("path", help="Path to NLBoot evidence JSON file") + nlboot_validate_p.set_defaults(func=nlboot.validate_evidence) + # --- release --- release_p = sub.add_parser("release", help="Release artifact inspection") release_sub = release_p.add_subparsers(dest="release_command", metavar="") diff --git a/sourceosctl/commands/nlboot.py b/sourceosctl/commands/nlboot.py index 093c44a..d6dc098 100644 --- a/sourceosctl/commands/nlboot.py +++ b/sourceosctl/commands/nlboot.py @@ -4,6 +4,68 @@ import pathlib import sys +# Repo-local schema directory (no network fetches). +_SCHEMA_DIR = pathlib.Path(__file__).parent.parent.parent / "fixtures" / "schemas" + +_KNOWN_SCHEMAS = { + "nlboot-evidence.v1": _SCHEMA_DIR / "nlboot-evidence.v1.schema.json", +} + + +def _load_schema(schema_version: str): + """Load a vendored JSON Schema by schemaVersion string. + + Returns the parsed schema dict or raises FileNotFoundError / ValueError. + """ + schema_path = _KNOWN_SCHEMAS.get(schema_version) + if schema_path is None: + raise ValueError(f"no bundled schema for schemaVersion '{schema_version}'") + return json.loads(schema_path.read_text()) + + +def validate_evidence(args) -> int: + """Validate a NLBoot evidence file against its bundled schema. Read-only.""" + try: + import jsonschema # type: ignore + except ImportError: + print( + "error: jsonschema package is required for validation " + "(pip install jsonschema)", + file=sys.stderr, + ) + return 1 + + path = pathlib.Path(args.path) + if not path.exists(): + print(f"error: file not found: {path}", file=sys.stderr) + return 1 + + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + print(f"error: invalid JSON in {path}: {exc}", file=sys.stderr) + return 1 + + schema_version = data.get("schemaVersion", "") + try: + schema = _load_schema(schema_version) + except (ValueError, FileNotFoundError) as exc: + print(f"error: cannot load schema: {exc}", file=sys.stderr) + return 1 + + validator = jsonschema.Draft7Validator(schema) + errors = sorted(validator.iter_errors(data), key=lambda e: list(e.path)) + + if not errors: + print(f"ok: {path} is valid ({schema_version})") + return 0 + + print(f"error: {path} failed schema validation ({schema_version}):", file=sys.stderr) + for err in errors: + field = ".".join(str(p) for p in err.absolute_path) or "" + print(f" {field}: {err.message}", file=sys.stderr) + return 1 + def inspect_evidence(args) -> int: """Inspect a NLBoot evidence file. Read-only.""" @@ -34,4 +96,7 @@ def inspect_evidence(args) -> int: else: print(f" {key}: {value}") + if getattr(args, "validate", False): + return validate_evidence(args) + return 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index d7cea24..6516253 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -59,12 +59,12 @@ def test_profiles_list_via_main(self): class TestNlboot(unittest.TestCase): def test_inspect_evidence_fixture(self): path = FIXTURES / "sample_nlboot_evidence.json" - args = _Args(path=str(path)) + args = _Args(path=str(path), validate=False) result = nlboot.inspect_evidence(args) self.assertIn(result, (0, None)) def test_inspect_evidence_missing_file(self): - args = _Args(path="/nonexistent/path/evidence.json") + args = _Args(path="/nonexistent/path/evidence.json", validate=False) result = nlboot.inspect_evidence(args) self.assertEqual(result, 1) @@ -73,7 +73,7 @@ def test_inspect_evidence_bad_json(self): f.write("not json {{{") tmp_path = f.name try: - args = _Args(path=tmp_path) + args = _Args(path=tmp_path, validate=False) result = nlboot.inspect_evidence(args) self.assertEqual(result, 1) finally: @@ -84,6 +84,67 @@ def test_nlboot_evidence_inspect_via_main(self): rc = main(["nlboot", "evidence", "inspect", str(path)]) self.assertEqual(rc, 0) + # --- schema validation --- + + def test_validate_evidence_valid_fixture(self): + path = FIXTURES / "sample_nlboot_evidence.json" + args = _Args(path=str(path)) + result = nlboot.validate_evidence(args) + self.assertEqual(result, 0) + + def test_validate_evidence_invalid_fixture(self): + path = FIXTURES / "invalid_nlboot_evidence.json" + args = _Args(path=str(path)) + result = nlboot.validate_evidence(args) + self.assertEqual(result, 1) + + def test_validate_evidence_missing_file(self): + args = _Args(path="/nonexistent/evidence.json") + result = nlboot.validate_evidence(args) + self.assertEqual(result, 1) + + def test_validate_evidence_bad_json(self): + with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f: + f.write("not json {{{") + tmp_path = f.name + try: + args = _Args(path=tmp_path) + result = nlboot.validate_evidence(args) + self.assertEqual(result, 1) + finally: + os.unlink(tmp_path) + + def test_validate_evidence_unknown_schema(self): + with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f: + json.dump({"schemaVersion": "unknown-schema.v99", "kind": "Unknown"}, f) + tmp_path = f.name + try: + args = _Args(path=tmp_path) + result = nlboot.validate_evidence(args) + self.assertEqual(result, 1) + finally: + os.unlink(tmp_path) + + def test_inspect_with_validate_flag_valid(self): + path = FIXTURES / "sample_nlboot_evidence.json" + rc = main(["nlboot", "evidence", "inspect", "--validate", str(path)]) + self.assertEqual(rc, 0) + + def test_inspect_with_validate_flag_invalid(self): + path = FIXTURES / "invalid_nlboot_evidence.json" + rc = main(["nlboot", "evidence", "inspect", "--validate", str(path)]) + self.assertEqual(rc, 1) + + def test_validate_subcommand_valid_via_main(self): + path = FIXTURES / "sample_nlboot_evidence.json" + rc = main(["nlboot", "evidence", "validate", str(path)]) + self.assertEqual(rc, 0) + + def test_validate_subcommand_invalid_via_main(self): + path = FIXTURES / "invalid_nlboot_evidence.json" + rc = main(["nlboot", "evidence", "validate", str(path)]) + self.assertEqual(rc, 1) + class TestRelease(unittest.TestCase): def test_inspect_fixture(self): From d6c22009c558db911f0b571fc1ade951ce05ca09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:39:15 +0000 Subject: [PATCH 3/3] ci: trigger rerun after workflow approval Co-authored-by: mdheller <21163552+mdheller@users.noreply.github.com>