jep-cli 0.7.1__tar.gz → 0.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  # Notice
2
2
 
3
- This repository contains an experimental CLI seed for the JEP v0.6 API.
3
+ This repository contains an experimental CLI seed for the JEP reference API. Supported protocol revisions are documented in README.md.
4
4
 
5
5
  Internet-Draft text and excerpts are governed by the IETF Trust Legal Provisions and BCP 78 / BCP 79.
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jep-cli
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Command-line client for JEP Core 0.7 with explicit legacy compatibility
5
5
  Author-email: HJS Spec <signal@humanjudgment.org>
6
6
  License: MIT
@@ -15,6 +15,7 @@ Requires-Python: >=3.9
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  License-File: NOTICE.md
18
+ Requires-Dist: rfc8785==0.1.4
18
19
  Provides-Extra: dev
19
20
  Requires-Dist: pytest>=7.0.0; extra == "dev"
20
21
  Dynamic: license-file
@@ -0,0 +1,3 @@
1
+ """JEP Core 0.7 CLI."""
2
+
3
+ __version__ = "0.7.2"
@@ -6,6 +6,8 @@ import json
6
6
  from typing import Any, Dict, Optional
7
7
  from urllib import request, error
8
8
 
9
+ from . import strict_json
10
+
9
11
 
10
12
  class JEPCLIError(Exception):
11
13
  pass
@@ -44,19 +46,19 @@ class JEPClient:
44
46
  data = None
45
47
  headers = {
46
48
  "content-type": "application/json",
47
- "user-agent": "JEP-CLI/0.7.1",
49
+ "user-agent": "JEP-CLI/0.7.2",
48
50
  }
49
51
  if self.api_key:
50
52
  headers["authorization"] = f"Bearer {self.api_key}"
51
53
  headers["x-api-key"] = self.api_key
52
54
  if payload is not None:
53
- data = json.dumps(payload).encode("utf-8")
55
+ data = strict_json.dumps(payload).encode("utf-8")
54
56
 
55
57
  req = request.Request(self.base_url + path, data=data, headers=headers, method=method)
56
58
  try:
57
59
  with request.urlopen(req, timeout=self.timeout) as resp:
58
60
  body = resp.read().decode("utf-8")
59
- return json.loads(body) if body else {}
61
+ return strict_json.loads(body) if body else {}
60
62
  except error.HTTPError as exc:
61
63
  body = exc.read().decode("utf-8")
62
64
  try:
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
  from typing import Any, Dict
11
11
 
12
12
  from .client import JEPClient, JEPCLIError
13
+ from . import strict_json
13
14
 
14
15
 
15
16
  VALID_VERBS = {"J", "D", "T", "V"}
@@ -17,12 +18,16 @@ VALID_VERBS = {"J", "D", "T", "V"}
17
18
 
18
19
  def parse_json_value(value: str) -> Any:
19
20
  if value == "-":
20
- return json.load(sys.stdin)
21
+ # Read bytes when available: a text stream may replace malformed UTF-8.
22
+ stream = getattr(sys.stdin, "buffer", sys.stdin)
23
+ return strict_json.loads(stream.read())
21
24
 
22
25
  try:
23
- return json.loads(value)
26
+ return strict_json.loads(value)
24
27
  except json.JSONDecodeError:
25
- pass
28
+ # JSON-looking input must not become a plain string after a parse error.
29
+ if value.lstrip().startswith(("{", "[", '"')):
30
+ raise
26
31
  # Inline event JSON may be longer than the filesystem filename limit.
27
32
  path = Path(value)
28
33
  try:
@@ -30,7 +35,8 @@ def parse_json_value(value: str) -> Any:
30
35
  except OSError:
31
36
  is_file = False
32
37
  if is_file:
33
- return json.loads(path.read_text(encoding="utf-8"))
38
+ return strict_json.loads(path.read_bytes())
39
+ strict_json.validate(value)
34
40
  return value
35
41
 
36
42
 
@@ -40,12 +46,15 @@ def parse_ext_items(items: list[str]) -> Dict[str, Any]:
40
46
  if "=" not in item:
41
47
  raise ValueError(f"extension must be key=json_or_string, got: {item}")
42
48
  key, raw = item.split("=", 1)
49
+ if key in ext:
50
+ raise ValueError(f"duplicate extension: {key!r}")
51
+ strict_json.validate(key)
43
52
  ext[key] = parse_json_value(raw)
44
53
  return ext
45
54
 
46
55
 
47
56
  def print_json(data: Any) -> None:
48
- print(json.dumps(data, indent=2, ensure_ascii=False))
57
+ print(strict_json.dumps(data))
49
58
 
50
59
 
