diff --git a/CHANGELOG.md b/CHANGELOG.md index 021140c..d7a6e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +- The token reaches each request through an `httpx.Auth` that holds it and withholds it from its + `repr`, with a given `client=` or the client the call builds; no header dict carries it. When an + exception escapes a request, the locals of the frames it carries (httpx's, httpcore's and h11's + included) are cleared before it propagates, so a traceback with frame locals does not show the + token. Tested with httpx's mock transport and with its real transport over a failing stream. +- `GitHubPagesHost`'s errors never quote their argument. A `repository` or `branch` with a part + shaped like a GitHub token (a token prefix, then 30 or more token characters) is refused; a + short name with a token prefix is accepted. + ## 0.2.0 — 2026-10-09 - `publish(signed, *, host, title, name=None, notebook=None, role="notebook", revises=None)` and diff --git a/README.md b/README.md index 85c22cc..7a6a21e 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,10 @@ ts.publish_attestation(withdrawal, host=host, name=receipt["name"]) ``` - **`GitHubPagesHost(repository, *, branch="main", token=None, ...)`**: the repository as - `owner/name`. Its `repr` shows the repository and the branch only. The HTTP client a call builds + `owner/name`. Its `repr` shows the repository and the branch only, and its errors never quote + an argument. A repository or branch with a part shaped like a GitHub token (a token prefix such + as `ghp_` or `github_pat_`, then 30 or more token characters) is refused; a short name with such + a prefix, such as `ghp_notes`, is accepted. The HTTP client a call builds ignores proxy and certificate environment variables; pass `client=` (an `httpx.Client`) for those. - **`publish(signed, *, host, title, name=None, notebook=None, role="notebook", revises=None)`** @@ -328,8 +331,12 @@ message names the request and GitHub's own message. A fine-grained personal access token, for the one publishing repository, with **Contents: read and write** and nothing else (enough for a public repository; a private one is unmeasured). Give it an expiry. It comes from `token=`, else from `TYPEDSTANDARDS_GITHUB_TOKEN`, read when a publish -runs; it is sent only as the `Authorization` header of the client the call builds and closes, and -it is in no message, log record, receipt or file. Never write it as a literal in a cell. Locally, +runs; it is added to each request's `Authorization` header by an `httpx.Auth` that holds it and +withholds it from its `repr`, whether the call builds its client or uses one given as `client=`. It +is in no message, log record, receipt or file. When an exception escapes a request, the locals of +the frames it carries, httpx's, httpcore's and h11's included, are cleared before it propagates, +so a traceback with frame locals does not show the token; the tests check this with httpx's mock +transport and with its real transport over a failing stream. Never write it as a literal in a cell. Locally, set it with the seed, from the secret store that starts the kernel (`op run --env-file=… -- jupyter lab`); in a hosted notebook, from the hosting service's secret store, as for the seed below. diff --git a/src/typedstandards/_publish.py b/src/typedstandards/_publish.py index 9a81b9e..9b85090 100644 --- a/src/typedstandards/_publish.py +++ b/src/typedstandards/_publish.py @@ -18,6 +18,7 @@ from __future__ import annotations import copy +import functools import json import logging import os @@ -111,14 +112,13 @@ def __init__( transport: httpx.BaseTransport | None = None, timeout: float = 30.0, ) -> None: - if not isinstance(repository, str) or not _REPOSITORY.match(repository): - raise ValueError(f"repository must be owner/name, as in https://github.com/owner/name: {repository!r}") - if not isinstance(branch, str) or not _BRANCH.match(branch) or ".." in branch: - raise ValueError(f"branch must be a branch name: {branch!r}") - if not isinstance(api_url, str) or not api_url.startswith("https://") or api_url.endswith("/"): - raise ValueError("api_url must be an https:// URL with no trailing /") - if token is not None and not isinstance(token, str): - raise TypeError("token must be a string") + problem = _host_argument_problem(repository, branch, api_url, token) + if problem is not None: + # The error never quotes an argument, and no argument stays a local of this frame, + # which a traceback with frame locals would print. + del repository, branch, token, api_url, client, transport + kind, message = problem + raise kind(message) self.repository = repository self.branch = branch self.api_url = api_url @@ -128,7 +128,7 @@ def __init__( self._transport = transport def __repr__(self) -> str: - return f"GitHubPagesHost({self.repository!r}, branch={self.branch!r})" + return f"GitHubPagesHost({getattr(self, 'repository', '?')!r}, branch={getattr(self, 'branch', '?')!r})" __str__ = __repr__ @@ -166,6 +166,31 @@ def _token_problem(value: str | None, source: str) -> str | None: return None +#: A token-shaped part of a name: one of GitHub's token prefixes, then 30 or more token +#: characters. The shortest documented token runs 36 after its prefix (a 40-character classic +#: token); a short name with a prefix, such as ``ghp_notes``, is a name. +_TOKEN_SHAPED = re.compile(r"^(?:github_pat_|ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9_.-]{30,}$") + + +def _host_argument_problem(repository: Any, branch: Any, api_url: Any, token: Any) -> tuple[type, str] | None: + """Why ``GitHubPagesHost`` refuses its arguments, in words that quote none of them.""" + for label, value in (("repository", repository), ("branch", branch)): + if isinstance(value, str) and any(_TOKEN_SHAPED.match(part) for part in value.split("/")): + return ValueError, ( + f"{label} looks like a GitHub token (the value is not shown): pass a token as token=, or set " + f"{TOKEN_VARIABLE}" + ) + if not isinstance(repository, str) or not _REPOSITORY.match(repository): + return ValueError, "repository must be owner/name, as in https://github.com/owner/name (the value is not shown)" + if not isinstance(branch, str) or not _BRANCH.match(branch) or ".." in branch: + return ValueError, "branch must be a branch name (the value is not shown)" + if not isinstance(api_url, str) or not api_url.startswith("https://") or api_url.endswith("/"): + return ValueError, "api_url must be an https:// URL with no trailing / (the value is not shown)" + if token is not None and not isinstance(token, str): + return TypeError, "token must be a string" + return None + + def _refuse(message: str) -> NoReturn: raise PublishRefusedError(message) @@ -185,13 +210,18 @@ def __init__(self, host: GitHubPagesHost) -> None: self.host = host self.base = f"{host.api_url}/repos/{host.repository}" self._own = host._client is None - # The headers are built inside each call below, so no frame holds them as a local. + # The token reaches each request through an httpx.Auth that holds it in a slot and withholds + # it from its repr, on both paths: no header dict of the client's or of a request carries + # it. httpcore and h11 hold the request head while they write it; _send clears the locals + # of those frames from whatever escapes the request. Tested on MockTransport and on + # httpx.HTTPTransport with a failing stream (tests/test_publish_transport.py). + self._auth = _bearer_auth_class()(_Token(host._resolve_token())) if host._client is None: # trust_env=False: building the client does not iterate the environment (which holds the # signing seed) for proxy settings, and reads no .netrc. A caller who needs a proxy or a # certificate bundle passes client=. self._http = httpx.Client( - headers=_headers(host), + headers=_HEADERS, transport=host._transport, timeout=host.timeout, follow_redirects=False, @@ -200,7 +230,7 @@ def __init__(self, host: GitHubPagesHost) -> None: self._headers: dict[str, str] = {} else: self._http = host._client - self._headers = _headers(host) + self._headers = dict(_HEADERS) def close(self) -> None: if self._own: @@ -212,11 +242,20 @@ def _request_headers(self, accept: str | None) -> dict[str, str]: def _send(self, method: str, path: str, body: Any = None, *, accept: str | None = None) -> httpx.Response: try: response = self._http.request( - method, self.base + path, json=body, headers=self._request_headers(accept), follow_redirects=False + method, + self.base + path, + json=body, + headers=self._request_headers(accept), + auth=self._auth, + follow_redirects=False, ) except self._httpx.TransportError as error: + _clear_frames(error) _log.info("publish: %s %s -> no response (%s)", method, path, type(error).__name__) raise _NoResponse(f"{method} {path}: no response ({type(error).__name__})") from None + except BaseException as error: # KeyboardInterrupt included; it is re-raised as itself + _clear_frames(error) + raise _log.info("publish: %s %s -> %s", method, path, response.status_code) return response @@ -260,13 +299,56 @@ def head(self) -> str: return _object_id(sha, f"the head of {self.host.branch}") -def _headers(host: GitHubPagesHost) -> dict[str, str]: - return { - "Authorization": f"Bearer {host._resolve_token()}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "typedstandards-python", - } +#: The headers each request carries. The token is not one of them: ``_bearer_auth_class`` adds it. +_HEADERS = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "typedstandards-python", +} + + +@functools.cache +def _bearer_auth_class() -> type: + """An ``httpx.Auth`` that sets ``Authorization: Bearer `` on the request it sends. It + holds the token as a ``_Token`` in a slot; its ``repr`` withholds it, and it is not pickled. + httpx is imported here, so importing the package does not load it.""" + import httpx + + class BearerAuth(httpx.Auth): + __slots__ = ("_token",) + + def __init__(self, token: _Token) -> None: + self._token = token + + def __repr__(self) -> str: + return "" + + def __reduce__(self) -> NoReturn: + raise TypeError("a token is not pickled") + + def auth_flow(self, request: httpx.Request) -> Any: + request.headers["Authorization"] = "Bearer " + self._token.reveal() + yield request + + return BearerAuth + + +def _clear_frames(error: BaseException) -> None: + """Clear the locals of every finished frame the exception and the exceptions chained to it + (``__cause__``, ``__context__``) carry. Below ``Client.request``, httpcore and h11 hold the + request head, Authorization line included, while they write it; a traceback with frame locals + would print those frames. Frames still executing, publish's own among them, are skipped.""" + import traceback + + seen: set[int] = set() + pending: list[BaseException | None] = [error] + while pending: + current = pending.pop() + if current is None or id(current) in seen: + continue + seen.add(id(current)) + traceback.clear_frames(current.__traceback__) + pending += [current.__cause__, current.__context__] class _ApiError(PublishError): diff --git a/tests/publish_support.py b/tests/publish_support.py index 28cd389..411faf8 100644 --- a/tests/publish_support.py +++ b/tests/publish_support.py @@ -142,5 +142,15 @@ def github( return FakeGitHub(files, token=TOKEN) -def host(gh: FakeGitHub, **kwargs: Any) -> ts.GitHubPagesHost: +#: How a test host reaches the fake API: through a client publish builds (``transport=``), or +#: through a client the caller gives (``client=``). +HOST_MODES = ("transport", "client") + + +def host(gh: FakeGitHub, mode: str = "transport", **kwargs: Any) -> ts.GitHubPagesHost: + if mode == "client": + import httpx + + given = httpx.Client(transport=gh.transport(), event_hooks=kwargs.pop("event_hooks", None) or {}) + return ts.GitHubPagesHost(gh.repository, token=TOKEN, client=given, **kwargs) return ts.GitHubPagesHost(gh.repository, token=TOKEN, transport=gh.transport(), **kwargs) diff --git a/tests/test_publish_token.py b/tests/test_publish_token.py index 14c6784..aa1b6ab 100644 --- a/tests/test_publish_token.py +++ b/tests/test_publish_token.py @@ -30,7 +30,7 @@ import httpx import pytest from github_stub import FakeGitHub # publish_support puts scripts/ on the path -from publish_support import TOKEN, Docs, github, host +from publish_support import HOST_MODES, TOKEN, Docs, github, host import typedstandards as ts @@ -73,7 +73,7 @@ def captured(call: Callable[[], Any]) -> list[tuple[str, str]]: warnings.simplefilter("always") try: texts.append(("return value", repr(call()))) - except Exception as error: # every exception's text is scanned + except (Exception, KeyboardInterrupt) as error: # every exception's text is scanned texts.append(("exception str", str(error))) texts.append(("exception repr", repr(error))) texts.append(("traceback", "".join(traceback.format_exception(error)))) @@ -98,7 +98,7 @@ def leaks(token: str, texts: list[tuple[str, str]]) -> list[str]: # --- every publish path, scanned ------------------------------------------------------------- -def publish_paths(docs: Docs) -> dict[str, Callable[[], Any]]: +def publish_paths(docs: Docs, mode: str = "transport") -> dict[str, Callable[[], Any]]: """Each path of acceptance 1 as a call, on a fresh fake host per call.""" def run(listed: dict | None = None, setup: Callable[[FakeGitHub], None] | None = None, **kwargs: Any): @@ -108,8 +108,8 @@ def call() -> Any: setup(gh) target = kwargs.pop("_target", "publish") if target == "attestation": - return ts.publish_attestation(docs.withdrawal, host=host(gh), name="dog-licensing") - return ts.publish(kwargs.pop("_signed", docs.first), host=host(gh), **kwargs) + return ts.publish_attestation(docs.withdrawal, host=host(gh, mode), name="dog-licensing") + return ts.publish(kwargs.pop("_signed", docs.first), host=host(gh, mode), **kwargs) return call @@ -136,9 +136,11 @@ def call() -> Any: } -def test_no_output_holds_the_token_on_any_publish_path(docs: Docs) -> None: +@pytest.mark.parametrize("mode", HOST_MODES) +def test_no_output_holds_the_token_on_any_publish_path(docs: Docs, mode: str) -> None: + """Each path, with the client publish builds and with a client the caller gives.""" found = {} - for label, call in publish_paths(docs).items(): + for label, call in publish_paths(docs, mode).items(): texts = captured(call) assert texts, label if leaks(TOKEN, texts): @@ -225,10 +227,24 @@ def leak(request: httpx.Request) -> None: warnings.warn(f"sent {value}", stacklevel=1) elif how == "exception": raise RuntimeError(f"sent {value}") - elif how == "frame local": - raise RuntimeError("a frame holding the header as a local raised") return None + if how == "frame local": + # A step after the request (the receipt) whose frame holds the token and raises. publish + # clears the frames below a request, so the offending frame is one it does not clear. + from typedstandards import _publish + + real = _publish._receipt + + def leaky_receipt(*args: Any, **kwargs: Any) -> Any: + held = TOKEN # noqa: F841 (the offending local) + raise RuntimeError("a frame holding the token as a local raised") + + _publish._receipt = leaky_receipt + try: + return ts.publish(docs.first, host=host(gh), name="dog-licensing", title="T") + finally: + _publish._receipt = real gh.hook = leak return ts.publish(docs.first, host=host(gh), name="dog-licensing", title="T") @@ -336,3 +352,106 @@ def test_publishing_reads_the_token_variable_and_never_the_whole_environment( assert ts.TOKEN_VARIABLE in recorder.keys_read assert SEED_VARIABLE not in recorder.keys_read assert recorder.read_all is False + + +# --- an exception that escapes the HTTP client, with either client ------------------------------ + + +def _raises_inside(request: httpx.Request) -> httpx.Response: + raise RuntimeError("a transport failed with an error that is not a transport error") + + +def _interrupted(request: httpx.Request) -> httpx.Response: + raise KeyboardInterrupt + + +def _undecodable(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, headers={"content-encoding": "gzip"}, content=b"not gzip data") + + +def _failing_hook(request: httpx.Request) -> None: + raise RuntimeError("an event hook failed") + + +ESCAPES = { + "an error raised inside the transport": (_raises_inside, None), + "an interrupt inside the transport": (_interrupted, None), + "a body that does not decode": (_undecodable, None), + "an error raised by an event hook": (None, _failing_hook), +} + + +@pytest.mark.parametrize("mode", HOST_MODES) +@pytest.mark.parametrize("escape", ESCAPES) +def test_no_frame_holds_the_token_when_an_exception_escapes_the_client(docs: Docs, mode: str, escape: str) -> None: + """No frame a traceback shows, publish's or httpx's, holds the token as a plain value, with a + given client or publish's own, whatever escapes the request.""" + handler, hook = ESCAPES[escape] + if hook is not None and mode == "transport": + pytest.skip("an event hook belongs to a client the caller gives") + + def call() -> Any: + gh = github(docs) + if handler is not None: + gh.hook = handler + kwargs = {"event_hooks": {"request": [hook]}} if hook is not None else {} + return ts.publish(docs.first, host=host(gh, mode, **kwargs), name="dog-licensing", title="T") + + texts = captured(call) + assert any(where == "traceback with locals" for where, _ in texts), texts + assert leaks(TOKEN, texts) == [] + + +# --- GitHubPagesHost's own errors ----------------------------------------------------------------- + + +#: A token-shaped value given as the wrong argument. Module constants, so the calls below hold no +#: local of their own that a traceback with frame locals would print: only the package's frames +#: are under test. +WRONG_ARGUMENT = "github_pat_TESTONLY_given_as_the_wrong_argument" +MISPLACED = { + "repository": lambda: ts.GitHubPagesHost(WRONG_ARGUMENT, token=TOKEN), + "branch": lambda: ts.GitHubPagesHost("example-owner/example-host", branch=WRONG_ARGUMENT, token=TOKEN), + "api_url": lambda: ts.GitHubPagesHost("example-owner/example-host", api_url=WRONG_ARGUMENT, token=TOKEN), +} + + +@pytest.mark.parametrize("argument", MISPLACED) +def test_the_hosts_errors_never_quote_their_argument(argument: str) -> None: + """A token-shaped value given as the wrong argument stays out of the error's str, repr and + traceback with locals, and so does the token= given beside it.""" + texts = captured(MISPLACED[argument]) + assert any(where == "exception repr" and text.startswith("ValueError(") for where, text in texts), texts + assert leaks(WRONG_ARGUMENT, texts) == [] + assert leaks(TOKEN, texts) == [] + + +#: A token-shaped part: a token prefix and a run of token characters as long as a token's. Built at +#: run time, so this file holds no literal of a token's shape. +TOKEN_SHAPED = "ghp_" + "TESTONLY" * 5 + + +@pytest.mark.parametrize( + "kwargs", + [ + {"repository": "example-owner/ghp_notes"}, + {"repository": "example-owner/gho_tools"}, + {"repository": "example-owner/github_pat_docs"}, + {"repository": "example-owner/example-host", "branch": "ghs_release-notes"}, + ], + ids=["a ghp_ repository", "a gho_ repository", "a github_pat_ repository", "a ghs_ branch"], +) +def test_a_name_that_starts_with_a_token_prefix_is_accepted(kwargs: dict[str, str]) -> None: + """Only a token-shaped part is refused: a short name with a token prefix is a name.""" + made = ts.GitHubPagesHost(**kwargs) + assert made.repository == kwargs["repository"] + + +@pytest.mark.parametrize("argument", ["repository", "branch"]) +def test_a_token_shaped_repository_or_branch_is_refused(argument: str) -> None: + kwargs = {"repository": "example-owner/example-host", argument: f"example-owner/{TOKEN_SHAPED}"} + if argument == "branch": + kwargs["branch"] = TOKEN_SHAPED + with pytest.raises(ValueError, match="looks like a GitHub token") as caught: + ts.GitHubPagesHost(**kwargs) + assert TOKEN_SHAPED not in str(caught.value) and TOKEN_SHAPED not in repr(caught.value) diff --git a/tests/test_publish_transport.py b/tests/test_publish_transport.py new file mode 100644 index 0000000..3250fb6 --- /dev/null +++ b/tests/test_publish_transport.py @@ -0,0 +1,179 @@ +"""The token on httpx's real transport (typedstandards#141 P5): ``httpx.HTTPTransport``, so each +request's head is written through httpcore and h11, as it is outside the tests. + +httpcore's sync network backend is replaced, for one test at a time, by one whose ``connect_tcp`` +returns a stream that opens no socket (the autouse offline guard stays on) and whose ``write`` +fails. The rule checked: when an exception escapes a publish's request, with publish's own client +or a given one, no frame it carries holds the token, in the exception, its ``__cause__`` or its +``__context__``, and the formatted traceback with frame locals does not show it either. + +Locked versions: httpx 0.28.1, httpcore 1.0.9, h11 0.16.0 (``uv.lock``). +""" + +from __future__ import annotations + +import traceback +from collections.abc import Iterator +from typing import Any + +import httpcore +import httpx +import pytest +from httpcore._backends.base import NetworkStream +from httpcore._backends.sync import SyncBackend +from httpcore._exceptions import ReadError, WriteError, map_exceptions +from publish_support import TOKEN, Docs + +import typedstandards as ts + +SECRET_TAIL = TOKEN[len("github_pat_") :] + + +def _write_interrupted() -> None: + raise KeyboardInterrupt + + +def _write_failed() -> None: + raise RuntimeError("the stream failed while writing") + + +def _write_os_error() -> None: + # As httpcore's own SyncStream.write maps a socket's OSError. httpcore's HTTP/1.1 connection + # passes over a WriteError from the request head and reads the response, so the transport + # error that escapes is the read's (_read_os_error). + with map_exceptions({OSError: WriteError}): + raise OSError("the connection was reset while writing") + + +def _read_os_error() -> bytes: + with map_exceptions({OSError: ReadError}): + raise OSError("the connection was reset while reading") + + +#: How the stream fails: (its write, its read). The read is reached only after a WriteError. +FAILURES = { + "an interrupt": (_write_interrupted, None), + "an error that is not a transport error": (_write_failed, None), + "a transport error": (_write_os_error, _read_os_error), +} + + +class FailingStream(NetworkStream): + """A stream with no socket: TLS is a no-op, and the first write fails one way.""" + + def __init__(self, fail: Any) -> None: + self.fail_write, self.fail_read = fail + self.written = 0 + + def read(self, max_bytes: int, timeout: float | None = None) -> bytes: + return self.fail_read() if self.fail_read is not None else b"" + + def write(self, buffer: bytes, timeout: float | None = None) -> None: + self.written += len(buffer) + self.fail_write() + + def close(self) -> None: + pass + + def start_tls(self, ssl_context: Any, server_hostname: str | None = None, timeout: float | None = None) -> Any: + return self + + def get_extra_info(self, info: str) -> Any: + return None + + +@pytest.fixture +def failing_network(monkeypatch: pytest.MonkeyPatch) -> Iterator[dict[str, Any]]: + """Make httpcore's sync backend hand out a FailingStream; the test sets ``fail``.""" + state: dict[str, Any] = {"fail": None, "streams": []} + + def connect_tcp(self: SyncBackend, host: str, port: int, **kwargs: Any) -> NetworkStream: + stream = FailingStream(state["fail"]) + state["streams"].append(stream) + return stream + + monkeypatch.setattr(SyncBackend, "connect_tcp", connect_tcp) + yield state + + +def _exceptions(error: BaseException) -> Iterator[BaseException]: + """The exception and every exception chained to it, as ``__cause__`` or ``__context__``.""" + seen: set[int] = set() + pending = [error] + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + yield current + pending += [e for e in (current.__cause__, current.__context__) if e is not None] + + +def _frame_texts(error: BaseException) -> list[tuple[str, str]]: + """The repr of every local of every frame each chained exception's traceback carries, and each + exception's traceback formatted with frame locals.""" + texts = [] + for current in _exceptions(error): + tb = current.__traceback__ + while tb is not None: + frame = tb.tb_frame + where = f"{frame.f_code.co_filename.rsplit('/', 1)[-1]}:{tb.tb_lineno} {frame.f_code.co_name}" + for name, value in frame.f_locals.items(): + try: + texts.append((f"{where} local {name}", repr(value))) + except Exception: # a repr that fails holds nothing to read + pass + tb = tb.tb_next + formatted = traceback.TracebackException.from_exception(current, capture_locals=True) + texts.append((f"{type(current).__name__} formatted with locals", "".join(formatted.format()))) + texts.append((f"{type(current).__name__} str and repr", f"{current!s} {current!r}")) + return texts + + +def _host(mode: str) -> ts.GitHubPagesHost: + if mode == "client": + return ts.GitHubPagesHost( + "example-owner/example-host", token=TOKEN, client=httpx.Client(transport=httpx.HTTPTransport()) + ) + return ts.GitHubPagesHost("example-owner/example-host", token=TOKEN, transport=httpx.HTTPTransport()) + + +@pytest.mark.parametrize("mode", ["transport", "client"]) +@pytest.mark.parametrize("failure", FAILURES) +def test_no_frame_holds_the_token_when_the_real_transport_fails( + docs: Docs, failing_network: dict[str, Any], mode: str, failure: str +) -> None: + """With publish's own client and with a given one, whatever escapes the request head's write + on httpx's real transport, no frame it carries holds the token.""" + failing_network["fail"] = FAILURES[failure] + try: + ts.publish(docs.first, host=_host(mode), name="dog-licensing", title="T") + except BaseException as error: # KeyboardInterrupt included: the test reads what escaped + escaped = error + else: + pytest.fail("the publish did not fail") + # The request head reached the stream, through httpcore and h11. + assert failing_network["streams"] and failing_network["streams"][0].written > 0 + if failure == "an interrupt": + assert type(escaped) is KeyboardInterrupt + elif failure == "a transport error": + assert isinstance(escaped, ts.PublishError) + assert any(isinstance(e, httpcore.ReadError) for e in _exceptions(escaped)) + else: + assert type(escaped) is RuntimeError + texts = _frame_texts(escaped) + found = sorted({where for where, text in texts if TOKEN in text or SECRET_TAIL in text}) + assert found == [] + + +def test_the_frame_walk_finds_a_token_held_below_it(failing_network: dict[str, Any]) -> None: + """The walk above reads httpcore's frames: a request whose header dict carries the token, sent + without publish, shows it there.""" + failing_network["fail"] = (_write_failed, None) + client = httpx.Client(transport=httpx.HTTPTransport()) + try: + client.get("https://api.example.org/", headers={"Authorization": "Bearer " + TOKEN}) + except RuntimeError as error: + escaped = error + found = {where for where, text in _frame_texts(escaped) if SECRET_TAIL in text} + assert any("http11.py" in where for where in found), sorted(found)