Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ validate: test
@python3 scripts/validate_scaffold.py

test:
@python3 -m pip install --user jsonschema >/dev/null
@python3 -m unittest discover -s tests -v
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ sourceosctl [--version] <command> [<subcommand>] [options]
| `sourceosctl doctor` | Run environment health checks (read-only) |
| `sourceosctl profiles list` | List available SourceOS profiles (read-only) |
| `sourceosctl nlboot evidence inspect <path>` | Inspect a NLBoot evidence JSON file (read-only) |
| `sourceosctl nlboot evidence inspect --validate <path>` | Inspect and validate a NLBoot evidence file against its bundled schema (read-only) |
| `sourceosctl nlboot evidence validate <path>` | Validate a NLBoot evidence file against its bundled JSON Schema (read-only) |
| `sourceosctl release inspect <path>` | 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) |
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions fixtures/invalid_nlboot_evidence.json
Original file line number Diff line number Diff line change
@@ -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"
}
75 changes: 75 additions & 0 deletions fixtures/schemas/nlboot-evidence.v1.schema.json
Original file line number Diff line number Diff line change
@@ -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)."
}
}
}
12 changes: 12 additions & 0 deletions sourceosctl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="<subcommand>")
Expand Down
65 changes: 65 additions & 0 deletions sourceosctl/commands/nlboot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<root>"
print(f" {field}: {err.message}", file=sys.stderr)
return 1


def inspect_evidence(args) -> int:
"""Inspect a NLBoot evidence file. Read-only."""
Expand Down Expand Up @@ -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
67 changes: 64 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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:
Expand All @@ -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):
Expand Down
Loading