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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,8 @@
`trustRegistry`.
- `show`: HTML for Jupyter (`_repr_html_`) or Marimo (`mo.Html`) from a record and its
`verify --json` result.
- `badge_cell` no longer reads a URL's host and port as a time: `https://192.168.1.10:8080/…` is
accepted. A date or time in the URL's path, query or fragment (as written or percent-encoded) or
in a fact is refused.
- README: only the calls that run the CLI need Node (the five commands, `cli_version()`, and
`show` without a result); its links are absolute, so they resolve on the PyPI page.
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ The wrapper looks for Node in this order:
1. `TYPEDSTANDARDS_NODE`, when set: the path (or name on `PATH`) of a Node binary;
2. `node` on `PATH`.

With no Node, or one older than 20.19.0, every call raises `typedstandards.NodeLocatorError`, whose
message names the floor and `TYPEDSTANDARDS_NODE`.
Only the calls that run the CLI need Node: `sign`, `withdraw`, `attest`, `view`, `verify`,
`cli_version()`, and `show` without a precomputed result. With no Node, or one older than 20.19.0,
each of those raises `typedstandards.NodeLocatorError`, whose message names the floor and
`TYPEDSTANDARDS_NODE`. `pin`, `badge_cell`, `comparison_cell`, `sidecar` and
`show(record, result)` run without Node.

Linux and macOS are tested. Windows is untested.

Expand Down Expand Up @@ -162,7 +165,8 @@ ts.show(bundle) # in Jupyter; ts.show(bundle, marimo=True) in Marimo
`typedstandards-badge` on nbformat 4.5). With `marimo=True`, it returns the source of a
`mo.md(...)` cell to paste into the app. The cell is written before signing and is part of the
signed bytes, so it names no hash and no time; a URL or value holding a 64-hex string, a date or
a time is refused.
a time is refused. The URL's host and port are not read as a time (`192.168.1.10:8080` is
accepted).
- **`comparison_cell(notebook, values, *, recompute, captured_at)`** appends the comparison cell
of spec §8.7.4 as the last cell (id `typedstandards-comparison`): the values as Python literals,
`current = <recompute>`, and a loop that prints each delta. Values are `None`, `bool`, `int`,
Expand Down Expand Up @@ -195,7 +199,7 @@ that moves the pin.

## Development

See [CLAUDE.md](CLAUDE.md) for the development loop and the checks CI runs.
See [CLAUDE.md](https://github.com/npstorey/typedstandards-python/blob/main/CLAUDE.md) for the development loop and the checks CI runs.

## License

Expand Down
22 changes: 17 additions & 5 deletions src/typedstandards/_badge.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import os
import re
from urllib.parse import quote, urlsplit
from urllib.parse import quote, unquote, urlsplit

from ._notebook import insert_cell, source_lines

Expand Down Expand Up @@ -71,25 +71,33 @@ def _check_fact(name: str, value: str) -> str:
return value.strip()


def refuse_hash_or_time(text: str) -> str:
def refuse_hash_or_time(text: str, *, authorities: tuple[str, ...] = ()) -> str:
"""Raise ``ValueError`` when ``text`` holds a 64-hex string, a date or a time of day.

The badge cell is part of the signed bytes, written before signing: a hash in it cannot be
the record's own, and a time in it cannot be the signing time, so either would mislead.
Each of ``authorities`` (a URL's host and port, as the text carries them) is left out of the
date and time check, so ``192.168.1.10:8080`` is not read as the time ``10:80``.
"""
if _HEX64.search(text):
raise ValueError(
"the badge cell would carry a 64-hex string; it is written before signing, so it names no hash"
)
if _DATE_OR_TIME.search(text):
scan = text
for authority in authorities:
if authority:
scan = scan.replace(authority, " ")
if _DATE_OR_TIME.search(scan):
raise ValueError("the badge cell would carry a date or time; it is written before signing, so it names no time")
return text


def badge_text(bundle_url: str, *, capture_method: str, host: str | None = None) -> str:
"""The badge cell's Markdown: the badge, a two-row table (host, capture method), one sentence."""
url = bundle_url.strip()
parts = urlsplit(url)
if host is None:
host = urlsplit(bundle_url.strip()).netloc
host = parts.netloc
host = _check_fact("host", host)
capture_method = _check_fact("capture_method", capture_method)
text = (
Expand All @@ -103,7 +111,11 @@ def badge_text(bundle_url: str, *, capture_method: str, host: str | None = None)
"This cell is a reader affordance and is not authoritative: verification reads the signed record, "
"not this cell, and the verifier shows the record's signer, hash and time.\n"
)
return refuse_hash_or_time(text)
# The cell carries the URL percent-encoded, which hides a time's ":"; so the URL past its
# authority (path, query, fragment) is checked as written and decoded too.
rest = url[len(parts.scheme) + 3 + len(parts.netloc) :]
refuse_hash_or_time(f"{rest}\n{unquote(rest)}")
return refuse_hash_or_time(text, authorities=(f"`{host}`", encode_uri_component(parts.netloc)))


def _marimo_source(markdown: str) -> str:
Expand Down
57 changes: 45 additions & 12 deletions tests/guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
- It passes the child process no environment but the inherited one: no ``env=`` on any call,
and nothing that sets, unsets or replaces a variable for a child.
- It computes none of the format's hashes: no module imports ``hashlib`` (or the modules behind
it), except the allowlisted P2 ``pin`` module, whose digest is a signed assertion.
it) or reaches one another way (a re-import from ``pin``, ``pin.hashlib``, ``sys.modules``, a
built import name), except the allowlisted ``pin`` module, whose digest is a signed assertion.

