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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`**
Expand Down Expand Up @@ -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.
Expand Down
122 changes: 102 additions & 20 deletions src/typedstandards/_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from __future__ import annotations

import copy
import functools
import json
import logging
import os
Expand Down Expand Up @@ -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
Expand All @@ -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__

Expand Down Expand Up @@ -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)

Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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 <token>`` 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 "<GitHub token withheld>"

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):
Expand Down
12 changes: 11 additions & 1 deletion tests/publish_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading