Skip to content

Commit 9cbbddc

Browse files
Copilotmdheller
andauthored
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>
1 parent 90f9d6f commit 9cbbddc

6 files changed

Lines changed: 233 additions & 4 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ sourceosctl [--version] <command> [<subcommand>] [options]
5151
| `sourceosctl doctor` | Run environment health checks (read-only) |
5252
| `sourceosctl profiles list` | List available SourceOS profiles (read-only) |
5353
| `sourceosctl nlboot evidence inspect <path>` | Inspect a NLBoot evidence JSON file (read-only) |
54+
| `sourceosctl nlboot evidence inspect --validate <path>` | Inspect and validate a NLBoot evidence file against its bundled schema (read-only) |
55+
| `sourceosctl nlboot evidence validate <path>` | Validate a NLBoot evidence file against its bundled JSON Schema (read-only) |
5456
| `sourceosctl release inspect <path>` | Inspect a release artifact JSON file (read-only) |
5557
| `sourceosctl fingerprint collect --dry-run` | Print environment fingerprint fields (dry-run only) |
5658
| `sourceosctl ai labs list` | List available AI labs (read-only) |
@@ -63,6 +65,8 @@ python3 bin/sourceosctl --help
6365
python3 bin/sourceosctl doctor
6466
python3 bin/sourceosctl profiles list
6567
python3 bin/sourceosctl nlboot evidence inspect fixtures/sample_nlboot_evidence.json
68+
python3 bin/sourceosctl nlboot evidence inspect --validate fixtures/sample_nlboot_evidence.json
69+
python3 bin/sourceosctl nlboot evidence validate fixtures/sample_nlboot_evidence.json
6670
python3 bin/sourceosctl release inspect fixtures/sample_release.json
6771
python3 bin/sourceosctl fingerprint collect --dry-run
6872
python3 bin/sourceosctl ai labs list
@@ -101,7 +105,7 @@ M1 is repo maturity and install surface definition:
101105
make validate
102106
```
103107

104-
The validation target runs the unit test suite and checks repository metadata. All 21 tests must pass.
108+
The validation target runs the unit test suite and checks repository metadata. All tests must pass.
105109

106110
```bash
107111
make test # run tests only
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"schemaVersion": "nlboot-evidence.v1",
3+
"kind": "NLBootEvidence",
4+
"timestamp": "NOT-A-TIMESTAMP",
5+
"target": {
6+
"arch": "x86_64"
7+
},
8+
"boot": {
9+
"loader": "systemd-boot"
10+
},
11+
"status": "invalid-status"
12+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "nlboot-evidence.v1",
4+
"title": "NLBootEvidence",
5+
"description": "Schema for NLBoot evidence records (version 1).",
6+
"type": "object",
7+
"required": ["schemaVersion", "kind", "timestamp", "target", "boot", "status", "signature"],
8+
"additionalProperties": false,
9+
"properties": {
10+
"schemaVersion": {
11+
"type": "string",
12+
"const": "nlboot-evidence.v1",
13+
"description": "Schema version identifier."
14+
},
15+
"kind": {
16+
"type": "string",
17+
"const": "NLBootEvidence",
18+
"description": "Record kind discriminator."
19+
},
20+
"timestamp": {
21+
"type": "string",
22+
"description": "ISO 8601 timestamp of the evidence record.",
23+
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$"
24+
},
25+
"target": {
26+
"type": "object",
27+
"description": "Boot target description.",
28+
"required": ["host", "arch"],
29+
"additionalProperties": false,
30+
"properties": {
31+
"host": {
32+
"type": "string",
33+
"description": "Hostname of the boot target."
34+
},
35+
"arch": {
36+
"type": "string",
37+
"description": "CPU architecture (e.g. x86_64, aarch64)."
38+
},
39+
"uefi": {
40+
"type": "boolean",
41+
"description": "Whether the target uses UEFI boot."
42+
}
43+
}
44+
},
45+
"boot": {
46+
"type": "object",
47+
"description": "Boot configuration details.",
48+
"required": ["loader", "kernel"],
49+
"additionalProperties": false,
50+
"properties": {
51+
"loader": {
52+
"type": "string",
53+
"description": "Boot loader used (e.g. systemd-boot, grub)."
54+
},
55+
"kernel": {
56+
"type": "string",
57+
"description": "Kernel version string."
58+
},
59+
"cmdline": {
60+
"type": "string",
61+
"description": "Kernel command line arguments."
62+
}
63+
}
64+
},
65+
"status": {
66+
"type": "string",
67+
"description": "Verification status of the boot evidence.",
68+
"enum": ["verified", "failed", "unknown"]
69+
},
70+
"signature": {
71+
"type": "string",
72+
"description": "Evidence signature (stub; not cryptographically verified in this tool)."
73+
}
74+
}
75+
}

sourceosctl/cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,20 @@ def build_parser() -> argparse.ArgumentParser:
5252
"inspect", help="Inspect a NLBoot evidence file"
5353
)
5454
nlboot_inspect_p.add_argument("path", help="Path to NLBoot evidence JSON file")
55+
nlboot_inspect_p.add_argument(
56+
"--validate",
57+
action="store_true",
58+
default=False,
59+
help="Validate the evidence file against its bundled JSON Schema",
60+
)
5561
nlboot_inspect_p.set_defaults(func=nlboot.inspect_evidence)
5662

63+
nlboot_validate_p = nlboot_evidence_sub.add_parser(
64+
"validate", help="Validate a NLBoot evidence file against its bundled schema"
65+
)
66+
nlboot_validate_p.add_argument("path", help="Path to NLBoot evidence JSON file")
67+
nlboot_validate_p.set_defaults(func=nlboot.validate_evidence)
68+
5769
# --- release ---
5870
release_p = sub.add_parser("release", help="Release artifact inspection")
5971
release_sub = release_p.add_subparsers(dest="release_command", metavar="<subcommand>")

sourceosctl/commands/nlboot.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,68 @@
44
import pathlib
55
import sys
66

7+
# Repo-local schema directory (no network fetches).
8+
_SCHEMA_DIR = pathlib.Path(__file__).parent.parent.parent / "fixtures" / "schemas"
9+
10+
_KNOWN_SCHEMAS = {
11+
"nlboot-evidence.v1": _SCHEMA_DIR / "nlboot-evidence.v1.schema.json",
12+
}
13+
14+
15+
def _load_schema(schema_version: str):
16+
"""Load a vendored JSON Schema by schemaVersion string.
17+
18+
Returns the parsed schema dict or raises FileNotFoundError / ValueError.
19+
"""
20+
schema_path = _KNOWN_SCHEMAS.get(schema_version)
21+
if schema_path is None:
22+
raise ValueError(f"no bundled schema for schemaVersion '{schema_version}'")
23+
return json.loads(schema_path.read_text())
24+
25+
26+
def validate_evidence(args) -> int:
27+
"""Validate a NLBoot evidence file against its bundled schema. Read-only."""
28+
try:
29+
import jsonschema # type: ignore
30+
except ImportError:
31+
print(
32+
"error: jsonschema package is required for validation "
33+
"(pip install jsonschema)",
34+
file=sys.stderr,
35+
)
36+
return 1
37+
38+
path = pathlib.Path(args.path)
39+
if not path.exists():
40+
print(f"error: file not found: {path}", file=sys.stderr)
41+
return 1
42+
43+
try:
44+
data = json.loads(path.read_text())
45+
except json.JSONDecodeError as exc:
46+
print(f"error: invalid JSON in {path}: {exc}", file=sys.stderr)
47+
return 1
48+
49+
schema_version = data.get("schemaVersion", "")
50+
try:
51+
schema = _load_schema(schema_version)
52+
except (ValueError, FileNotFoundError) as exc:
53+
print(f"error: cannot load schema: {exc}", file=sys.stderr)
54+
return 1
55+
56+
validator = jsonschema.Draft7Validator(schema)
57+
errors = sorted(validator.iter_errors(data), key=lambda e: list(e.path))
58+
59+
if not errors:
60+
print(f"ok: {path} is valid ({schema_version})")
61+
return 0
62+
63+
print(f"error: {path} failed schema validation ({schema_version}):", file=sys.stderr)
64+
for err in errors:
65+
field = ".".join(str(p) for p in err.absolute_path) or "<root>"
66+
print(f" {field}: {err.message}", file=sys.stderr)
67+
return 1
68+
769

870
def inspect_evidence(args) -> int:
971
"""Inspect a NLBoot evidence file. Read-only."""
@@ -34,4 +96,7 @@ def inspect_evidence(args) -> int:
3496
else:
3597
print(f" {key}: {value}")
3698