Each scanner takes a directory and returns one line per offence, so a test can drive it over the
package and over a fixture tree of offenders.
Expand Down Expand Up @@ -119,25 +120,57 @@ def env_overrides(root: Path) -> list[str]:
return found


def _is_sys_modules(node: ast.AST) -> bool:
return (isinstance(node, ast.Attribute) and node.attr == "modules") or (
isinstance(node, ast.Name) and node.id == "modules"
)


def _digest_name(value: object) -> bool:
return isinstance(value, str) and value.split(".")[0] in HASH_MODULES


def hash_imports(root: Path, allow: frozenset[str] = HASH_ALLOWLIST) -> list[str]:
"""Every import of a digest module outside the allowlist, static or dynamic."""
"""Every route to a digest module outside the allowlist: an import of one (static, or dynamic
with a literal name); any reference to one's name (a name, an attribute such as
``pin.hashlib``, an imported alias such as ``from .pin import hashlib``, a string naming it);
a ``sys.modules`` lookup that names one or is not a literal; and ``import_module`` or
``__import__`` with a name that is not a literal, since it can build any name."""
found = []
for path in python_files(root):
where = path.relative_to(root)
if where.as_posix() in allow:
continue
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
modules: list[str] = []
line = getattr(node, "lineno", 0)
if isinstance(node, ast.Import):
modules = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom) and node.module:
modules = [node.module]
for alias in node.names:
if _digest_name(alias.name):
found.append(f"{where}:{line}: imports {alias.name}")
elif isinstance(node, ast.ImportFrom):
if _digest_name(node.module or ""):
found.append(f"{where}:{line}: imports {node.module}")
for alias in node.names:
if _digest_name(alias.name):
found.append(f"{where}:{line}: imports the name {alias.name} from {node.module or '.'}")
elif isinstance(node, ast.Call) and _call_name(node) in {"import_module", "__import__"} and node.args:
first = node.args[0]
if isinstance(first, ast.Constant) and isinstance(first.value, str):
modules = [first.value]
for module in modules:
if module.split(".")[0] in HASH_MODULES:
found.append(f"{where}:{node.lineno}: imports {module}")
return found
if not (isinstance(first, ast.Constant) and isinstance(first.value, str)):
found.append(f"{where}:{line}: {_call_name(node)} with a name that is not a literal")
elif _digest_name(first.value):
found.append(f"{where}:{line}: imports {first.value}")
elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "get":
if _is_sys_modules(node.func.value):
found.append(f"{where}:{line}: looks up sys.modules")
elif isinstance(node, ast.Subscript) and _is_sys_modules(node.value):
key = node.slice
if not (isinstance(key, ast.Constant) and isinstance(key.value, str)) or _digest_name(key.value):
found.append(f"{where}:{line}: looks up sys.modules")
elif isinstance(node, ast.Name) and _digest_name(node.id):
found.append(f"{where}:{line}: refers to {node.id}")
elif isinstance(node, ast.Attribute) and _digest_name(node.attr):
found.append(f"{where}:{line}: refers to .{node.attr}")
elif isinstance(node, ast.Constant) and isinstance(node.value, str) and node.value in HASH_MODULES:
found.append(f"{where}:{line}: names {node.value}")
return sorted(set(found))
31 changes: 31 additions & 0 deletions tests/test_badge.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,37 @@ def test_a_url_carrying_a_hash_or_date_is_refused(tmp_path: Path, url: str) -> N
assert _read(path) == original


