From c227445cb7700a074e3009910fc6540934d042f5 Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Tue, 4 Aug 2026 13:31:44 -0600 Subject: [PATCH] Add ITU-T X.590 JSON Signature Schema (JSS) support Adds support for JSS signatures by allowing the provided signatures property in the top level of the document, and in all the code bindings. --- src/shacl2code/lang/common.py | 1 + .../lang/templates/cpp/jsonld-source.j2 | 15 ++++ .../templates/golang/shaclobjectset.go.j2 | 12 +++ src/shacl2code/lang/templates/jsonschema.j2 | 75 ++++++++++++++++++- .../lang/templates/python/model.py.j2 | 46 +++++++++--- src/shacl2code/lang/templates/rust/lib.rs.j2 | 38 +++++++++- src/shacl2code/main.py | 12 ++- src/shacl2code/model.py | 3 +- testfixtures/testfixtures/jsonvalidation.py | 61 +++++++++++++++ tests/conftest.py | 2 + tests/test_cpp.py | 6 ++ tests/test_golang.py | 3 + tests/test_jsonschema.py | 12 ++- tests/test_python.py | 74 ++++++++++++++++++ tests/test_rust.py | 2 + 15 files changed, 342 insertions(+), 20 deletions(-) diff --git a/src/shacl2code/lang/common.py b/src/shacl2code/lang/common.py index 9d9055e7..4ef2adaf 100644 --- a/src/shacl2code/lang/common.py +++ b/src/shacl2code/lang/common.py @@ -137,6 +137,7 @@ def get_all_named_individuals(cls): "concrete_classes": concrete_classes, "abstract_classes": abstract_classes, "context": model.context, + "jss_signature": model.jss_signature, **self.get_additional_render_args(model), } diff --git a/src/shacl2code/lang/templates/cpp/jsonld-source.j2 b/src/shacl2code/lang/templates/cpp/jsonld-source.j2 index c6bdafb1..1dc3f400 100644 --- a/src/shacl2code/lang/templates/cpp/jsonld-source.j2 +++ b/src/shacl2code/lang/templates/cpp/jsonld-source.j2 @@ -485,6 +485,21 @@ void JSONLDDeserializer::read(std::istream& input, SHACLObjectSet& objectSet, errorHandler.handleDeserializeError("Invalid context URLs", path); return; } + {%- if jss_signature %} + { + const auto jss_signature = "{{ jss_signature }}"; + + if (d.contains(jss_signature)) { + path.pushMember(jss_signature, [&] { + if (!d[jss_signature].is_array()) { + errorHandler.handleDeserializeError("Signature {{ jss_signature }} must be an array", path); + return; + } + }); + d.erase(jss_signature); + } + } + {%- endif %} if (!d.contains("@graph")) { JSONData data; diff --git a/src/shacl2code/lang/templates/golang/shaclobjectset.go.j2 b/src/shacl2code/lang/templates/golang/shaclobjectset.go.j2 index b080e03a..4299d176 100644 --- a/src/shacl2code/lang/templates/golang/shaclobjectset.go.j2 +++ b/src/shacl2code/lang/templates/golang/shaclobjectset.go.j2 @@ -53,6 +53,8 @@ var CONTEXT_URLS = []string{ {{ '/' }}{{ '*' }}*/ } +var JSS_SIGNATURE = "{{ jss_signature or '' }}" + func makeshaclObjectList(lst []SHACLObject) shaclObjectList { idx := make(map[SHACLObject]int) for i, o := range lst { @@ -187,6 +189,16 @@ func (self *SHACLObjectSetObject) Decode(decoder *json.Decoder) error { return DecodeSHACLObject[SHACLObject](data, path, context, nil, state) } + if JSS_SIGNATURE != "" { + if signature, found := data[JSS_SIGNATURE]; found { + _, ok := signature.([]interface{}) + if !ok { + return &DecodeError{path, "Bad type for signature '" + JSS_SIGNATURE + "'"} + } + } + delete(data, JSS_SIGNATURE) + } + _, has_graph := data["@graph"] if has_graph { for k, v := range data { diff --git a/src/shacl2code/lang/templates/jsonschema.j2 b/src/shacl2code/lang/templates/jsonschema.j2 index a43ff374..d098f605 100644 --- a/src/shacl2code/lang/templates/jsonschema.j2 +++ b/src/shacl2code/lang/templates/jsonschema.j2 @@ -50,16 +50,87 @@ "$ref": "#/$defs/AnyClass", "unevaluatedProperties": false } - } + }{%- if jss_signature %}, + "{{ jss_signature }}": { + "$ref": "#/$defs/JSSsignatures" + }{%- endif %} }, "required": ["@graph"] }, "else": { - "$ref": "#/$defs/AnyClass" + "allOf": [ + { + "$ref": "#/$defs/AnyClass" + }{%- if jss_signature %}, + { + "properties": { + "{{ jss_signature }}": { "$ref": "#/$defs/JSSsignatures" } + } + }{%- endif %} + ] }, "unevaluatedProperties": false, "$defs": { + "JSSsignatures": { + "type": "array", + "items": { + "type": "object", + "anyOf": [ + { "required": [ "public_key" ] }, + { "required": [ "public_cert_chain" ] }, + { "required": [ "cert_url" ] }, + { "required": [ "thumbprint" ] } + ], + "required": [ "hash_algorithm", "algorithm", "value" ], + "properties": { + "id": { "$ref": "#/$defs/JSSidentifier" }, + "type": { "const": "x590" }, + "algorithm": { "type": "string" }, + "cert_url": { "type": "string" }, + "created": { "$ref": "#/$defs/JSStimestamp" }, + "hash_algorithm": { "type": "string" }, + "modified": { "$ref": "#/$defs/JSStimestamp" }, + "public_cert_chain": { + "type": "array", + "items": { + "type": "string", + "contentEncoding": "base64url" + } + }, + "public_key": { + "type": "string", + "contentEncoding": "base64url" + }, + "related_to": { "type": "string" }, + "related_version": { "type": "string" }, + "revoked": { + "default": false, + "type": "boolean" + }, + "signatures": { "$ref": "#/$defs/JSSsignatures" }, + "signee": { "type": "string" }, + "thumbprint": { + "type": "string", + "contentEncoding": "base64url" + }, + "valid_from": { "$ref": "#/$defs/JSStimestamp" }, + "valid_until": { "$ref": "#/$defs/JSStimestamp" }, + "value": { + "type": "string", + "contentEncoding": "base64url" + } + } + } + }, + "JSSidentifier": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "JSStimestamp": { + "type": "string", + "pattern": "^(\\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])T([01]\\d|2[0-3]):([0-5]\\d):([0-5]\\d)(\\.\\d+)?Z$" + }, {%- for class in classes %} {#- Classes are divided into 2 parts. The properties are separated into a separate object #} {#- so that a object can references the properties of its parent without needing the const #} diff --git a/src/shacl2code/lang/templates/python/model.py.j2 b/src/shacl2code/lang/templates/python/model.py.j2 index a875e039..ea45b3f9 100644 --- a/src/shacl2code/lang/templates/python/model.py.j2 +++ b/src/shacl2code/lang/templates/python/model.py.j2 @@ -1275,19 +1275,27 @@ class SHACLObject(metaclass=SHACLObjectMeta): if key in ("@id", self.ID_ALIAS): return True - expanded_key = state.expand_iri(key) - if expanded_key and expanded_key in self._OBJ_IRI_PROPS: - p = self._OBJ_IRI_PROPS[expanded_key] - elif key in self._OBJ_IRI_PROPS: - p = self._OBJ_IRI_PROPS[key] - elif key in self._OBJ_COMPACT_PROPS: - p = self._OBJ_COMPACT_PROPS[key] - else: - return False - with decoder.read_property(key) as prop_d: if prop_d is None: raise TypeError(f"Property decoder for key '{key}' cannot be None") + + if _JSS_SIGNATURE and key == _JSS_SIGNATURE: + if not prop_d.is_list(): + raise TypeError( + f"Property '{key}' must be a list with signature information", + ) + return True + + expanded_key = state.expand_iri(key) + if expanded_key and expanded_key in self._OBJ_IRI_PROPS: + p = self._OBJ_IRI_PROPS[expanded_key] + elif key in self._OBJ_IRI_PROPS: + p = self._OBJ_IRI_PROPS[key] + elif key in self._OBJ_COMPACT_PROPS: + p = self._OBJ_COMPACT_PROPS[key] + else: + return False + v = p.prop.decode(prop_d, state) self.__set(p, v) return True @@ -2215,9 +2223,23 @@ class JSONLDDeserializer(object): if context_prop: decode_context(context_prop, objectset) + if _JSS_SIGNATURE: + with h.read_property(_JSS_SIGNATURE) as signature_prop: + if signature_prop and not signature_prop.is_list(): + raise ValueError( + f"Expected signature property '{_JSS_SIGNATURE}' to be an list, got {type(signature_prop.data)}" + ) + state = DecodeState(objectset) with h.read_property("@graph") as graph_prop: - objectset.decode(graph_prop if graph_prop else h, state) + if graph_prop: + for k in h.object_keys(): + if k in {"@graph", "@context", _JSS_SIGNATURE}: + continue + raise KeyError(f"Unexpected property '{k}'") + objectset.decode(graph_prop, state) + else: + objectset.decode(h, state) def read(self, f: BinaryIO, objectset: SHACLObjectSet) -> None: """Parse a JSON-LD file and deserialize its objects into the given object set.""" @@ -2979,6 +3001,8 @@ except ImportError: # fmt: off """Format Guard{{ '"' }}{{ '"' }}{{ '"' }} +_JSS_SIGNATURE: Optional[str] = {% if jss_signature %}"{{ jss_signature }}"{% else %}None{% endif %} + CONTEXT_URLS: List[str] = [ {%- for url in context.urls %} "{{ url }}", diff --git a/src/shacl2code/lang/templates/rust/lib.rs.j2 b/src/shacl2code/lang/templates/rust/lib.rs.j2 index d3759b77..782650a2 100644 --- a/src/shacl2code/lang/templates/rust/lib.rs.j2 +++ b/src/shacl2code/lang/templates/rust/lib.rs.j2 @@ -1562,6 +1562,19 @@ fn decode_ref( } } +//{% if jss_signature %} +// ============================================================ +// Check a JSS Signature +// ============================================================ + +fn check_jss_signature(v: &Value, path: &Path) -> Result { + if v.is_array() { + return Ok(true); + } + return Err(Error::Decode(path.to_string(), "Signature value must be an array".to_string())); +} +//{% endif %} + // ============================================================ // Decode a SHACL object from JSON-LD // ============================================================ @@ -1647,6 +1660,12 @@ fn decode_shacl_object( if k == "@type" || k == "@context" { continue; } + {% if jss_signature %} + if k == "{{ jss_signature }}" { + check_jss_signature(v, &path.push_path(k))?; + continue; + } + {%- endif %} match obj_mut.decode_property(k, v, path, state) { Ok(true) => {} _ => { @@ -1687,6 +1706,12 @@ fn decode_shacl_object( if k == "@type" || k == "@context" { continue; } + {% if jss_signature %} + if k == "{{ jss_signature }}" { + check_jss_signature(v, &path.push_path(k))?; + continue; + } + {%- endif %} match obj_mut.decode_property(k, v, path, state) { Ok(true) => {} _ => { @@ -1717,6 +1742,12 @@ fn decode_shacl_object( if k == "@type" || k == "@context" { continue; } + {% if jss_signature %} + if k == "{{ jss_signature }}" { + check_jss_signature(v, &path.push_path(k))?; + continue; + } + {%- endif %} let found = obj.as_shacl_object_mut().decode_property(k, v, path, state)?; if !found { @@ -1908,6 +1939,11 @@ impl SHACLObjectSet { "Wrong context URL(s)".to_string(), )); } + //{% if jss_signature %} + if let Some(signature_value) = dict.get("{{ jss_signature }}") { + check_jss_signature(signature_value, &path.push_path("{{ jss_signature }}"))?; + } + //{% endif %} let state = DecodeState { context: self.context.clone(), @@ -1916,7 +1952,7 @@ impl SHACLObjectSet { if let Some(graph_val) = dict.get("@graph") { // Check for unknown root fields for k in dict.keys() { - if k != "@context" && k != "@graph" { + if k != "@context" && k != "@graph" {% if jss_signature %}&& k != "{{ jss_signature }}"{% endif %} { return Err(Error::Decode( path.to_string(), format!("Unknown property '{}'", k), diff --git a/src/shacl2code/main.py b/src/shacl2code/main.py index 9ce7604b..1711345a 100644 --- a/src/shacl2code/main.py +++ b/src/shacl2code/main.py @@ -51,7 +51,12 @@ def handle_generate(parser, args): data = json.load(f) contexts.append(ContextData(data, url)) - m = Model(graph, UrlContext(contexts), is_prerelease=args.pre_release) + m = Model( + graph, + UrlContext(contexts), + is_prerelease=args.pre_release, + jss_signature=args.jss_signature, + ) render = args.lang(args) render.output(m) @@ -125,6 +130,11 @@ def handle_version(parser, args): action=argparse.BooleanOptionalAction, help="Mark the generated binding as pre-release. Overrides any ontology annotations", ) + generate_parser.add_argument( + "--jss-signature", + metavar="NAME", + help="Allow ITU-T X.590 JSON Signature Schema (JSS) property with name 'NAME'. Recommended name is 'signatures'", + ) generate_parser.set_defaults(func=handle_generate) lang_subparser = generate_parser.add_subparsers( diff --git a/src/shacl2code/model.py b/src/shacl2code/model.py index d327af95..a4edfa59 100644 --- a/src/shacl2code/model.py +++ b/src/shacl2code/model.py @@ -117,13 +117,14 @@ class Class: class Model(object): - def __init__(self, graph, context=None, is_prerelease=None): + def __init__(self, graph, context=None, is_prerelease=None, jss_signature=None): self.model = graph self.context = context self.compact_ids = {} self.objects = {} self.classes = [] self.ontologies = [] + self.jss_signature = jss_signature class_iris = set() classes_by_iri = {} diff --git a/testfixtures/testfixtures/jsonvalidation.py b/testfixtures/testfixtures/jsonvalidation.py index 642737c1..4fc88049 100644 --- a/testfixtures/testfixtures/jsonvalidation.py +++ b/testfixtures/testfixtures/jsonvalidation.py @@ -732,6 +732,67 @@ def node_kind_tests(name, blank, iri): BASE_OBJ, id="Base object", ), + param( + True, + { + "@context": CONTEXT, + "@graph": [ + { + "@type": "test-class", + }, + ], + "signatures": [], + }, + id="JSS Signature", + ), + param( + False, + { + "@context": CONTEXT, + "@graph": [ + { + "@type": "test-class", + }, + ], + "unknown": {}, + }, + id="Unknown top level property with @graph", + ), + param( + True, + { + "@context": CONTEXT, + "@type": "test-class", + "signatures": [], + }, + id="Inline with signature", + ), + param( + False, + { + "@context": CONTEXT, + "@graph": [ + { + "@type": "test-class", + }, + ], + "signatures": "string", + }, + id="Signature with wrong type (scalar)", + ), + param( + False, + { + "@context": CONTEXT, + "@graph": [ + { + "@type": "test-class", + }, + ], + "signatures": {}, + }, + id="Signature with wrong type (object)", + ), ], ) diff --git a/tests/conftest.py b/tests/conftest.py index a80017b1..e9a94570 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,6 +54,8 @@ def test_jsonschema(model_server, test_context_url): MODEL_DIR / "test.ttl", "--context", test_context_url, + "--jss-signature", + "signatures", "jsonschema", "--output", "-", diff --git a/tests/test_cpp.py b/tests/test_cpp.py index 6e866bff..4f9ce916 100644 --- a/tests/test_cpp.py +++ b/tests/test_cpp.py @@ -106,6 +106,8 @@ def f(name, *, namespace=None): TEST_MODEL, "--context", model_server + "/test-context.json", + "--jss-signature", + "signatures", "cpp", "--output", out_basename, @@ -344,6 +346,10 @@ def f(code_fragment, **kwargs): ["--input", TEST_MODEL, "--context-url", TEST_CONTEXT, SPDX3_CONTEXT_URL], "test-context", ), + ( + ["--input", TEST_MODEL, "--jss-signature", "signatures"], + "test-signature", + ), ], ) diff --git a/tests/test_golang.py b/tests/test_golang.py index 08a183bc..416ffa93 100644 --- a/tests/test_golang.py +++ b/tests/test_golang.py @@ -44,6 +44,8 @@ def test_lib(tmp_path_factory, model_server): TEST_MODEL, "--context", model_server + "/test-context.json", + "--jss-signature", + "signatures", "golang", "--output", libdir, @@ -483,6 +485,7 @@ def link_test(test_lib, tmp_path_factory): [ ["--input", TEST_MODEL], ["--input", TEST_MODEL, "--context-url", TEST_CONTEXT, SPDX3_CONTEXT_URL], + ["--input", TEST_MODEL, "--jss-signature", "signatures"], ], ) diff --git a/tests/test_jsonschema.py b/tests/test_jsonschema.py index 62d3c569..3081616d 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -32,14 +32,17 @@ [ ["--input", TEST_MODEL], ["--input", TEST_MODEL, "--context-url", TEST_CONTEXT, SPDX3_CONTEXT_URL], + ["--input", TEST_MODEL, "--jss-signature", "signatures"], ], ) class TestOutput: - def test_output_syntax(self, args): + def test_output_syntax(self, args, tmp_path): """ Checks that the output file is valid json syntax by parsing it with Python """ - p = subprocess.run( + schema_file = tmp_path / "schema.json" + + subprocess.run( [ "shacl2code", "generate", @@ -48,14 +51,15 @@ def test_output_syntax(self, args): + [ "jsonschema", "--output", - "-", + schema_file, ], check=True, stdout=subprocess.PIPE, encoding="utf-8", ) - json.loads(p.stdout) + with schema_file.open("r") as f: + json.load(f) def test_ajv_compile(self, tmp_path, args): """ diff --git a/tests/test_python.py b/tests/test_python.py index d7e19fa9..5a421db1 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -80,6 +80,8 @@ def python_model(tmp_path_factory, model_context_url): TEST_MODEL, "--context", model_context_url, + "--jss-signature", + "signatures", ], [ "--version", @@ -154,6 +156,11 @@ def model(python_model): id="No main", ), pytest.param(["--input", TEST_MODEL], ["--version=1.0.0"], id="Version"), + pytest.param( + ["--input", TEST_MODEL, "--jss-signature", "signatures"], + [], + id="JSS Signature", + ), ], ) @@ -474,6 +481,73 @@ def test_jsonschema_validation(roundtrip, test_jsonschema): jsonschema.validate(data, schema=test_jsonschema) +# TODO: Make python bindings pass the other JSON validation tests +@pytest.mark.parametrize( + "passes,data", + [ + pytest.param( + True, + { + "@context": jsonvalidation.CONTEXT, + "@graph": [ + { + "@type": "test-class", + }, + ], + "signatures": [], + }, + id="JSS Signature", + ), + pytest.param( + False, + { + "@context": jsonvalidation.CONTEXT, + "@graph": [ + { + "@type": "test-class", + }, + ], + "unknown": {}, + }, + id="Unknown top level property with @graph", + ), + pytest.param( + True, + { + "@context": jsonvalidation.CONTEXT, + "@type": "test-class", + "signatures": [], + }, + id="Inline with signature", + ), + pytest.param( + False, + { + "@context": jsonvalidation.CONTEXT, + "@graph": [ + { + "@type": "test-class", + }, + ], + "signatures": "string", + }, + id="Signature with wrong type", + ), + ], +) +def test_json_validation(passes, data, tmp_path, test_context_url, model_script): + jsonvalidation.replace_context(data, test_context_url) + + data_file = tmp_path / "data.json" + data_file.write_text(json.dumps(data)) + + p = subprocess.run([model_script, data_file, "--outfile", os.devnull], check=False) + if passes: + assert p.returncode == 0 + else: + assert p.returncode != 0 + + @jsonvalidation.link_tests() def test_links(filename, name, expect_tag, model, tmp_path, test_context_url): data_file = tmp_path / "data.json" diff --git a/tests/test_rust.py b/tests/test_rust.py index 5de6c71a..32a6c253 100644 --- a/tests/test_rust.py +++ b/tests/test_rust.py @@ -69,6 +69,8 @@ def test_lib(tmp_path_factory, model_server, cargo_run): TEST_MODEL, "--context", model_server + "/test-context.json", + "--jss-signature", + "signatures", "rust", "--output", libdir / "shacl_model",