99+
if getattr(args, "validate", False):
100+
return validate_evidence(args)
101+
37102
return 0

tests/test_cli.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,12 @@ def test_profiles_list_via_main(self):
5959
class TestNlboot(unittest.TestCase):
6060
def test_inspect_evidence_fixture(self):
6161
path = FIXTURES / "sample_nlboot_evidence.json"
62-
args = _Args(path=str(path))
62+
args = _Args(path=str(path), validate=False)
6363
result = nlboot.inspect_evidence(args)
6464
self.assertIn(result, (0, None))
6565

6666
def test_inspect_evidence_missing_file(self):
67-
args = _Args(path="/nonexistent/path/evidence.json")
67+
args = _Args(path="/nonexistent/path/evidence.json", validate=False)
6868
result = nlboot.inspect_evidence(args)
6969
self.assertEqual(result, 1)
7070

@@ -73,7 +73,7 @@ def test_inspect_evidence_bad_json(self):
7373
f.write("not json {{{")
7474
tmp_path = f.name
7575
try:
76-
args = _Args(path=tmp_path)
76+
args = _Args(path=tmp_path, validate=False)
7777
result = nlboot.inspect_evidence(args)
7878
self.assertEqual(result, 1)
7979
finally:
@@ -84,6 +84,67 @@ def test_nlboot_evidence_inspect_via_main(self):
8484
rc = main(["nlboot", "evidence", "inspect", str(path)])
8585
self.assertEqual(rc, 0)
8686

87+
# --- schema validation ---
88+
89+
def test_validate_evidence_valid_fixture(self):
90+
path = FIXTURES / "sample_nlboot_evidence.json"
91+
args = _Args(path=str(path))
92+
result = nlboot.validate_evidence(args)
93+
self.assertEqual(result, 0)
94+
95+
def test_validate_evidence_invalid_fixture(self):
96+
path = FIXTURES / "invalid_nlboot_evidence.json"
97+
args = _Args(path=str(path))
98+
result = nlboot.validate_evidence(args)
99+
self.assertEqual(result, 1)
100+
101+
def test_validate_evidence_missing_file(self):
102+
args = _Args(path="/nonexistent/evidence.json")
103+
result = nlboot.validate_evidence(args)
104+
self.assertEqual(result, 1)
105+
106+
def test_validate_evidence_bad_json(self):
107+
with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f:
108+
f.write("not json {{{")
109+
tmp_path = f.name
110+
try:
111+
args = _Args(path=tmp_path)
112+
result = nlboot.validate_evidence(args)
113+
self.assertEqual(result, 1)
114+
finally:
115+
os.unlink(tmp_path)
116+
117+
def test_validate_evidence_unknown_schema(self):
118+
with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f:
119+
json.dump({"schemaVersion": "unknown-schema.v99", "kind": "Unknown"}, f)
120+
tmp_path = f.name
121+
try:
122+
args = _Args(path=tmp_path)
123+
result = nlboot.validate_evidence(args)
124+
self.assertEqual(result, 1)
125+
finally:
126+
os.unlink(tmp_path)
127+
128+
def test_inspect_with_validate_flag_valid(self):
129+
path = FIXTURES / "sample_nlboot_evidence.json"
130+
rc = main(["nlboot", "evidence", "inspect", "--validate", str(path)])
131+
self.assertEqual(rc, 0)
132+
133+
def test_inspect_with_validate_flag_invalid(self):
134+
path = FIXTURES / "invalid_nlboot_evidence.json"
135+
rc = main(["nlboot", "evidence", "inspect", "--validate", str(path)])
136+
self.assertEqual(rc, 1)
137+
138+
def test_validate_subcommand_valid_via_main(self):
139+
path = FIXTURES / "sample_nlboot_evidence.json"
140+
rc = main(["nlboot", "evidence", "validate", str(path)])
141+
self.assertEqual(rc, 0)
142+
143+
def test_validate_subcommand_invalid_via_main(self):
144+
path = FIXTURES / "invalid_nlboot_evidence.json"
145+
rc = main(["nlboot", "evidence", "validate", str(path)])
146+
self.assertEqual(rc, 1)
147+
87148

88149
class TestRelease(unittest.TestCase):
89150
def test_inspect_fixture(self):

0 commit comments

Comments
 (0)