@pytest.mark.parametrize(
"url",
[
"https://192.168.1.10:8080/bundles/a.bundle.json",
"https://records.example.org:8443/a.bundle.json",
"http://127.0.0.1:12345/bundles/a.bundle.json",
],
)
def test_a_host_with_a_port_is_not_read_as_a_time(tmp_path: Path, url: str) -> None:
path = _write(tmp_path, synthetic_notebook())
text = badge_cell(url, capture_method="script-run", notebook=path)
assert text.splitlines()[0] == badge_markdown(url)
assert json.loads(_read(path))["cells"][0]["id"] == "typedstandards-badge"


@pytest.mark.parametrize(
("url", "capture_method"),
[
("https://192.168.1.10:8080/2026-10-04/a.bundle.json", "script-run"),
("https://records.example.org:8443/a.bundle.json?t=12:30", "script-run"),
("https://records.example.org/a.bundle.json?t=12%3A30%3A45", "script-run"),
("https://records.example.org/a.bundle.json#T12:30", "script-run"),
("https://192.168.1.10:8080/a.bundle.json", "run-T12:30:45"),
],
ids=["date-in-path", "time-in-query", "encoded-time-in-query", "time-in-fragment", "time-in-fact"],
)
def test_a_date_or_time_beside_a_port_is_still_refused(url: str, capture_method: str) -> None:
with pytest.raises(ValueError, match="date or time"):
badge_cell(url, capture_method=capture_method)


def test_a_second_badge_is_refused(tmp_path: Path) -> None:
path = _write(tmp_path, synthetic_notebook())
badge_cell(BUNDLE_URL, capture_method="script-run", notebook=path)
Expand Down
70 changes: 69 additions & 1 deletion tests/test_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ def test_package_runs_no_global_cli() -> None:
"hash_dynamic.py": "import importlib\nh = importlib.import_module('hashlib')\n",
"hash_hmac.py": "import hmac\n",
"pin.py": "import hashlib\n",
# Re-import routes (the cold read's four), each outside the allowlist.
"reimport_from_pin.py": "from .pin import hashlib\n",
"pin_attribute.py": "from . import pin\ndigest = pin.hashlib.sha256(b'x')\n",
"sys_modules.py": "import sys\ndigest = sys.modules['hashlib'].sha256(b'x')\n",
"dynamic_name.py": "import importlib\nmodule = importlib.import_module('hash' + 'lib')\n",
}


Expand Down Expand Up @@ -100,7 +105,16 @@ def test_env_scanner_fails_on_offenders(offenders: Path) -> None:


def test_hash_scanner_fails_on_offenders_and_allows_only_pin(offenders: Path) -> None:
assert _files(hash_imports(offenders)) == {"hash_import.py", "hash_from.py", "hash_dynamic.py", "hash_hmac.py"}
assert _files(hash_imports(offenders)) == {
"hash_import.py",
"hash_from.py",
"hash_dynamic.py",
"hash_hmac.py",
"reimport_from_pin.py",
"pin_attribute.py",
"sys_modules.py",
"dynamic_name.py",
}
assert "pin.py" in _files(hash_imports(offenders, allow=frozenset()))


Expand All @@ -125,6 +139,60 @@ def _drive_every_command() -> None:
typedstandards.cli_version()


DIGEST_CONSTRUCTORS = (
"new", "md5", "sha1", "sha224", "sha256", "sha384", "sha512", "sha3_224", "sha3_256", "sha3_384",
"sha3_512", "shake_128", "shake_256", "blake2b", "blake2s",
) # fmt: skip