51
60
  def build_client(args: argparse.Namespace) -> JEPClient:
@@ -0,0 +1,78 @@
1
+ """Strict JSON input boundary; signature/profile validation stays with the API."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import math
6
+ from typing import Any
7
+
8
+ import rfc8785
9
+
10
+
11
+ def _pairs(pairs):
12
+ result = {}
13
+ for key, value in pairs:
14
+ if key in result:
15
+ raise ValueError(f"duplicate JSON member: {key!r}")
16
+ result[key] = value
17
+ return result
18
+
19
+
20
+ def _constant(token):
21
+ raise ValueError(f"non-JSON numeric constant: {token}")
22
+
23
+
24
+ def validate(value: Any) -> None:
25
+ """Reject lossy/invalid input without rewriting values or signed members.
26
+
27
+ Large integer policy matches Core 0.7.4: exact binary64 integers and their
28
+ JCS shortest-decimal spellings are supported. This is not signature checking.
29
+ """
30
+ if value is None or isinstance(value, bool):
31
+ return
32
+ if isinstance(value, str):
33
+ value.encode("utf-8", errors="strict")
34
+ elif isinstance(value, int):
35
+ if abs(value) > 2**53 - 1:
36
+ try:
37
+ number = float(value)
38
+ if not math.isfinite(number):
39
+ raise ValueError("integer exceeds binary64 range")
40
+ if int(number) != value and rfc8785.dumps(number) != str(value).encode("ascii"):
41
+ raise ValueError("integer is neither exact binary64 nor its JCS spelling")
42
+ except OverflowError as exc:
43
+ raise ValueError("integer exceeds binary64 range") from exc
44
+ elif isinstance(value, float):
45
+ if not math.isfinite(value):
46
+ raise ValueError("non-finite number")
47
+ elif isinstance(value, list):
48
+ for item in value:
49
+ validate(item)
50
+ elif isinstance(value, dict):
51
+ for key, item in value.items():
52
+ if not isinstance(key, str):
53
+ raise ValueError("JSON member names must be strings")
54
+ validate(key)
55
+ validate(item)
56
+ else:
57
+ raise ValueError("unsupported JSON value")
58
+
59
+
60
+ def loads(text: str | bytes) -> Any:
61
+ if isinstance(text, bytes):
62
+ text = text.decode("utf-8", errors="strict")
63
+ # Reject raw surrogate code points even before JSON parsing.
64
+ text.encode("utf-8", errors="strict")
65
+ try:
66
+ value = json.loads(text, object_pairs_hook=_pairs, parse_constant=_constant)
67
+ validate(value)
68
+ return value
69
+ except RecursionError as exc:
70
+ raise ValueError("JSON nesting exceeds parser capacity") from exc
71
+
72
+
73
+ def dumps(value: Any) -> str:
74
+ try:
75
+ validate(value)
76
+ return json.dumps(value, ensure_ascii=False, allow_nan=False)
77
+ except RecursionError as exc:
78
+ raise ValueError("JSON nesting exceeds parser capacity") from exc
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jep-cli
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Command-line client for JEP Core 0.7 with explicit legacy compatibility
5
5
  Author-email: HJS Spec <signal@humanjudgment.org>
6
6
  License: MIT
@@ -15,6 +15,7 @@ Requires-Python: >=3.9
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  License-File: NOTICE.md
18
+ Requires-Dist: rfc8785==0.1.4
18
19
  Provides-Extra: dev
19
20
  Requires-Dist: pytest>=7.0.0; extra == "dev"
20
21
  Dynamic: license-file
@@ -5,6 +5,7 @@ pyproject.toml
5
5
  jep_cli/__init__.py
6
6
  jep_cli/client.py
7
7
  jep_cli/main.py
8
+ jep_cli/strict_json.py
8
9
  jep_cli.egg-info/PKG-INFO
9
10
  jep_cli.egg-info/SOURCES.txt
10
11
  jep_cli.egg-info/dependency_links.txt
@@ -12,4 +13,5 @@ jep_cli.egg-info/entry_points.txt
12
13
  jep_cli.egg-info/requires.txt
13
14
  jep_cli.egg-info/top_level.txt
14
15
  tests/test_acceptance.py
15
- tests/test_cli.py
16
+ tests/test_cli.py
17
+ tests/test_strict_json.py
@@ -1,3 +1,4 @@
1
+ rfc8785==0.1.4
1
2
 
2
3
  [dev]
3
4
  pytest>=7.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "jep-cli"
7
- version = "0.7.1"
7
+ version = "0.7.2"
8
8
  description = "Command-line client for JEP Core 0.7 with explicit legacy compatibility"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -12,7 +12,7 @@ authors = [
12
12
  { name = "HJS Spec", email = "signal@humanjudgment.org" }
13
13
  ]
14
14
  requires-python = ">=3.9"
15
- dependencies = []
15
+ dependencies = ["rfc8785==0.1.4"]
16
16
  classifiers = [
17
17
  "Development Status :: 3 - Alpha",
18
18
  "Intended Audience :: Developers",
@@ -0,0 +1,87 @@
1
+ import io
2
+ import json
3
+
4
+ import pytest
5
+
6
+ from jep_cli import main as cli
7
+ from jep_cli import strict_json
8
+
9
+ BAD = [
10
+ '{"x":1,"x":2}',
11
+ '{"x":{"a":1,"\\u0061":2}}',
12
+ '{"x":NaN}', '{"x":Infinity}', '{"x":-Infinity}',
13
+ '{"x":1e400}', '{"x":9007199254740993}',
14
+ '{"x":"\\ud800"}', '{"\\udfff":1}',
15
+ '{"x":1,}', '{"x":1} {}',
16
+ ]
17
+
18
+
19
+ @pytest.mark.parametrize('raw', BAD)
20
+ @pytest.mark.parametrize('source', ['inline', 'file', 'stdin'])
21
+ def test_all_input_sources_reject(raw, source, tmp_path, monkeypatch):
22
+ argument = raw
23
+ if source == 'file':
24
+ path = tmp_path / 'event.json'
25
+ path.write_bytes(raw.encode('utf-8'))
26
+ argument = str(path)
27
+ elif source == 'stdin':
28
+ monkeypatch.setattr(cli.sys, 'stdin', io.StringIO(raw))
29
+ argument = '-'
30
+ with pytest.raises(ValueError):
31
+ cli.parse_json_value(argument)
32
+
33
+
34
+ @pytest.mark.parametrize('source', ['file', 'stdin'])
35
+ def test_bad_utf8_rejected_before_text_replacement(source, tmp_path, monkeypatch):
36
+ raw = b'{"x":"\xff"}'
37
+ if source == 'file':
38
+ path = tmp_path / 'event.json'
39
+ path.write_bytes(raw)
40
+ argument = str(path)
41
+ else:
42
+ stream = io.TextIOWrapper(io.BytesIO(raw), encoding='utf-8', errors='replace')
43
+ monkeypatch.setattr(cli.sys, 'stdin', stream)
44
+ argument = '-'
45
+ with pytest.raises(ValueError):
46
+ cli.parse_json_value(argument)
47
+
48
+
49
+ @pytest.mark.parametrize('raw', [
50
+ '{"x":1000000000000000100}', '{"x":1000000000000000128}',
51
+ '{"x":-1000000000000000100}', '{"x":100000000000000000000}',
52
+ '{"x":"\\ud83d\\ude00","ext":{},"ext_crit":[],"sig":"keep..me"}',
53
+ '{"x":"\\\\ud800","__proto__":{"ok":true}}',
54
+ ])
55
+ def test_supported_values_preserve_members(raw):
56
+ value = strict_json.loads(raw)
57
+ assert value == json.loads(raw)
58
+ assert strict_json.loads(strict_json.dumps(value)) == value
59
+
60
+
61
+ def test_duplicate_extension_is_not_silently_overwritten():
62
+ with pytest.raises(ValueError):
63
+ cli.parse_ext_items(['urn:test=1', 'urn:test=2'])
64
+
65
+
66
+ @pytest.mark.parametrize('legacy', [False, True])
67
+ def test_rejection_happens_before_network_and_never_falls_back(monkeypatch, legacy):
68
+ def no_client(*args):
69
+ pytest.fail('invalid input reached the client')
70
+ monkeypatch.setattr(cli, 'build_client', no_client)
71
+ args = ['verify', '{"who":"a","who":"b"}']
72
+ if legacy:
73
+ args.append('--legacy')
74
+ assert cli.main(args) == 1
75
+
76
+
77
+ def test_outgoing_values_are_checked():
78
+ with pytest.raises(ValueError):
79
+ strict_json.dumps({'x': float('nan')})
80
+ with pytest.raises(ValueError):
81
+ strict_json.dumps({'x': '\ud800'})
82
+
83
+
84
+ def test_plain_text_and_long_inline_json_still_work():
85
+ assert cli.parse_json_value('approve') == 'approve'
86
+ raw = json.dumps({'x': 'a' * 10000})
87
+ assert cli.parse_json_value(raw) == json.loads(raw)
@@ -1,3 +0,0 @@
1
- """JEP CLI v0.6."""
2
-
3
- __version__ = "0.7.1"
File without changes
File without changes
File without changes
File without changes