Skip to content

Commit f113d1f

Browse files
authored
Merge pull request crs4#181 from kikkomep/feat/unconditional-validation-abort
feat(validation): ✨ abort validation when the file descriptor is not valid JSON
2 parents 6d570f5 + 5eff111 commit f113d1f

7 files changed

Lines changed: 126 additions & 4 deletions

File tree

rocrate_validator/models/requirement.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import importlib
18+
import json
1819
from abc import ABC, abstractmethod
1920
from dataclasses import dataclass
2021
from functools import total_ordering
@@ -225,6 +226,11 @@ def _do_validate_(self, context: ValidationContext) -> bool:
225226
logger.debug("Skipping check '%s' because: %s", check.name, e)
226227
context.result._add_skipped_check(check)
227228
continue
229+
except json.JSONDecodeError as e:
230+
# The file descriptor is not valid JSON, so this check could not run.
231+
# This is a malformed-input problem (reported as an ad-hoc issue by the
232+
# dedicated "File Descriptor JSON format" check), not a validator bug.
233+
logger.debug("Skipping check %s: file descriptor is not valid JSON: %s", check, e)
228234
except Exception as e:
229235
if context.maybe_warn_offline_cache_miss(e):
230236
logger.debug("Offline cache miss during check %s: %s", check, e)
@@ -233,6 +239,9 @@ def _do_validate_(self, context: ValidationContext) -> bool:
233239
logger.warning("Consider reporting this as a bug.")
234240
if logger.isEnabledFor(logging.DEBUG):
235241
logger.exception("Unhandled exception during check execution", exc_info=e)
242+
# Stop running further checks once the metadata is known to be unusable.
243+
if context.aborted:
244+
break
236245
skipped_checks = set(self._checks) - set(checks_to_perform)
237246
context.result.skipped_checks.update(skipped_checks)
238247
logger.debug(

rocrate_validator/models/validation.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,12 @@ def __do_validate__(self, requirements: list[Requirement] | None = None) -> Vali
227227
logger.debug("Aborting on first requirement failure")
228228
terminate = True
229229
break
230+
# Unconditional abort: the metadata is unusable (e.g. not valid JSON),
231+
# so stop now instead of reporting false positives from later checks.
232+
if context.aborted:
233+
logger.debug("Aborting validation: %s", context.abort_reason)
234+
terminate = True
235+
break
230236
self.notify(ProfileValidationEvent(EventType.PROFILE_VALIDATION_END, profile=profile))
231237
if terminate:
232238
break
@@ -287,6 +293,10 @@ def __init__(self, validator: Validator, settings: ValidationSettings):
287293
self._properties: dict = {}
288294
# URLs already reported as missing from the HTTP cache during this run
289295
self._offline_cache_misses_warned: set[str] = set()
296+
# flag set when the validation must be aborted because the metadata
297+
# cannot be read (e.g. the file descriptor is not valid JSON)
298+
self._aborted: bool = False
299+
self._abort_reason: str | None = None
290300

291301
# initialize the ROCrate object
292302
if settings.metadata_dict:
@@ -426,6 +436,43 @@ def fail_fast(self) -> bool:
426436
"""
427437
return bool(self.settings.abort_on_first)
428438

439+
@property
440+
def aborted(self) -> bool:
441+
"""
442+
Whether the validation has been aborted because the metadata cannot be read.
443+
444+
Unlike :attr:`fail_fast`, this abort is unconditional: when the file descriptor
445+
cannot be parsed (e.g. it is not valid JSON) no metadata can be read, so any
446+
further check would only produce false positives.
447+
448+
:return: ``True`` if the validation has been aborted, ``False`` otherwise
449+
:rtype: bool
450+
"""
451+
return self._aborted
452+
453+
@property
454+
def abort_reason(self) -> str | None:
455+
"""
456+
The reason why the validation has been aborted, if any.
457+
"""
458+
return self._abort_reason
459+
460+
def abort_validation(self, reason: str | None = None) -> None:
461+
"""
462+
Signal that the validation cannot meaningfully continue because the metadata
463+
is unusable (e.g. the file descriptor is not valid JSON).
464+
465+
The check that detects the problem is expected to record its issue first and
466+
then call this method; the validation loop stops as soon as the current
467+
requirement completes, avoiding false positives from checks that depend on
468+
readable metadata.
469+
470+
:param reason: An optional human-readable description of the abort cause
471+
"""
472+
self._aborted = True
473+
self._abort_reason = reason
474+
logger.debug("Validation aborted: %s", reason)
475+
429476
@property
430477
def rel_fd_path(self) -> Path:
431478
"""

rocrate_validator/profiles/ro-crate/1.1/must/0_file_descriptor_format.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
# pylint: disable=invalid-name # profile filename uses digit prefix (load-order convention)
1616

17+
import json
1718
import re
1819
from typing import Any
1920
from urllib.parse import urljoin
@@ -77,6 +78,17 @@ def check(self, context: ValidationContext) -> bool:
7778
logger.debug("Checking validity of JSON file at %s", context.ro_crate.metadata)
7879
context.ro_crate.metadata.as_dict()
7980
return True
81+
except json.JSONDecodeError as e:
82+
context.result.add_issue(
83+
f'RO-Crate file descriptor "{context.rel_fd_path}" is not valid JSON: '
84+
f"{e.msg} at line {e.lineno}, column {e.colno} (char {e.pos})",
85+
self,
86+
)
87+
if logger.isEnabledFor(logging.DEBUG):
88+
logger.debug("RO-Crate file descriptor is not valid JSON", exc_info=True)
89+
# The metadata cannot be parsed: abort to avoid false positives downstream.
90+
context.abort_validation(f"file descriptor is not valid JSON: {e}")
91+
return False
8092
except Exception:
8193
context.result.add_issue(
8294
f'RO-Crate file descriptor "{context.rel_fd_path}" is not in the correct format', self

rocrate_validator/profiles/ro-crate/1.2/must/0_file_descriptor_format.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import json
1516
import re
1617
from http import HTTPStatus
1718
from pathlib import Path
@@ -128,6 +129,17 @@ def check(self, context: ValidationContext) -> bool:
128129
logger.debug("Checking validity of JSON file at %s", context.ro_crate.metadata)
129130
context.ro_crate.metadata.as_dict()
130131
return True
132+
except json.JSONDecodeError as e:
133+
context.result.add_issue(
134+
f'RO-Crate file descriptor "{context.rel_fd_path}" is not valid JSON: '
135+
f"{e.msg} at line {e.lineno}, column {e.colno} (char {e.pos})",
136+
self,
137+
)
138+
if logger.isEnabledFor(logging.DEBUG):
139+
logger.debug("RO-Crate file descriptor is not valid JSON", exc_info=True)
140+
# The metadata cannot be parsed: abort to avoid false positives downstream.
141+
context.abort_validation(f"file descriptor is not valid JSON: {e}")
142+
return False
131143
except Exception:
132144
context.result.add_issue(
133145
f'RO-Crate file descriptor "{context.rel_fd_path}" is not in the correct format', self

rocrate_validator/requirements/shacl/requirements.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ def finalize(cls, context: ValidationContext) -> None:
101101

102102
logger.debug("Starting %s requirement finalization for context %s", cls.__name__, context)
103103

104+
# If the validation was aborted (e.g. the metadata is not valid JSON), the data
105+
# graph cannot be parsed: skip the forced SHACL run to avoid false positives.
106+
if context.aborted:
107+
logger.debug("Skipping forced SHACL run: validation aborted (%s)", context.abort_reason)
108+
return
109+
104110
# extract profiles and target profile from context
105111
profiles = context.profiles
106112

tests/integration/profiles/ro-crate-1.2/test_metadata_document.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def test_not_json():
4949
False,
5050
profile_identifier="ro-crate-1.2",
5151
expected_triggered_requirements=["File Descriptor JSON format"],
52-
expected_triggered_issues=['RO-Crate file descriptor "ro-crate-metadata.json" is not in the correct format'],
52+
expected_triggered_issues=['RO-Crate file descriptor "ro-crate-metadata.json" is not valid JSON'],
5353
)
5454

5555

tests/integration/profiles/ro-crate/test_file_descriptor_format.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import logging
1616

17-
from rocrate_validator import models
17+
from rocrate_validator import models, services
1818
from tests.ro_crates import InvalidFileDescriptor, ValidROC
1919
from tests.shared import do_entity_test
2020

@@ -32,8 +32,44 @@ def test_missing_file_descriptor():
3232

3333

3434
def test_not_valid_json_format():
35-
"""Test a RO-Crate with an invalid JSON file descriptor format."""
36-
do_entity_test(paths.invalid_json_format, models.Severity.REQUIRED, False, ["File Descriptor JSON format"], [])
35+
"""
36+
Test a RO-Crate with an invalid JSON file descriptor format.
37+
38+
The validator must emit an ad-hoc issue reporting that the file descriptor is
39+
not valid JSON, including the position of the parsing error.
40+
"""
41+
do_entity_test(
42+
paths.invalid_json_format,
43+
models.Severity.REQUIRED,
44+
False,
45+
["File Descriptor JSON format"],
46+
['RO-Crate file descriptor "ro-crate-metadata.json" is not valid JSON'],
47+
)
48+
49+
50+
def test_not_valid_json_format_aborts_validation():
51+
"""
52+
An unparsable JSON file descriptor must abort the validation (fail-fast).
53+
54+
Since the metadata cannot be read, no further check can run meaningfully, so the
55+
only reported failure must be the JSON-format one (no false positives).
56+
"""
57+
result = services.validate(
58+
models.ValidationSettings(
59+
rocrate_uri=models.URI(paths.invalid_json_format),
60+
requirement_severity=models.Severity.REQUIRED,
61+
)
62+
)
63+
assert not result.passed(), "An invalid-JSON crate must not pass validation"
64+
65+
failed_requirements = [r.name for r in result.failed_requirements]
66+
assert failed_requirements == ["File Descriptor JSON format"], (
67+
f"Only the JSON-format requirement should fail, got: {failed_requirements}"
68+
)
69+
70+
issues = [i.message for i in result.get_issues(models.Severity.REQUIRED) if i.message]
71+
assert len(issues) == 1, f"Exactly one issue expected, got: {issues}"
72+
assert "is not valid JSON" in issues[0]
3773

3874

3975
def test_not_valid_jsonld_format_missing_context():

0 commit comments

Comments
 (0)