def test_no_module_but_pin_computes_a_digest_at_run_time(
seed: str, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""While the five commands and every helper run, record each hashlib constructor call by the
module of the frame that made it. Only typedstandards.pin may appear; third-party libraries
(httpx, yaml) hashing internally are not typedstandards frames and are not counted."""
import hashlib
import sys

import httpx
from support import analysis_input, synthetic_notebook

callers: list[str] = []

def recording(name: str, real: Any) -> Any:
def call(*args: Any, **kwargs: Any) -> Any:
caller = sys._getframe(1).f_globals.get("__name__", "")
if caller == "typedstandards" or caller.startswith("typedstandards."):
callers.append(f"{caller} called hashlib.{name}")
return real(*args, **kwargs)

return call

for name in DIGEST_CONSTRUCTORS:
monkeypatch.setattr(hashlib, name, recording(name, getattr(hashlib, name)))

_drive_every_command()
notebook = tmp_path / "analysis.ipynb"
notebook.write_text(synthetic_notebook(), encoding="utf-8")
typedstandards.badge_cell(
"https://records.example.org/bundles/analysis.bundle.json", capture_method="script-run", notebook=notebook
)
typedstandards.comparison_cell(notebook, {"rows": 1}, recompute="recompute()", captured_at="2026-10-04T00:00:00Z")
_, entry = typedstandards.pin(
"https://files.example.org/data.csv", transport=httpx.MockTransport(lambda r: httpx.Response(200, content=b"x"))
)
signed = typedstandards.sign(analysis_input(queries=[entry]), output_file=notebook)
bundle = typedstandards.view(signed, visibility="public")
typedstandards.sidecar(bundle, notebook)
typedstandards.show(bundle)
typedstandards.show(bundle, typedstandards.verify(bundle), marimo=True)

assert "typedstandards.pin called hashlib.sha256" in callers # the recorder sees pin's one digest
others = sorted({c for c in callers if not c.startswith("typedstandards.pin ")})
if others:
pytest.fail(f"a module other than pin computed a digest: {others}")


def test_every_child_inherits_the_environment(seed: str, spawned: list[dict[str, Any]]) -> None:
before = dict(os.environ)
_drive_every_command()
Expand Down
71 changes: 71 additions & 0 deletions tests/test_readme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""The README is the wheel's long description, shown on PyPI, where a relative link does not
resolve. Every Markdown link and image in it is absolute (or an in-page ``#anchor``).

It also pins the README's Node sentence: only the five commands, ``cli_version()`` and ``show``
without a result run the CLI and so need Node; the other helpers run without one.
"""

from __future__ import annotations

import json
import re
from pathlib import Path

import httpx
import pytest
from support import FIXTURES, synthetic_notebook

import typedstandards

README = Path(__file__).parent.parent / "README.md"

#: An inline link or image destination: ``](dest)`` or ``](<dest>)``; and a reference definition.
_INLINE = re.compile(r"\]\(\s*(<[^>]*>|[^)\s]+)")
_REFERENCE = re.compile(r"^\s{0,3}\[[^\]]+\]:\s*(\S+)", re.MULTILINE)
_ABSOLUTE = re.compile(r"(https?://|mailto:|#)")


def relative_links(markdown: str) -> list[str]:
"""Every link or image destination outside fenced code that is neither absolute nor an anchor."""
prose = re.sub(r"^```.*?^```", "", markdown, flags=re.MULTILINE | re.DOTALL)
found = [m.strip("<>") for m in _INLINE.findall(prose)] + _REFERENCE.findall(prose)
return [dest for dest in found if not _ABSOLUTE.match(dest)]


def test_the_checker_finds_relative_links() -> None:
text = "See [a](CLAUDE.md), ![i](docs/x.png), [b](<rel path.md>), [c](#use), [d](https://x.org/y).\n\n[e]: e.md\n"
assert relative_links(text) == ["CLAUDE.md", "docs/x.png", "rel path.md", "e.md"]


def test_readme_has_no_relative_link() -> None:
assert relative_links(README.read_text(encoding="utf-8")) == []


def test_the_helpers_need_no_node(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv(typedstandards.NODE_OVERRIDE, str(tmp_path / "no-node-here"))
with pytest.raises(typedstandards.NodeLocatorError):
typedstandards.locate_node()

notebook = tmp_path / "analysis.ipynb"
notebook.write_text(synthetic_notebook(), encoding="utf-8")
typedstandards.badge_cell(
"https://records.example.org/bundles/analysis.bundle.json", capture_method="script-run", notebook=notebook
)
typedstandards.comparison_cell(notebook, {"rows": 1}, recompute="recompute()", captured_at="2026-10-04T00:00:00Z")
typedstandards.pin(
"https://files.example.org/data.csv", transport=httpx.MockTransport(lambda r: httpx.Response(200, content=b"x"))
)
bundle = json.loads((FIXTURES / "record-active.bundle.json").read_text(encoding="utf-8"))
result = json.loads((FIXTURES / "record-active.verify.json").read_text(encoding="utf-8"))
typedstandards.sidecar(bundle, notebook)
typedstandards.show(bundle, result)

# The calls that run the CLI raise it, naming the floor and the override.
for call in (
lambda: typedstandards.show(bundle),
lambda: typedstandards.verify(bundle),
lambda: typedstandards.view(FIXTURES / "record-active.signed.json", visibility="public"),
typedstandards.cli_version,
):
with pytest.raises(typedstandards.NodeLocatorError, match="20.19"):
call()
Loading