diff --git a/src/opendox/cli.py b/src/opendox/cli.py index 7165c451..e84c81f1 100644 --- a/src/opendox/cli.py +++ b/src/opendox/cli.py @@ -104,6 +104,10 @@ # the driver is imported when the server is started, never here. from opendox.runtime import bundle as bundle_mod # noqa: E402 from opendox.runtime import migrations as migrations_mod # noqa: E402 +# THE CONSOLE TOKEN'S PRIVATE COPY (plan 034 T104): on a standalone plane the +# token is not on `/capabilities`, and `generate-and-open` opens the page +# through a 0600 copy in the state directory instead. Stdlib-only. +from opendox import console_access # noqa: E402 from opendox.boundary import ( # noqa: E402 BoundaryViolation, HumanGate, OutputBoundary, ) @@ -893,34 +897,86 @@ def _generate_and_serve(args: argparse.Namespace, run_dir: Path, *, # bundled server it started as its child. install_report=_install_report(args)) url = serve_mod.server_url(httpd, "/index.html") - print(f" serving {url}") - print(f" snapshot {serve_mod.server_url(httpd, '/snapshot.json')}") - # The URL is ALWAYS printed on its own line, AND FLUSHED (plan 034 T056). - # Where standard output is a pipe or a file, Python buffers it by block, - # and the process is about to block in `serve_forever()`. So without the - # flush, a wrapper reading this line never sees it while the server runs, - # and it cannot learn an ephemeral port or tell that the server started. - # Measured at openDox-code#59 e3ef506a: zero lines in 20 s on a pipe. - print(url, flush=True) - - if not args.no_open: + # THE CONSOLE TOKEN, ON A STANDALONE PLANE (plan 034 T104; RULED + # openxFactory#656 `5963851934`). `/capabilities` no longer carries it, so + # this entry point writes it into a 0600 private copy in the install's + # state directory, an HTML page that forwards to `url` with the token in + # the FRAGMENT. The browser is handed the copy's PATH, because a URL given + # to `webbrowser.open` sits on a command line every user can read + # (`/proc//cmdline`). The path is printed with or without + # `--no-open`, and the token never is: opening that file again re-opens + # the page. `None` on a host's plane, where no token was minted (a + # standalone plane still keeps every console's copy unserved then, + # `console_access.guard_private_roots`), and under `--no-serve`. A copy + # that cannot be written safely refuses the run before it serves. + # + # A plain `kill`, or a closed terminal, stops a standalone console the way + # Ctrl-C does (`terminate_as_interrupt`), from BEFORE the copy is written + # to after it is removed (Copilot at openDox-code#84, r4175213864), and a + # stop that arrives while the copy is being written or removed is held + # until that is done (`deferred_termination`), so no copy is ever left + # half handled. A plane that writes no copy keeps the signals' defaults. + # + # `--no-serve` SERVES NOTHING, SO IT PUBLISHES NOTHING (adversarial review + # of openDox-code#84, B8). It closes the server as soon as it has printed + # the URL, so a copy written for it opened a console page nothing + # answered, and was deleted as the run returned. No copy is written, none + # is opened, and no console line is printed. + console = None + serving = not args.no_serve + with console_access.terminate_as_interrupt( + serving and console_access.needs_copy(httpd)): try: - opener(url) - except Exception as exc: # a headless box has no browser — never fatal - print(f" (could not open a browser: {exc}; open the URL above manually)") - - if args.no_serve: - httpd.server_close() - return 0 - - print(" serving until interrupted (Ctrl-C to stop)", flush=True) - try: - httpd.serve_forever() - except KeyboardInterrupt: - pass - finally: - httpd.server_close() - return 0 + try: + with console_access.deferred_termination(): + if serving: + console = console_access.publish(httpd, page_url=url) + print(f" serving {url}") + print(f" snapshot {serve_mod.server_url(httpd, '/snapshot.json')}") + if console is not None: + print(f" console {console.file_url} (this user's private " + "copy, mode 0600: open it to open the console page " + "again)") + # A browser that cannot open it is told the way past it, + # in one line with no token (RULED, B3). + print(f" {console_access.UNOPENABLE_HINT}") + # The URL is ALWAYS printed on its own line, AND FLUSHED (plan + # 034 T056). Where standard output is a pipe or a file, Python + # buffers it by block, and the process is about to block in + # `serve_forever()`. So without the flush, a wrapper reading + # this line never sees it while the server runs, and it cannot + # learn an ephemeral port or tell that the server started. + # Measured at openDox-code#59 e3ef506a: zero lines in 20 s on + # a pipe. It carries no token. + print(url, flush=True) + + if not args.no_open: + try: + opener(console.file_url if console is not None else url) + except Exception as exc: # a headless box has no browser — never fatal + print(f" (could not open a browser: {exc}; open the " + f"{'console file' if console is not None else 'URL'} " + "above manually)") + + if args.no_serve: + return 0 + + print(" serving until interrupted (Ctrl-C to stop)", flush=True) + httpd.serve_forever() + except console_access.ConsoleAccessRefused as exc: + print(f"generate-and-open refused: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + pass + return 0 + finally: + # The copy goes with the server: its token is this serve's, and + # dies with it. It goes FIRST, while this process still holds the + # port, so no later serve can bind it and write its own copy in + # between. A stop that arrives meanwhile lets it finish. + with console_access.deferred_termination(raise_pending=False): + console_access.remove_private_copy(console) + httpd.server_close() # ---- gate console (US9): human-only executable gate actions ---------------- diff --git a/src/opendox/console_access.py b/src/opendox/console_access.py new file mode 100644 index 00000000..694f5dfa --- /dev/null +++ b/src/opendox/console_access.py @@ -0,0 +1,1417 @@ +"""How the console token reaches the page on a STANDALONE plane (plan 034 T104). + +THE RULING. openxFactory#656 comment `5963851934` (Brett, 2026-10-03, "Token +via the opened URL (Recommended)"): adversarial review 2's M5 found that a +standalone openDox served its per-serve console token from `/capabilities` to +any loopback caller, other OS users on the same machine included, and nothing +checks which local user connects. The token lets a page edit documents and run +chat turns that spend the operator's model credential. Release 1 changes the +DELIVERY, as Jupyter does, and nothing else: + + * the server stops handing the token out from `/capabilities` on a + standalone plane (`delivery_for`, read by `serve.build_server`); + * the entry point writes a PRIVATE COPY, mode 0600, in openDox's state + directory (`write_private_copy`), and the page is opened through it, so the + token travels only in the opened URL's FRAGMENT (`opened_url`). A fragment + is never sent to a server, so it never reaches a request line, a server + log or a `Referer`; + * the page reads it from `location.hash`, keeps it in `sessionStorage`, and + strips it from the address bar (`web/views/notebook.js`); + * every route that requires the token still requires it. + +WHO IS "STANDALONE". A plane built from openDox's OWN default profile +(`opendox.default_profile`), which an entry point registers where no host has. +A HOST's plane, openxFactory's, keeps the `/capabilities` delivery its page and +its suites read today: the ruling changes the standalone plane, and the +governed one is unchanged by it. + +WHY THE BROWSER IS GIVEN A FILE AND NOT THE URL. `webbrowser.open(url)` runs +`xdg-open url` or the browser with the URL on its command line, and a command +line is readable by every user of the machine (`/proc//cmdline`), for as +long as that process lives. So the URL that carries the token is written into +the private copy, an HTML page that forwards to it, and the browser is handed +the copy's `file://` path. That is Jupyter's own redirect file, and for the +same reason. The start prints the copy's PATH, never the token, with or +without `--no-open`, and opening that file again is how a user re-opens the +page while the server runs. The copy is removed when the server stops. Beside +the path, one line with no token tells a user whose browser cannot open that +file (a snap or Flatpak browser, a Windows browser under WSL) to move the +state directory (`UNOPENABLE_HINT`, RULED as an accepted limit). + +THE COPY IS CHECKED THE WAY openDox-code#69's BUNDLE CHECKS ITS TREE +(`opendox.runtime.bundle`: `refuse_an_unsafe_tree`, `_make_private_directories`, +`write_authentication`). The rules are copied here, not imported, because they +are that module's private helpers and its refusal names a socket: + + * the state directory and `console/` must be real directories, this user's, + writable by no one else, and `console/` exactly 0700; every directory + above them must be this user's or root's, and one that others can write + must be sticky; every symbolic link on the configured path must be this + user's or root's; + * a missing directory is made relative to its parent's DESCRIPTOR, born + 0700, and opened without following a link before anything is made under + it; + * the file is created exclusively, without following a link, set to exactly + 0600 by its descriptor, and renamed into place. A name already at the + target that is not this user's own regular file of mode 0600 with one + link (a link, a directory, a FIFO, a file another user owns, a file with + a second hard link, a loosened file) is REFUSED, never followed or + replaced; + * a READ asks all of it again of what exists, and of the file by its + descriptor, opened without blocking: a regular file, this user's, + exactly 0600, one link; + * the state directory and every root the plane serves may not overlap in + either direction, before any write, as T100's served-repository boundary + refuses its own (holder's rulings on openxFactory#1220's review, Copilot + `r4171166321`, and on batch N's, `r4174345203`), judged by name AND by + the directories' own identities, so a second spelling of one directory + (a case-insensitive filesystem's) is the same directory; + * EVERY standalone plane keeps that boundary and never serves a copy, the + planes that minted no token included (`publish`): a sibling plane of the + same user shares the state directory, and serves what another plane + wrote there unless it refuses it too; + * a publication sweeps the copies their servers left when they died + (`_sweep_stale_copies`): a copy whose reservation is free is no running + console's; + * every refusal names its path, an operating-system one included, so an + entry point refuses its start by name; and the copy is removed when the + server stops, by Ctrl-C, SIGTERM or SIGHUP, or when its start is refused + after it was written. A stop is read as Ctrl-C from before the copy is + written to after it is removed, and held while a copy is being written or + removed (`deferred_termination`); the first stop is the only one raised + (`_terminate_as_interrupt`), and every writer and remover of `console/` + takes the directory's lock (`_lock`); + * a platform without the POSIX primitives these rules rest on is named and + refused before anything is written or read (`unsupported_platform`), as + the bundle refuses its own. + +#1144 12.4a, as T007 batch N amends it (openxFactory#1222), is the normative +text this module realizes. + +A CREATED FILE, with no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import html +import json +import os +import re +import signal +import stat +import sys +import urllib.parse +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +from opendox.runtime import config as runtime_config + +try: # POSIX; the copy's rules are POSIX's + import fcntl +except ImportError: # pragma: no cover + fcntl = None + +__all__ = [ + "CONSOLE_DIRNAME", "ConsoleAccessRefused", "ConsoleTerminated", + "DELIVERY_CAPABILITIES", + "DELIVERY_OPENED_URL", "FRAGMENT_KEY", "PrivateCopy", "RECORD_ELEMENT_ID", + "COPY_MARKER", "RECORD_KIND", "UNOPENABLE_HINT", "deferred_termination", + "delivery_for", "guard_private_roots", "is_copy_bytes", + "is_private_file", "needs_copy", "opens_a_private_file", + "opened_url", "private_copy_path", "publish", "read_private_copy", + "remove_private_copy", "terminate_as_interrupt", "unsupported_platform", + "within_private_roots", "write_private_copy", +] + +#: The token rides on `/capabilities`, as a host's plane has always read it. +DELIVERY_CAPABILITIES = "capabilities" +#: The token rides only in the opened URL's fragment (the standalone plane). +DELIVERY_OPENED_URL = "opened-url" + +#: The private copies' directory, under the state directory. +CONSOLE_DIRNAME = "console" +#: The fragment's one key: `…/index.html#console_token=`. The page reads +#: the same key (`web/views/notebook.js`, `CONSOLE_TOKEN_FRAGMENT_KEY`). +FRAGMENT_KEY = "console_token" +#: The machine-readable record inside the copy, for a harness or a script. +RECORD_KIND = "opendox-console-access" +RECORD_SCHEMA_VERSION = 1 +RECORD_ELEMENT_ID = "opendox-console" +#: The copy's FIRST bytes, before any byte of the token (Copilot at +#: openDox-code#84, r4179793524). A copy is written from its start, so any +#: part of one that holds a byte of the token holds this whole line first: a +#: file that begins with it is a copy, written in full or caught part way +#: (`is_copy_bytes`), and a file shorter than it holds no token yet. An HTML +#: comment, which a browser reads before the doctype without effect. +COPY_MARKER = (b"\n") +#: The one mode a private copy may have. +PRIVATE_MODE = 0o600 +#: The one mode the copies' directory, `console/`, may have (#1144 12.4a: the +#: copy is mode 0600 "in a directory of mode 0700"). +CONSOLE_DIR_MODE = 0o700 +#: The one line a start prints beside the copy's path, and it carries no token. +#: Some browsers cannot open the copy where it is: a snap or Flatpak browser +#: is kept out of a hidden directory such as `~/.local/state`, and a Windows +#: browser under WSL may not open a Linux path at all. The token is never +#: printed, so this line is the way past it (RULED by Brett on the adversarial +#: review of openDox-code#84, B3, 2026-10-04: "Hint line, accepted limit"). +UNOPENABLE_HINT = ( + "if your browser cannot open this file (a snap or Flatpak browser, or a " + "Windows browser under WSL), set " + f"{runtime_config.PREFIX}STATE_DIR to a folder that is not hidden and " + "start again") +#: A copy is a few hundred bytes; a read stops well past that. +_READ_LIMIT = 64 * 1024 +#: `secrets.token_urlsafe` spells a token in these characters only, so a token +#: needs no escaping in a fragment and a value outside them is not one. +_TOKEN_SHAPE = re.compile(r"[A-Za-z0-9_-]{16,512}") +_RECORD_PATTERN = re.compile( + r'') +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) +#: The one shape a console page's AUTHORITY may have: a loopback host, +#: spelled as `server_url` spells it, and an optional port. No user +#: information, and nothing a browser reads as the authority's end (`\\`). +_LOOPBACK_AUTHORITY = re.compile( + r"(?:127\.0\.0\.1|localhost|\[::1\])(?::(?P[0-9]{1,5}))?") +#: The names a publication may sweep when their servers are gone +#: (`_sweep_stale_copies`): a copy, a writer's temporary file, and a remover's +#: taken name. Each holds a token, and nothing else is ever touched. +_SWEEPABLE = re.compile(r"[0-9]+\.html|\.[0-9]+\.html\.opendox-[0-9]+" + r"|\.[0-9]+\.html\.removing-[0-9]+-[0-9a-f]{12}") + +#: Whether every call the copy's rules make relative to a directory's +#: descriptor takes one here, read ONCE at import, as `opendox.runtime.bundle` +#: reads its own: a case that stands a wrapper in for one of them must not +#: read as another platform. +_DIR_FD_CALLS = all(call in os.supports_dir_fd for call in ( + os.open, os.mkdir, os.stat, os.rename, os.unlink, os.link)) + + +def unsupported_platform() -> str | None: + """Why this platform cannot keep a console token's private copy, or `None`. + + The copy is a POSIX design, as openDox-code#69's bundle is, and every one + of its rules rests on a POSIX primitive: its directories and the file are + judged by owner (`os.getuid`), made and opened without following a link + (`O_DIRECTORY`, `O_NOFOLLOW`, calls relative to a directory's + descriptor), set to 0600 by descriptor (`fchmod`), and read without + blocking (`O_NONBLOCK`). Without them (Windows) the standalone start + ended in an `AttributeError` traceback (adversarial review of + openDox-code#84, B2). So the writer and the reader name the gap first, + and refuse, as `opendox.runtime.bundle.unsupported_platform` names its + own (holder's ruling).""" + missing = [name for name, present in ( + ("os.getuid", hasattr(os, "getuid")), + ("os.O_DIRECTORY", hasattr(os, "O_DIRECTORY")), + ("os.O_NOFOLLOW", hasattr(os, "O_NOFOLLOW")), + ("os.O_NONBLOCK", hasattr(os, "O_NONBLOCK")), + ("os.fchmod", hasattr(os, "fchmod")), + ("calls relative to a directory's descriptor", _DIR_FD_CALLS), + ) if not present] + if not missing: + return None + return (f"the console token's private copy needs a POSIX platform, and " + f"this one ({sys.platform}) lacks {', '.join(missing)}: the copy " + "and its directories are judged by owner and made without " + "following a link, so a standalone console cannot hand its token " + "to this user alone here, and is not started") + + +def _refuse_an_unsupported_platform() -> None: + reason = unsupported_platform() + if reason is not None: + raise ConsoleAccessRefused(reason) + + +class ConsoleAccessRefused(Exception): + """The private copy cannot be written or read safely, and why.""" + + +@dataclasses.dataclass(frozen=True) +class PrivateCopy: + """One written copy: where it is, what it opens, and which file it is.""" + + path: Path + page_url: str + #: The URL with the token in its fragment. Kept out of the copy's `repr`, + #: so a log line, a traceback or a failed assertion that prints a copy + #: never prints its token. + opened_url: str = dataclasses.field(repr=False) + #: `(st_dev, st_ino)` of the file this process wrote, so a removal at + #: shutdown removes that file and never one written after it. + identity: tuple[int, int] + #: The open descriptor that RESERVES the copy for its server's life + #: (`_Reservation`), or None where no reservation could be taken. + reservation: "_Reservation | None" = dataclasses.field( + default=None, compare=False, repr=False) + + @property + def file_url(self) -> str: + """The `file://` URL a browser is given: a path, never the token.""" + return self.path.as_uri() + + +class _Reservation: + """The open descriptor that holds a copy's lock while its server runs. + + A COPY IS RESERVED FOR ITS SERVER'S LIFE (Copilot at openDox-code#84, + r4178133814). The copy's name is per PORT, and two consoles can share a + port number and a state directory, one on 127.0.0.1 and one on ::1. So + the writer takes an exclusive `flock` on the file it wrote, on its own + descriptor, and keeps it until the copy is removed. A later publication + on that port finds the lock held, and refuses rather than replace a + RUNNING console's copy. A copy whose server died holds no lock, since the + kernel drops it with the process, and is replaced as a stale one. + `close` is idempotent, and so is dropping the reservation.""" + + def __init__(self, fd: int) -> None: + self._fd: int | None = fd + + def close(self) -> None: + fd, self._fd = self._fd, None + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + + def __del__(self) -> None: + self.close() + + +def delivery_for(profile: Any) -> str: + """Which delivery a plane built from `profile` uses. + + openDox's OWN default profile is the standalone plane: the token travels in + the opened URL. Any other profile is a host's, and keeps `/capabilities`.""" + from opendox import default_profile + + return (DELIVERY_OPENED_URL if profile is default_profile + else DELIVERY_CAPABILITIES) + + +def _refuse_page_url(page_url: str) -> None: + """The console page must be this machine's own plane, as a BROWSER reads + the URL, not only as `urlsplit` does (Copilot at openDox-code#84, + r4180089809). A browser takes `\\` for `/`, so in + `http://evil.example\\@127.0.0.1:8080/` it sees the host `evil.example` + where `urlsplit` sees user information and `127.0.0.1`, and the token's + fragment would be handed to the remote page. So the authority must be a + loopback host and an optional port, exactly (`_LOOPBACK_AUTHORITY`), and + a backslash or a control character anywhere, which a browser rewrites or + strips, is refused.""" + if "\\" in page_url or any(ord(c) < 0x20 or ord(c) == 0x7F for c in page_url): + raise ConsoleAccessRefused( + f"the console page {page_url!r} holds a backslash or a control " + "character, which a browser reads differently, so it is not " + "certainly this machine's own plane") + parts = urllib.parse.urlsplit(page_url) + authority = _LOOPBACK_AUTHORITY.fullmatch(parts.netloc) + if (parts.scheme != "http" or parts.hostname not in _LOOPBACK_HOSTS + or authority is None + or int(authority.group("port") or 80) > 65535): + raise ConsoleAccessRefused( + f"the console page {page_url!r} is not a loopback http URL, and a " + "console token is only ever opened on this machine's own plane") + if parts.query or parts.fragment or "#" in page_url or "?" in page_url: + raise ConsoleAccessRefused( + f"the console page {page_url!r} already carries a query or a " + "fragment, so the token's fragment cannot be the only one") + + +def _refuse_token(token: Any) -> str: + if not isinstance(token, str) or not _TOKEN_SHAPE.fullmatch(token): + raise ConsoleAccessRefused("the console token is not a token this " + "server mints") + return token + + +def opened_url(page_url: str, token: str) -> str: + """`page_url` with the token in its FRAGMENT and never in its query. + + A fragment stays in the browser: it is not part of the request line, so no + server log and no `Referer` carries it.""" + _refuse_page_url(page_url) + _refuse_token(token) + return page_url + "#" + urllib.parse.urlencode({FRAGMENT_KEY: token}) + + +def private_copy_path(state_dir: Path | str, port: int) -> Path: + """Where the copy for the plane on `port` lives, under `state_dir`.""" + return Path(state_dir) / CONSOLE_DIRNAME / f"{int(port)}.html" + + +# --------------------------- the tree's rules (bundle.py's, copied) --------------------------- + +def _unsafe_because(info: os.stat_result, *, uid: int, own: bool) -> str | None: + """Why one directory on the copy's path is unsafe, or `None`. + + `opendox.runtime.bundle._unsafe_because`, rule for rule.""" + mode = info.st_mode + if stat.S_ISLNK(mode): + return "is a symbolic link" + if not stat.S_ISDIR(mode): + return "is not a directory" + if own: + if info.st_uid != uid: + return f"is owned by uid {info.st_uid}, not by this user" + if mode & 0o022: + return (f"is writable by {'every user' if mode & 0o002 else 'its group'}" + f" (mode {stat.S_IMODE(mode):o})") + return None + if info.st_uid not in (uid, 0): + return f"is owned by uid {info.st_uid}, neither this user nor root" + if mode & 0o022 and not mode & stat.S_ISVTX: + return (f"is writable by {'every user' if mode & 0o002 else 'its group'}" + f" and is not sticky (mode {stat.S_IMODE(mode):o})") + return None + + +def _console_dir_unsafe_because(info: os.stat_result, *, uid: int) -> str | None: + """Why the copies' own directory is unsafe, or `None`: the rules for + every directory this user owns on the path, and exactly mode 0700 (#1144 + 12.4a). A `console/` loosened after it was made is refused by name, as a + loosened copy is, even where no one else can write it.""" + reason = _unsafe_because(info, uid=uid, own=True) + # The PERMISSION bits only: a directory made under a setgid parent + # inherits the setgid bit, which grants no one access. + permissions = stat.S_IMODE(info.st_mode) & 0o777 + if reason is None and permissions != CONSOLE_DIR_MODE: + reason = f"has mode {permissions:o}, not {CONSOLE_DIR_MODE:o}" + return reason + + +def _lock(directory: int) -> None: + """Hold the console directory's lock on its descriptor until it closes. + + PUBLICATION AND REMOVAL ARE SERIALIZED (Copilot at openDox-code#84, + r4175213842). The copy's name is per PORT, and two serves can share one + (127.0.0.1 and ::1), so one serve's removal can find another's copy at + the name and must put it back. Where that takes a rename, a check that + the name is free and the rename are two steps, and a third copy published + between them would be overwritten by an older one. Every writer and + remover of `console/` takes this exclusive lock, an advisory `flock` the + kernel drops when the descriptor closes or the process dies. Where the + filesystem keeps no such locks, nothing is held, as before.""" + if fcntl is not None: + with contextlib.suppress(OSError): + fcntl.flock(directory, fcntl.LOCK_EX) + + +def _unsafe(path: Path, reason: str) -> ConsoleAccessRefused: + return ConsoleAccessRefused( + f"{path} {reason}, so another user could replace or read the console " + "token's private copy. Use a state directory only this user can change " + f"({runtime_config.PREFIX}STATE_DIR)") + + +#: How many symbolic links one walk of the state directory may follow, the +#: kernel's own `MAXSYMLINKS` on Linux. +_MAX_LINKS = 40 + + +def _walked(configured: Path | str) -> Path: + """The state directory, resolved ONCE, as the kernel walks it, with every + directory it passes through and every symbolic link it follows judged on + the way (Copilot at openDox-code#84, r4174785933). + + The tree rules below judge the configured path's own components and the + directories above the RESOLVED path. A directory reached only through a + link's target (`alias -> shared/hop`, `hop -> private`) is neither, so a + `shared` that others could write went unjudged, and another user could + re-point `hop` between the checks and the write, which walked the + configured path again. So every directory passed through is judged by + the rule for the directories above the state directory (this user's or + root's, and sticky if others can write it), every link followed by the + rule for a link (this user's or root's), and the caller works on the + path returned, never on the configured one again. Nothing on that path + can then be replaced by another user. A missing tail is appended as + named, to be made by descriptor under the deepest directory that + exists.""" + uid = os.getuid() + configured = Path(configured) + if not configured.is_absolute() or ".." in configured.parts: + raise ConsoleAccessRefused( + f"the state directory {str(configured)!r} is not an absolute path " + "without `..`, so the copy's path is not the one the kernel walks") + pending = list(reversed(configured.parts[1:])) + current = Path(configured.anchor) + links = 0 + while pending: + name = pending.pop() + if name in ("", "."): + continue + if name == "..": # only ever from a link's target + current = current.parent + continue + candidate = current / name + try: + info = os.lstat(candidate) + except FileNotFoundError: + rest = [name, *reversed(pending)] + if ".." in rest: + raise ConsoleAccessRefused( + f"{candidate} does not exist, and the state directory " + f"{configured} would climb out of it with `..`") from None + return current.joinpath(*rest) + if stat.S_ISLNK(info.st_mode): + if info.st_uid not in (uid, 0): + raise _unsafe(candidate, f"is a symbolic link owned by uid " + f"{info.st_uid}, neither this user nor root, who " + "could point it elsewhere") + links += 1 + if links > _MAX_LINKS: + raise ConsoleAccessRefused( + f"the state directory {configured} passes through more " + f"than {_MAX_LINKS} symbolic links") + target = Path(os.readlink(candidate)) + if target.is_absolute(): + current = Path(target.anchor) + pending.extend(reversed(target.parts[1:])) + else: + pending.extend(reversed(target.parts)) + continue + reason = _unsafe_because(info, uid=uid, own=False) + if reason is not None: + raise _unsafe(candidate, reason) + current = candidate + return current + + +def _refuse_an_unsafe_tree(state_dir: Path, *, existing_only: bool) -> None: + """The copy's whole path is this user's to change, or it is refused. + + `opendox.runtime.bundle.refuse_an_unsafe_tree`'s three rules, over the + state directory and `console/` instead of the socket's tree.""" + uid = os.getuid() + configured = Path(state_dir) + if not configured.is_absolute() or ".." in configured.parts: + raise ConsoleAccessRefused( + f"the state directory {str(configured)!r} is not an absolute path " + "without `..`, so the copy's path is not the one the kernel walks") + + def present(path: Path) -> bool: + return not existing_only or os.path.lexists(path) + + for component in (configured, *configured.parents): + if not present(component): + continue + info = os.lstat(component) + if stat.S_ISLNK(info.st_mode) and info.st_uid not in (uid, 0): + raise _unsafe(component, f"is a symbolic link owned by uid " + f"{info.st_uid}, neither this user nor root, who " + "could point it elsewhere") + state = configured.resolve() + tree = [state, state / CONSOLE_DIRNAME] + checks = [(path, True) for path in tree] + [ + (path, False) for path in dict.fromkeys( + [*state.parents, *configured.parents])] + for directory, mine in checks: + if not present(directory): + continue + info = os.lstat(directory) if mine else os.stat(directory) + reason = (_console_dir_unsafe_because(info, uid=uid) + if directory == tree[1] + else _unsafe_because(info, uid=uid, own=mine)) + if reason is not None: + raise _unsafe(directory, reason) + + +def _open_private_directory(leaf: Path, *, state: Path) -> int: + """A descriptor on `leaf`, every missing directory on the way born 0700. + + `opendox.runtime.bundle._make_private_directories`, which it copies: each + missing component is made RELATIVE TO ITS PARENT'S DESCRIPTOR, under a + umask of 077, and opened with `O_NOFOLLOW` before anything is made beneath + it. The directory it starts from is judged by its descriptor first. The + caller owns the descriptor returned.""" + uid = os.getuid() + missing: list[str] = [] + base = leaf + while not os.path.lexists(base): + missing.append(base.name) + base = base.parent + flags = os.O_RDONLY | os.O_DIRECTORY + if not missing: + # It exists: open it without following a link, and judge what opened. + try: + descriptor = os.open(leaf, flags | os.O_NOFOLLOW) + except OSError: + info = os.lstat(leaf) + reason = _unsafe_because(info, uid=uid, own=True) + if reason is None: + raise + raise _unsafe(leaf, reason) from None + reason = _unsafe_because(os.fstat(descriptor), uid=uid, own=True) + if reason is not None: + os.close(descriptor) + raise _unsafe(leaf, reason) + return descriptor + descriptor = os.open(base, flags) + own = base == state or state in base.parents + reason = _unsafe_because(os.fstat(descriptor), uid=uid, own=own) + if reason is not None: + os.close(descriptor) + raise _unsafe(base, reason) + path = base + previous = os.umask(0o077) + try: + for name in reversed(missing): + path = path / name + with contextlib.suppress(FileExistsError): + os.mkdir(name, 0o700, dir_fd=descriptor) + try: + child = os.open(name, flags | os.O_NOFOLLOW, dir_fd=descriptor) + except OSError: + info = os.stat(name, dir_fd=descriptor, follow_symlinks=False) + reason = _unsafe_because(info, uid=uid, own=True) + if reason is None: + raise + raise _unsafe(path, reason) from None + os.close(descriptor) + descriptor = child + reason = _unsafe_because(os.fstat(descriptor), uid=uid, own=True) + if reason is not None: + raise _unsafe(path, reason) + except BaseException: + os.close(descriptor) + raise + finally: + os.umask(previous) + return descriptor + + +def _file_unsafe_because(info: os.stat_result, *, uid: int) -> str | None: + """Why a private copy is not one, or `None`.""" + mode = info.st_mode + if stat.S_ISLNK(mode): + return "is a symbolic link" + if not stat.S_ISREG(mode): + return "is not a regular file" + if info.st_uid != uid: + return f"is owned by uid {info.st_uid}, not by this user" + if info.st_nlink != 1: + return f"has {info.st_nlink} hard links, not one" + if stat.S_IMODE(mode) != PRIVATE_MODE: + return f"has mode {stat.S_IMODE(mode):o}, not {PRIVATE_MODE:o}" + return None + + +# --------------------------- the copy --------------------------- + +def _record_json(record: Mapping[str, Any]) -> str: + """The record as JSON that cannot end its own `` or open a tag.""" + text = json.dumps(dict(record), sort_keys=True, ensure_ascii=True) + return (text.replace("<", "\\u003c").replace(">", "\\u003e") + .replace("&", "\\u0026")) + + +def _opener_html(record: Mapping[str, Any]) -> str: + target = html.escape(str(record["opened_url"]), quote=True) + return ( + COPY_MARKER.decode("ascii") + + "\n" + '\n' + "\n" + '\n' + '\n' + f'\n' + "Opening openDox\n" + f'\n" + "\n" + "\n" + "

This private file opens this user's openDox console. If the page " + f'does not open, open openDox. Keep this file ' + "private: anyone who can read it can act as this console.

\n" + "\n" + "\n") + + +def _identity(path: Path | str) -> tuple[int, int] | None: + """`(st_dev, st_ino)` of what `path` names, or `None` where nothing + there can be asked.""" + try: + info = os.stat(path) + except (OSError, ValueError): + return None + return (info.st_dev, info.st_ino) + + +def _identities_above(path: Path) -> set[tuple[int, int]]: + """The identities of every directory above `path` that exists.""" + return {key for key in map(_identity, Path(path).parents) if key is not None} + + +def _refuse_a_served_state_dir(state_dir: Path, + served_roots: Iterable[Path | str], *, + port: int | None) -> None: + """The state directory and every root this plane serves may not overlap, + in EITHER direction, or the copy is refused before anything is written. + + RULED by the holder on openxFactory#1220's review (Copilot + `r4171166321`), mirroring T100's served-repository boundary + (`doxbench_trust`'s state directory): the token's copy must never sit + inside what the plane can serve, nor where a clone or an accidental + commit could carry it. So the state directory may not BE a served root + or lie inside one. And, by the holder's ruling on batch N's review + (Copilot `r4174345203`), no served root may be or lie inside the state + directory either: `/console` itself, or the bundle's tree beside + it, served as a root, would serve the copy (Copilot at openDox-code#84, + `r4173889265`, found the first of these). Asked of the RESOLVED paths, so + a link counts as where it leads. `port` names the copy the refusal is + about, and `None`, on a plane that writes none, names every console's. + + AND OF THE DIRECTORIES' OWN IDENTITIES (adversarial review of + openDox-code#84, B4). On a case-insensitive filesystem (macOS's default) + `/STATE` and `/state` are one directory, and resolving a + path keeps the case it was given, so names alone let the second + spelling of the state directory, or of a root, through. Every directory + that exists on either path is also compared by `(st_dev, st_ino)` + (`_identity`): the same directory is the same, however it is spelled.""" + resolved = Path(state_dir).resolve() + copy = (f"the copy {private_copy_path(resolved, port)}" if port is not None + else f"every console's private copy in {resolved / CONSOLE_DIRNAME}") + state_is = _identity(resolved) + above_state = _identities_above(resolved) + for root in served_roots: + served = Path(root).resolve() + served_is = _identity(served) + if resolved == served or (state_is is not None and state_is == served_is): + where = f"is the served repository ({served})" + elif served in resolved.parents or (served_is is not None + and served_is in above_state): + where = f"lies inside the served repository ({served})" + elif resolved in served.parents or (state_is is not None + and state_is in _identities_above(served)): + where = (f"holds {served}, a root this plane serves, so the plane " + f"would serve what the state directory keeps, {copy} " + "among it") + else: + continue + raise ConsoleAccessRefused( + f"{runtime_config.PREFIX}STATE_DIR ({state_dir}) {where}. The " + "state directory and every root this plane serves (through " + "`/source` or the static bundle) may not overlap, and a clone or a " + "commit could carry what lies inside a repository, so the console " + "token's private copy is refused there and nothing is written. Set " + f"{runtime_config.PREFIX}STATE_DIR to a directory apart from the " + "repositories and the bundle this machine serves") + + +def write_private_copy(state_dir: Path | str, *, page_url: str, port: int, + token: str, + served_roots: Iterable[Path | str]) -> PrivateCopy: + """Write the copy for the plane on `port`, mode 0600, or refuse. + + `served_roots` are the roots this plane serves: the state directory and + any of them may not overlap, in either direction, and that is asked + before anything is written. A file already at the copy's path is replaced + ONLY when it is this user's own regular file of mode 0600 with one link, + an earlier serve's copy for this port (#1144 12.4a). Anything else there, + a loosened copy included, is refused by name and never replaced. + + EVERY REFUSAL NAMES ITS PATH (the opener file's lifecycle, T104's + self-pass). An operating-system refusal on the way (a parent that will + not let this user make the state directory, a full disk) is a + `ConsoleAccessRefused` naming the copy, so the entry point refuses its + start by name instead of ending in a traceback. And a copy whose + read-back fails is removed with the refusal, so a start that never served + leaves no copy behind. + + THE STATE DIRECTORY IS WALKED ONCE (`_walked`, Copilot at + openDox-code#84, r4174785933), and the served-root boundary, the tree's + rules and the write all work on the path that walk returned. A link on + the configured path that is re-pointed after the checks cannot redirect + the write. The copy's `path` is that walked path. + + THE WALK IS INSIDE THE CONVERSION TOO (Copilot at openDox-code#84, + r4177975898): an overlong component (ENAMETOOLONG) or an unsearchable + parent (EACCES) on the way is a refusal by name, like any other. + + A PLATFORM WITHOUT THE POSIX PRIMITIVES is refused first, by name + (`unsupported_platform`).""" + _refuse_an_unsupported_platform() + target = private_copy_path(state_dir, port) + try: + state = _walked(state_dir) + _refuse_a_served_state_dir(state, tuple(served_roots), port=port) + record = { + "schema_version": RECORD_SCHEMA_VERSION, + "kind": RECORD_KIND, + "page_url": page_url, + "opened_url": opened_url(page_url, token), + "port": int(port), + "pid": os.getpid(), + FRAGMENT_KEY: token, + } + target = private_copy_path(state, port) + identity, reservation = _write_the_copy(state, target, record) + except OSError as exc: + raise ConsoleAccessRefused( + f"{target} cannot be written ({exc}), so the console token has no " + "private copy and the start is refused. Use a state directory this " + f"user can write ({runtime_config.PREFIX}STATE_DIR)") from None + copy = PrivateCopy(path=target, page_url=page_url, + opened_url=record["opened_url"], identity=identity, + reservation=reservation) + try: + read_private_copy(target) # what was written is what a reader accepts + except BaseException: + remove_private_copy(copy) + raise + return copy + + +def _write_the_copy(state: Path, target: Path, + record: Mapping[str, Any]) -> tuple[int, int]: + """`write_private_copy`'s writing half: the tree judged and made, the + name judged, the file written beside it and renamed into place. Returns + the written file's `(st_dev, st_ino)`.""" + _refuse_an_unsafe_tree(state, existing_only=True) + directory = _open_private_directory(target.parent, state=state) + uid = os.getuid() + temporary = f".{target.name}.opendox-{os.getpid()}" + try: + # Judged only after the directories exist: what `existing_only` could + # not see before they were made, it sees now. `console/` is judged by + # its descriptor too, its exact mode included. + _refuse_an_unsafe_tree(state, existing_only=False) + reason = _console_dir_unsafe_because(os.fstat(directory), uid=uid) + if reason is not None: + raise _unsafe(target.parent, reason) + _lock(directory) # until the copy is in place (`_lock`) + _sweep_stale_copies(directory, spare=target.name) + try: + present = os.stat(target.name, dir_fd=directory, + follow_symlinks=False) + except FileNotFoundError: + present = None + # THIS USER'S OWN regular file, of mode 0600, with one link, is an + # earlier serve's copy for this port, and is replaced. Anything else + # was PLANTED, LINKED or LOOSENED there, and is refused, never + # followed or replaced (#1144 12.4a). + reason = (None if present is None + else _file_unsafe_because(present, uid=uid)) + if reason is not None: + raise ConsoleAccessRefused( + f"{target} {reason}: something other than this user's own " + "private copy is at that name, so it is refused, never " + "followed or replaced") + if present is not None: + _refuse_a_running_consoles_copy(target, directory, present) + with contextlib.suppress(FileNotFoundError): + os.unlink(temporary, dir_fd=directory) # an interrupted start's; a link itself, never its target + handle = os.open(temporary, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + PRIVATE_MODE, dir_fd=directory) + # From here a failure (a full disk, an interrupt) removes the + # temporary file it made, so no partial copy is left beside the name. + # The descriptor stays open on success: it is the copy's RESERVATION + # (`_Reservation`), locked before the copy takes its name, so no other + # publication can find the name unreserved. + reservation = _Reservation(handle) + try: + os.fchmod(handle, PRIVATE_MODE) + data = _opener_html(record).encode("utf-8") + view = memoryview(data) + while view: + view = view[os.write(handle, view):] + os.fsync(handle) + written = os.fstat(handle) + if fcntl is not None: + with contextlib.suppress(OSError): # no locks here: unreserved + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + os.replace(temporary, target.name, src_dir_fd=directory, + dst_dir_fd=directory) + except BaseException: + reservation.close() + with contextlib.suppress(FileNotFoundError): + os.unlink(temporary, dir_fd=directory) + raise + finally: + os.close(directory) + return (written.st_dev, written.st_ino), reservation + + +def _refuse_a_running_consoles_copy(target: Path, directory: int, + present: os.stat_result) -> None: + """Refuse when the copy at `target` is RESERVED by a console that is still + running (`_Reservation`); return where it is a stale copy, to be replaced. + + The copy is opened without following a link or blocking, checked to be + the file judged a moment ago, and its lock asked for WITHOUT waiting: a + lock that is held is a running console's, and one that is free is a + stale copy's. Where the filesystem keeps no locks, nothing can be told, + and the copy is replaced, as before. The console directory's lock + (`_lock`) is held throughout, so no publication races this one.""" + try: + held = os.open(target.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=directory) + except FileNotFoundError: + return + try: + info = os.fstat(held) + if (info.st_dev, info.st_ino) != (present.st_dev, present.st_ino): + raise ConsoleAccessRefused( + f"{target} changed while it was judged, so it is refused, " + "never replaced") + if fcntl is None: + return + try: + fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise ConsoleAccessRefused( + f"{target} belongs to a console that is still running" + f"{_writer_of(held)}. Two serves on this port number share " + f"this state directory (for example one on 127.0.0.1 and one " + "on ::1), and a running console's copy is never replaced. " + "Stop that console, or serve on another port or with another " + f"{runtime_config.PREFIX}STATE_DIR") from None + except OSError: + return # no locks here: replace, as before + finally: + os.close(held) + + +def _sweep_stale_copies(directory: int, *, spare: str) -> None: + """Remove every copy in `console/` whose console is gone, `spare` aside. + + A SERVER THAT DIED LEFT ITS COPY (adversarial review of openDox-code#84, + B9). A SIGKILL, an out-of-memory kill or a power cut runs no cleanup, so + its copy, a token in it, stayed until a later serve happened to take the + same port. A publication now sweeps them. It runs with the console + directory's lock held (`_lock`), so no publication or removal is under + way: a copy whose lock is free belongs to no running console + (`_Reservation`), and a writer's temporary file or a remover's taken name + found then belongs to a process that died mid-way. Only this user's own + regular files of mode 0600 with one link, named as those are named + (`_SWEEPABLE`), are swept, each only while its name is still the file + whose lock was taken. A lock still held, or a filesystem that keeps no + locks, tells nothing, and the file stays. `spare` is the name this + publication judges itself (`_refuse_a_running_consoles_copy`).""" + if fcntl is None: + return + uid = os.getuid() + try: + names = os.listdir(directory) + except OSError: + return + for name in names: + if name == spare or not _SWEEPABLE.fullmatch(name): + continue + with contextlib.suppress(OSError): + handle = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=directory) + try: + info = os.fstat(handle) + if _file_unsafe_because(info, uid=uid) is not None: + continue + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) # held: stays + still = os.stat(name, dir_fd=directory, follow_symlinks=False) + if (still.st_dev, still.st_ino) == (info.st_dev, info.st_ino): + os.unlink(name, dir_fd=directory) + finally: + os.close(handle) + + +def _writer_of(handle: int) -> str: + """`" (pid N)"` from the copy's own record, or nothing.""" + with contextlib.suppress(OSError, ValueError, AttributeError): + found = _RECORD_PATTERN.search( + os.pread(handle, _READ_LIMIT, 0).decode("utf-8", "replace")) + pid = json.loads(found.group("record")).get("pid") + if isinstance(pid, int): + return f" (pid {pid})" + return "" + + +def read_private_copy(path: Path | str) -> dict: + """The record in the copy at `path`, or a refusal naming why: an + operating-system error on the way (an overlong component, an unsearchable + parent) included (Copilot at openDox-code#84, r4177975898). A platform + without the POSIX primitives is refused first, by name + (`unsupported_platform`).""" + _refuse_an_unsupported_platform() + try: + return _read_the_copy(path) + except OSError as exc: + raise ConsoleAccessRefused( + f"{path} cannot be read ({exc}), so it is not a private copy this " + "user can use") from None + + +def _read_the_copy(path: Path | str) -> dict: + """The record in the copy at `path`, or a refusal naming why. + + The tree is judged again, and the file by its own descriptor, opened + without following a link and without blocking: a regular file, this + user's, exactly 0600, with one link. A planted, linked or loosened copy is + refused, and so is a FIFO, without waiting on it.""" + given = Path(path) + if given.parent.name != CONSOLE_DIRNAME: + raise ConsoleAccessRefused(f"{given} is not in a `{CONSOLE_DIRNAME}/` " + "directory of a state directory") + # Walked once, as the writer walks it, and read from where the walk led. + state = _walked(given.parent.parent) + target = state / CONSOLE_DIRNAME / given.name + try: + _refuse_an_unsafe_tree(state, existing_only=False) + except FileNotFoundError: + raise ConsoleAccessRefused(f"{target} does not exist: no plane wrote a " + "private copy there") from None + uid = os.getuid() + # NEVER MADE BY A READ: the directory is opened as it is, without + # following a link, and judged by its descriptor. + try: + directory = os.open(target.parent, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + except OSError: + info = os.lstat(target.parent) + raise _unsafe(target.parent, _unsafe_because(info, uid=uid, own=True) + or "cannot be opened") from None + try: + reason = _console_dir_unsafe_because(os.fstat(directory), uid=uid) + if reason is not None: + raise _unsafe(target.parent, reason) + # `O_NONBLOCK` (Copilot at openDox-code#84, r4174674702): a FIFO + # planted at the name, with no writer, would block a plain read-only + # `open` forever, before the descriptor's regular-file check below + # could refuse it. A regular file reads the same either way. + try: + handle = os.open(target.name, + os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=directory) + except FileNotFoundError: + raise ConsoleAccessRefused(f"{target} does not exist: no plane on " + "that port wrote a private copy") from None + except OSError: + info = os.stat(target.name, dir_fd=directory, follow_symlinks=False) + reason = _file_unsafe_because(info, uid=uid) or "cannot be opened" + raise ConsoleAccessRefused(f"{target} {reason}") from None + try: + reason = _file_unsafe_because(os.fstat(handle), uid=uid) + if reason is not None: + raise ConsoleAccessRefused( + f"{target} {reason}, so it is not this user's private copy") + data = os.read(handle, _READ_LIMIT) + finally: + os.close(handle) + finally: + os.close(directory) + match = _RECORD_PATTERN.search(data.decode("utf-8", "replace")) + if match is None: + raise ConsoleAccessRefused(f"{target} carries no console record") + try: + record = json.loads(match.group("record")) + except ValueError: + raise ConsoleAccessRefused(f"{target}'s console record is not JSON") from None + if (not isinstance(record, dict) or record.get("kind") != RECORD_KIND + or record.get("schema_version") != RECORD_SCHEMA_VERSION): + raise ConsoleAccessRefused(f"{target}'s console record is not a " + f"{RECORD_KIND} v{RECORD_SCHEMA_VERSION}") + token = _refuse_token(record.get(FRAGMENT_KEY)) + page_url = record.get("page_url") + if not isinstance(page_url, str) or record.get("opened_url") != opened_url( + page_url, token): + raise ConsoleAccessRefused(f"{target}'s console record does not open " + "its own page with its own token") + return record + + +def remove_private_copy(copy: PrivateCopy | None) -> None: + """Remove `copy` when the server stops, if it is still the file written. + + A later serve on the same port writes a file of its own, and that one is + left alone. Never raises: a copy already gone is the goal reached. + + THE NAME IS TAKEN BEFORE IT IS JUDGED (Copilot at openDox-code#84, + r4173806552). Checking the name's identity and + then unlinking it are two steps, and a replacement written between them + would be the file unlinked. So the name is first RENAMED to a name only + this process uses, atomically, and what was renamed is judged: this + process's own file is removed, and anything else is linked back under the + name (never over a still newer copy) and its temporary name removed. The + entry points also remove the copy BEFORE they close the listening socket, + so no later serve can bind the port, and write its own copy, until this + one is gone. + + PUT BACK BY A RENAME WHERE A HARD LINK CANNOT BE MADE (T104's self-pass). + A filesystem without hard links refuses the link (EPERM), and so does a + directory, and the other serve's copy used to be deleted with the + temporary name. It is renamed back instead, where the name is still + free, and the console directory's lock (`_lock`) keeps any newer copy + from being published between that check and the rename.""" + if copy is None: + return + try: + directory = os.open(copy.path.parent, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + except OSError: + if copy.reservation is not None: # nothing left to remove: unreserve + copy.reservation.close() + return + name = copy.path.name + taken = f".{name}.removing-{os.getpid()}-{os.urandom(6).hex()}" + try: + _lock(directory) # no copy is published meanwhile (`_lock`) + try: + os.rename(name, taken, src_dir_fd=directory, dst_dir_fd=directory) + except OSError: + return # nothing there: already gone + with contextlib.suppress(OSError): + info = os.stat(taken, dir_fd=directory, follow_symlinks=False) + if (info.st_dev, info.st_ino) != copy.identity: + # ANOTHER SERVE'S COPY: put it back under its name, unless a + # still newer one has arrived there, which then stands. + try: + os.link(taken, name, src_dir_fd=directory, + dst_dir_fd=directory, follow_symlinks=False) + except FileExistsError: + pass + except OSError: + if not _name_exists(name, directory): + os.rename(taken, name, src_dir_fd=directory, + dst_dir_fd=directory) + with contextlib.suppress(OSError): + os.unlink(taken, dir_fd=directory) + finally: + # Its RESERVATION goes with it, the file gone and the console + # directory still locked, so no publication sees it in between. + if copy.reservation is not None: + copy.reservation.close() + os.close(directory) + + +def _name_exists(name: str, directory: int) -> bool: + try: + os.stat(name, dir_fd=directory, follow_symlinks=False) + except FileNotFoundError: + return False + return True + + +class ConsoleTerminated(KeyboardInterrupt): + """SIGTERM, SIGHUP or Ctrl-C, raised as the interrupt the serve loops + already stop on.""" + + +#: Whether a stop is being HELD (`deferred_termination`), the one that +#: arrived meanwhile, and whether a stop has already been RAISED. Python runs +#: signal handlers in the main thread only, as the entry points publish and +#: remove there, so plain module state serves. +_held = {"depth": 0, "pending": None, "stopping": False} + + +def _terminate_as_interrupt(signum, frame): + """A stop, raised once. + + THE FIRST STOP IS THE ONLY ONE RAISED (adversarial review of + openDox-code#84, B5). A double Ctrl-C, or a SIGTERM and then the SIGHUP + of a closing terminal, could land its second signal after the first had + unwound the serve loop and before the cleanup's `deferred_termination` + held anything, and that second interrupt escaped the `finally` and left + the copy. Once one stop is raised, every later one is only recorded, and + the cleanup runs to its end.""" + if _held["depth"] or _held["stopping"]: + _held["pending"] = signum + return + _held["stopping"] = True + raise ConsoleTerminated + + +@contextlib.contextmanager +def deferred_termination(*, raise_pending: bool = True): + """Hold a SIGTERM or SIGHUP that arrives inside the block, rather than + raising it in the middle of publishing or removing a copy (Copilot at + openDox-code#84, r4175213864): a copy half published, or half removed, + is one nothing cleans up. On the way out, a held stop is raised as + `ConsoleTerminated` once the block is done, when the copy is in the + caller's hands, or dropped with `raise_pending=False`, for a removal, + which is a stop already. Only the handler `terminate_as_interrupt` + installs holds anything: SIGTERM, SIGHUP where it has its default, and + Ctrl-C where it has Python's own.""" + _held["depth"] += 1 + try: + yield + finally: + _held["depth"] -= 1 + if not _held["depth"]: + pending, _held["pending"] = _held["pending"], None + if pending is not None and raise_pending and not _held["stopping"]: + _held["stopping"] = True # raised once (`_terminate_as_interrupt`) + raise ConsoleTerminated + + +@contextlib.contextmanager +def terminate_as_interrupt(enabled: bool): + """While a standalone console's private copy exists, read SIGTERM as the + Ctrl-C the serve loops already stop cleanly on, so a plain `kill ` + unwinds through the code that removes the copy (Copilot at + openDox-code#84, r4173806590). The handler it replaces is put back on the + way out. `enabled` is False wherever no copy was written, a host's plane + or a plane with no token, and then nothing changes: those planes keep the + signal's default action exactly as before. Off the main thread no handler + can be installed, and nothing is. + + AND SIGHUP (T104's self-pass), which a closed terminal sends and whose + default action ends the process with the copy left behind. It is read the + same way, but only where it still has its default action: a process + started ignoring it (`nohup`) keeps ignoring it. + + AND CTRL-C (Copilot at openDox-code#84, r4178041022). Python's own + SIGINT handler raises at once, so a Ctrl-C just after the copy's rename + into place, or just after a removal's take, bypassed + `deferred_termination` and left a copy, or a `.removing-*` file, behind. + It is taken the same way, still raised as a `KeyboardInterrupt`, but only + where it still has Python's own handler: an ignored SIGINT, or a host's + own handler, is left exactly as it was.""" + if not enabled: + yield + return + signals = [signal.SIGTERM] + hangup = getattr(signal, "SIGHUP", None) + if hangup is not None and signal.getsignal(hangup) == signal.SIG_DFL: + signals.append(hangup) + if signal.getsignal(signal.SIGINT) is signal.default_int_handler: + signals.append(signal.SIGINT) + previous: dict = {} + try: + for signum in signals: + previous[signum] = signal.signal(signum, _terminate_as_interrupt) + except ValueError: + for signum, handler in previous.items(): + signal.signal(signum, handler) + yield + return + _held.update(pending=None, stopping=False) + try: + yield + finally: + for signum, handler in previous.items(): + signal.signal(signum, handler) + _held.update(pending=None, stopping=False) + + +def is_copy_bytes(data: bytes) -> bool: + """Whether `data`, a file's bytes from its start, are a console token's + private copy's: they begin with its `COPY_MARKER`, written in full or + caught part way through its write (Copilot at openDox-code#84, + r4179793524), or they carry a whole console record (`RECORD_KIND`) in + the element a copy keeps it in. Fewer bytes than the marker hold no + token, and are not a copy's.""" + if data.startswith(COPY_MARKER): + return True + found = _RECORD_PATTERN.search(data[:_READ_LIMIT].decode("utf-8", "replace")) + if found is None: + return False + try: + record = json.loads(found.group("record")) + except ValueError: + return False + return isinstance(record, dict) and record.get("kind") == RECORD_KIND + + +def _carries_a_console_record(handle: int, info: os.stat_result) -> bool: + """Whether the regular file open on `handle` IS a console token's private + copy by what it holds (`is_copy_bytes`), wherever the file lies and + whatever its name, a copy still being written included. + + Read with `pread`, from the descriptor already open, so it needs no new + descriptor and does not move the offset the caller then reads from. A + regular file whose head cannot be read is judged to be a copy: a check + that cannot be made denies, never allows. What the caller then SENDS is + judged again by its own bytes (`serve.read_unless_private`, + `serve.DashboardHandler.copyfile`), since a file can grow after this.""" + if not stat.S_ISREG(info.st_mode): + return False + try: + head = os.pread(handle, _READ_LIMIT, 0) + except OSError: + return True + return is_copy_bytes(head) + + +def is_private_file(handle: int, private_roots: Iterable[Path | str]) -> bool: + """Whether the file open on `handle` is a console token's private copy. + + Judged by the FILE'S OWN IDENTITY, `(st_dev, st_ino)`, against every name + in each directory `publish` marked private (Copilot at openDox-code#84, + r4178133842). A path cannot tell: a served root re-pointed at the state + directory after publication, a link swapped after a check, or a hard link + made anywhere under a served root all reach the copy by a name that looks + like something else. The identity of what was OPENED cannot be swapped + afterwards. Every name in the directory counts, every port's copy and a + temporary name included. + + AND BY WHAT THE FILE HOLDS (Copilot at openDox-code#84, r4179239380 and + r4179239411). A copy in ANOTHER state directory (a second standalone + plane of the same user, with its own `OPENDOX_STATE_DIR`) is in no + directory this plane marked, and a copy removed between this open and + the directory's scan has no name left there to match. Both are still a + file that holds a console record, so the file open on `handle` is judged + by its own bytes first (`_carries_a_console_record`), and by its + identity after. + + A SCAN THAT FAILS DENIES (Copilot at openDox-code#84, r4179239424). A + private directory that does not exist holds no copy, and counts for + nothing. One that exists and cannot be listed (out of descriptors, say) + cannot clear the file, so the file is judged to be a copy; so is a name + in it whose status cannot be read for any reason but its removal.""" + info = os.fstat(handle) + if _carries_a_console_record(handle, info): + return True + key = (info.st_dev, info.st_ino) + for root in private_roots: + try: + with os.scandir(root) as entries: + for entry in entries: + try: + found = entry.stat(follow_symlinks=False) + except FileNotFoundError: + continue # removed meanwhile + except OSError: + return True # cannot be cleared: denied + if (found.st_dev, found.st_ino) == key: + return True + except FileNotFoundError: + continue # no directory: no copy in it + except OSError: + return True # cannot be listed: denied + return False + + +def within_private_roots(path: Path | str, + private_roots: Iterable[Path | str]) -> bool: + """Whether `path`, where it leads, IS a private-copy directory or lies + inside one: by name, and by the identity of every directory on its way. + + A case-insensitive filesystem (macOS's default) has more than one + spelling for each directory, and resolving a path keeps the case it was + given, so `/state-alias/CONSOLE/` named the copies' directory under + a name no private root spells (adversarial review of openDox-code#84, + B4). So the resolved path is also judged by `(st_dev, st_ino)`: where it, + or any directory above it, is a private root by identity, it is that + root, however it is spelled. A private root that does not exist yet is + judged by name alone, as nothing can lie inside it.""" + target = Path(path).resolve() + roots = [Path(root) for root in private_roots] + if any(target == root or root in target.parents for root in roots): + return True + marked = {key for key in map(_identity, roots) if key is not None} + if not marked: + return False + return bool(marked & ({_identity(target)} | _identities_above(target))) + + +def opens_a_private_file(path: Path | str, + private_roots: Iterable[Path | str]) -> bool: + """Whether what opens at `path` is a file in a private-copy directory + (`is_private_file`). Opened without blocking, so a FIFO cannot stall the + check; a path that does not open is no private file, and is left to the + caller to answer as it always has.""" + try: + handle = os.open(path, os.O_RDONLY | os.O_NONBLOCK) + except OSError: + return False + try: + return is_private_file(handle, private_roots) + finally: + os.close(handle) + + +def needs_copy(httpd: Any) -> bool: + """Whether the plane `httpd` delivers its console token through a + private copy: a standalone plane that minted one. The entry points ask it + BEFORE `publish`, to read a stop as Ctrl-C from before the copy exists.""" + return bool(getattr(httpd, "console_token", None)) and _standalone(httpd) + + +def _standalone(httpd: Any) -> bool: + return getattr(httpd, "console_token_delivery", None) == DELIVERY_OPENED_URL + + +def guard_private_roots(httpd: Any, state_dir: Path | str) -> None: + """Keep a standalone plane that WRITES NO COPY from serving another's. + + A SIBLING PLANE SERVED ANOTHER PLANE'S COPY (adversarial review of + openDox-code#84, B1). Two standalone planes of one user share the state + directory. One that minted no token (no git identity, so no session + verbs) wrote no copy, so it asked no boundary and marked no private + root, and a root it served that held the state directory served the + other plane's copy, token and all, to any local caller. The boundary is + the PLANE's, not the copy's: this one is asked of the state directory as + `write_private_copy` asks it, by name and by identity, and refuses the + start by name, and the copies' directory is marked private + (`httpd.private_roots`), so the static handler, `/source` and + `/snapshot.json` refuse every console's copy here too. + + ONE WALK, AS THE WRITER WALKS (Copilot at openDox-code#84, + r4179091592). The boundary and the marking each resolved the configured + path for themselves, so a link on it re-pointed between the two left the + boundary judging the real state directory and the marking naming a + decoy. The state directory is walked once (`_walked`), every directory + and link on the way judged as the writer judges them, and the boundary + and the marking both use the path that walk returned. An operating-system + refusal on the way is a refusal by name, as it is for the writer. + + AND THE PLATFORM FIRST (Copilot at openDox-code#84, r4179091624): this + plane's handlers judge files by the same POSIX primitives, so a platform + without them is refused by name here too (`unsupported_platform`).""" + _refuse_an_unsupported_platform() + try: + state = _walked(state_dir) + _refuse_a_served_state_dir(state, tuple(getattr(httpd, "served_roots", ())), + port=None) + except OSError as exc: + raise ConsoleAccessRefused( + f"{private_copy_path(state_dir, 0).parent} cannot be judged ({exc}), " + "so this standalone plane cannot keep the console tokens' private " + "copies unserved, and the start is refused. Use a state directory " + f"this user can reach ({runtime_config.PREFIX}STATE_DIR)") from None + httpd.private_roots = (state / CONSOLE_DIRNAME,) + + +def publish(httpd: Any, *, page_url: str, + env: Mapping[str, str] | None = None) -> PrivateCopy | None: + """What an ENTRY POINT does after `serve.build_server`. On a standalone + plane that minted a token, write the private copy into the install's + state directory and return it. On a standalone plane that minted none, + write nothing, and still keep the boundary and mark every console's copy + private (`guard_private_roots`), so the DELIVERY'S RULES do not depend on + the token. Otherwise, a host's plane, `None`, and nothing changes. + + A state directory that cannot be named, or a tree that is not this user's + alone, refuses (`ConsoleAccessRefused`), and the entry point refuses with + it: a console nobody can open is not served as if it could be.""" + if not _standalone(httpd): + return None + _refuse_an_unsupported_platform() # token or not (r4179091624) + try: + state = runtime_config.state_dir(env) + except runtime_config.ConfigurationError as exc: + raise ConsoleAccessRefused( + "the console tokens' private copies have no state directory, so " + f"this standalone plane cannot keep them unserved: {exc}") from None + if not needs_copy(httpd): + guard_private_roots(httpd, state) + return None + token = httpd.console_token + copy = write_private_copy(state, page_url=page_url, + port=int(httpd.server_address[1]), token=token, + served_roots=getattr(httpd, "served_roots", ())) + # THE STATIC HANDLER NEVER SERVES A COPY (Copilot at openDox-code#84, + # r4173889294). It follows links inside `--web-dir` (a governed host's + # composed web root is made of them), so a link out of the bundle into the + # state directory would reach the copies. The handler refuses every static + # request whose resolved target is this directory or lies inside it, by + # name or by identity (`within_private_roots`), every port's copy included + # (`serve.DashboardHandler.send_head`). + httpd.private_roots = (copy.path.parent.resolve(),) + return copy diff --git a/src/opendox/serve.py b/src/opendox/serve.py index 41ef1c71..fae584f0 100644 --- a/src/opendox/serve.py +++ b/src/opendox/serve.py @@ -105,6 +105,7 @@ import functools import http.server import json +import os import secrets import socket import subprocess @@ -175,6 +176,9 @@ # `opendox.runtime.config` and `opendox.corpus_adapter` besides the stdlib. from opendox import corpus_adapter # noqa: E402 from opendox.runtime import local_git_adapter # noqa: E402 +# THE CONSOLE TOKEN'S DELIVERY on a standalone plane (plan 034 T104): which +# delivery a plane uses, and the private copy an entry point writes. +from opendox import console_access # noqa: E402 # THE GENERATOR SEAM'S DEFAULT (5.4; plan 034 T052): openDox's own snapshot # generator, which `build_server()` and `main()` register where no host has. # Both modules are stdlib-only and name no sibling, so this adds no reach. @@ -681,10 +685,15 @@ def hosted_ref_refused(loopback: bool, ref: str | None) -> bool: # The realization is a HUMAN CONSOLE test, applied to the session verbs before the # body is parsed, exactly where the other two clauses live: # -# 1. a per-serve TOKEN, minted at start-up and published ONLY on -# `/capabilities`. The served page reads it same-origin; a cross-origin page -# cannot read a same-origin JSON response at all, so the drive-by class is -# structurally out. +# 1. a per-serve TOKEN, minted at start-up. On a HOST's plane it is published +# ONLY on `/capabilities`: the served page reads it same-origin, and a +# cross-origin page cannot read a same-origin JSON response at all, so the +# drive-by class is structurally out. On a STANDALONE plane it is not on +# `/capabilities` at all (plan 034 T104; RULED openxFactory#656 +# `5963851934`, adversarial review 2's M5): the page is opened with it in +# the URL's FRAGMENT, through a 0600 private copy in the state directory +# (`console_access`), so another OS user of the machine cannot simply ask +# this loopback server for it. # 2. a same-origin `Origin`/`Referer` when the caller sends one, so a browser # that CAN reach the plane cannot borrow the human's session from another # site. @@ -692,7 +701,8 @@ def hosted_ref_refused(loopback: bool, ref: str | None) -> bool: # # What this HONESTLY does not do, stated so no reader over-reads it: a process # already running as the engineer, on the engineer's own machine, can `GET -# /capabilities` and present the token. Hardening THAT is the xForge host's +# /capabilities` on a host's plane, or read the private copy on a standalone +# one, and present the token. Hardening THAT is the xForge host's # concern (the pre-existing ruling recorded at `cli.py`'s `_human_gate` and D22), # not this local console's. What the check removes is every caller that cannot # demonstrate it came from the console this serve started — which is the whole of @@ -803,6 +813,33 @@ def host_names_this_loopback_serve(host_lines, port: int, # --------------------------- source-path containment (pure) --------------------------- +def read_unless_private(path: Path | str, private_roots) -> bytes | None: + """The bytes of `path`, or None where the file it OPENS is a console + token's private copy (`console_access.is_private_file`, plan 034 T104). + + Every route that reads a file for any caller, with no console check, + reads through this (Copilot at openDox-code#84, r4178133842): `/source` + and `/snapshot.json`. A root or a snapshot named through a link is + resolved again on every request, and could be re-pointed at the state + directory after the copy was published; a hard link reaches the copy by + another name. The file actually opened is judged, so neither is served. + With no private root (a host's plane) it reads as before. + + THE BYTES READ ARE JUDGED, NOT ONLY THE FILE (Copilot at + openDox-code#84, r4179793524). A copy being written in another state + directory grows: judged before the read, it could hold no token yet, and + hold one by the time it was read. So the file is read first, and what + was read is judged by its own bytes (`console_access.is_copy_bytes`) as + well as the file by its identity.""" + with open(path, "rb") as stream: + data = stream.read() + if private_roots and ( + console_access.is_copy_bytes(data) + or console_access.is_private_file(stream.fileno(), private_roots)): + return None + return data + + def resolve_source_path(checkout_root: Path, url_tail: str) -> Path | None: """Resolve a `/source/` request to an absolute file under `checkout_root`, or None to reject. Rejects absolute paths, NUL bytes, any @@ -1324,6 +1361,115 @@ def _route(self, head_only: bool) -> bool: return True return False + def send_head(self): + """The static bundle's file, as `SimpleHTTPRequestHandler` serves it, + except a target inside a console token's private-copy directory. + + The stdlib handler follows links inside `--web-dir`, and a governed + host's composed web root is MADE of links out of it, so links are not + refused wholesale. But a link into the state directory must never + serve the token's copy (plan 034 T104; Copilot at openDox-code#84, + r4173889294): a target whose RESOLVED path is a directory the entry + point marked private (`console_access.publish` sets + `private_roots` on the server, on every standalone plane, whether it + wrote a copy or not), or lies inside one, is a 404, for GET and HEAD, + files and listings alike. A host's plane marks nothing, and serves + exactly as before. + + BY NAME AND BY IDENTITY (adversarial review of openDox-code#84, B4): + a case-insensitive filesystem spells the copies' directory more than + one way, so where the resolved target, or a directory above it, is a + private root by `(st_dev, st_ino)`, it is that root + (`console_access.within_private_roots`). + + A DIRECTORY REQUEST IS JUDGED BY WHAT IT SERVES (Copilot at + openDox-code#84, r4174674625). For `/sub/` the stdlib handler serves + the directory's first index page that exists (`index_pages`: + `index.html`, then `index.htm`), so the index page it would pick is + judged as well as the directory, and `web/sub/index.html` linked to a + copy is a 404 like the link itself. + + AND BY THE IDENTITY OF THE FILE (Copilot at openDox-code#84, + r4178133842). A path cannot tell a hard link to the copy from any + other file, so the file the handler would serve is opened and judged + by `(st_dev, st_ino)` first (`console_access.is_private_file`), and a + private copy is a 404. The file the stdlib handler then opens is + judged the same way, against a link swapped in between: its headers + are already sent by then, so it is closed unread and its bytes are + never written.""" + private = getattr(self.server, "private_roots", ()) + # Judged afresh for every request: a length judged for an earlier one + # never bounds this one's body, should a handler ever serve several. + self._judged_length = None + if private: + path = Path(self.translate_path(self.path)) + judged = [path] + if path.is_dir(): + for name in getattr(self, "index_pages", ("index.html", "index.htm")): + if (path / name).is_file(): + judged.append(path / name) + break + for candidate in judged: + if (console_access.within_private_roots(candidate, private) + or console_access.opens_a_private_file(candidate, private)): + self.send_error(404, "File not found") + return None + stream = super().send_head() + if private and stream is not None: + try: + handle = stream.fileno() + except (AttributeError, OSError, ValueError): + handle = None # a directory listing, in memory + if handle is not None and console_access.is_private_file(handle, private): + stream.close() + self.close_connection = True # its promised body never comes + return None + if handle is not None: + # What `copyfile` may send: the file as long as it was when + # judged, and its own first bytes judged again as they are + # sent (`copyfile`). + self._judged_length = os.fstat(handle).st_size + return stream + + def copyfile(self, source, outputfile): + """The static body, as `SimpleHTTPRequestHandler` copies it, except + on a plane that marked a private-copy directory. + + A FILE CAN GROW AFTER IT WAS JUDGED (Copilot at openDox-code#84, + r4179793524). A copy being written in another state directory holds + no token in its first bytes, and the stdlib copies to the end of the + file as it is when it reads, not as it was when `send_head` judged it. + So no more than the judged length is sent, and the body's first bytes, + read before anything is sent, are judged by what they are: a copy's + (`console_access.is_copy_bytes`) are never sent, and the connection + is closed, as the backstop closes it. Bytes shorter than a copy's + marker hold no token, and only they are sent.""" + limit = getattr(self, "_judged_length", None) + self._judged_length = None + if limit is None: + return super().copyfile(source, outputfile) + need = min(limit, len(console_access.COPY_MARKER)) + head = b"" + while len(head) < need: + chunk = source.read(need - len(head)) + if not chunk: + break + head += chunk + if console_access.is_copy_bytes(head): + self.close_connection = True + return None + outputfile.write(head) + remaining = limit - len(head) + if len(head) < need: + return None + while remaining > 0: + chunk = source.read(min(64 * 1024, remaining)) + if not chunk: + break + outputfile.write(chunk) + remaining -= len(chunk) + return None + def do_GET(self): # noqa: N802 if not self._route(head_only=False): super().do_GET() @@ -1361,12 +1507,33 @@ def _read_snapshot(self, entry=None) -> bytes | None: pass the hosted refusal with one entry and serve another's bytes (FR-048).""" if entry is not None: - return entry.read_bytes() + return self._entry_bytes(entry) try: - return Path(self.snapshot_path).read_bytes() + return read_unless_private( + self.snapshot_path, getattr(self.server, "private_roots", ())) except OSError: return None + def _entry_bytes(self, entry) -> bytes | None: + """A registered entry's snapshot bytes, read as `read_unless_private` + reads, where this plane marked a private-copy directory and the entry + names its file. Otherwise the entry reads itself, as before. + + THE IN-MEMORY PAYLOAD STILL COMES FIRST (Copilot at openDox-code#84, + at af2a2efb): `SnapshotEntry.read_bytes` serves an entry's payload + before its file, and only the FILE is guarded, so an entry with both + serves its payload whether or not the file exists.""" + private = getattr(self.server, "private_roots", ()) + path = getattr(entry, "snapshot_path", None) + if getattr(entry, "payload", None) is not None: + return entry.read_bytes() + if private and path is not None: + try: + return read_unless_private(path, private) + except OSError: + return None + return entry.read_bytes() + def _serve_snapshot(self, head_only: bool) -> None: """`/snapshot.json`: the active snapshot, or the registered `(repository, ref)` the query names. An unknown pair is a 404, and the @@ -1399,7 +1566,7 @@ def _serve_snapshot(self, head_only: bool) -> None: return if self._hosted_entry_refused(entry): return - self._serve_bytes(entry.read_bytes(), JSON_CTYPE, head_only, + self._serve_bytes(self._entry_bytes(entry), JSON_CTYPE, head_only, entry=entry) return if repository: @@ -1514,10 +1681,17 @@ def _serve_source(self, tail: str, head_only: bool) -> None: self.end_headers() return try: - body = target.read_bytes() + body = read_unless_private( + target, getattr(self.server, "private_roots", ())) except OSError: self.send_error(404, "unreadable source") return + if body is None: # a console token's private copy + self.send_response(404) + self._divergence_headers(entry) + self.send_header("Content-Length", "0") + self.end_headers() + return ctype = "text/markdown; charset=utf-8" if target.suffix == ".md" else "text/plain; charset=utf-8" self._serve_bytes(body, ctype, head_only, entry=entry) @@ -2235,12 +2409,27 @@ def build_server( route_bindings=route_bindings, ) # The human console's per-serve token (FR-019's third clause, review finding - # 2). Minted only where session verbs exist at all, and published on - # `/capabilities` — the one route the served page reads same-origin and no - # cross-origin page can read. + # 2). Minted only where session verbs exist at all. + # + # WHERE IT IS DELIVERED depends on whose plane this is (plan 034 T104; + # RULED openxFactory#656 `5963851934`). On a HOST's plane it is published on + # `/capabilities`, the one route the served page reads same-origin and no + # cross-origin page can read, as it always was. On a STANDALONE plane, + # built from openDox's own default profile, it is NOT: any loopback caller + # can read `/capabilities`, other OS users of the machine included. The + # entry point writes it into a 0600 private copy instead and opens the page + # with it in the URL's fragment (`console_access.publish`). The routes that + # require it require it exactly as before; only the delivery differs. + # + # THE DELIVERY IS THE PLANE'S, TOKEN OR NOT (adversarial review of + # openDox-code#84, B1): a standalone plane that minted none still keeps + # the state directory's boundary and never serves another plane's copy + # (`console_access.guard_private_roots`), so it is named a standalone + # plane's whether or not a token was minted. console_token = (mint_console_token() if capabilities["actions"]["session"] else None) - if console_token: + console_delivery = console_access.delivery_for(domain_profile.current()) + if console_token and console_delivery == console_access.DELIVERY_CAPABILITIES: capabilities[CONSOLE_TOKEN_FIELD] = console_token # THE ONE REPOSITORY THIS SERVE CAN WRITE TO. A plane reaching several # repositories serves them all for READING through per-entry source roots, @@ -2420,7 +2609,38 @@ def build_server( # trace on the first live connection. route_extension.resolve_handlers(route_bindings, bound) factory = functools.partial(bound, directory=str(web_dir)) - return _server_class_for(host)((host, port), factory) + httpd = _server_class_for(host)((host, port), factory) + # FOR THE ENTRY POINT, which delivers the token where `/capabilities` does + # not (`console_access.publish`): the token (`None` where none was + # minted), and which delivery this plane uses, whether or not it was. + httpd.console_token = console_token + httpd.console_token_delivery = console_delivery + # ...and EVERY root this plane serves files from, which the token's copy + # may not sit in (Copilot at openDox-code#84, r4173806506): the checkout, + # the static bundle's directory, each declared source root, the root of + # each entry the registry holds now (the bootstrapped session worktrees + # among them), and, on a loopback plane, the sessions container every + # later session worktree is made in (`branch_session.sessions_root`). + # + # AND THE SNAPSHOT FILES `/snapshot.json` READS DIRECTLY (Copilot at + # openDox-code#84, r4175213798), not through the static handler: the + # configured snapshot, each registered entry's, and, on a loopback plane, + # the container every session's snapshot is written in. A snapshot named + # at an earlier copy would otherwise be replaced by the new one and served. + served = [checkout_root, web_dir, snapshot_path, + *(Path(path).resolve() for path in (source_roots or {}).values())] + if loopback: + from opendox import branch_session as session_mod + served.append(session_mod.sessions_root(checkout_root)) + served.append(session_mod.snapshots_root(checkout_root)) + entries = getattr(source.registry, "entries", None) + for entry in (entries() if callable(entries) else ()): + for root in (getattr(entry, "source_root", None), + getattr(entry, "snapshot_path", None)): + if root: + served.append(Path(root).resolve()) + httpd.served_roots = tuple(dict.fromkeys(served)) + return httpd class _IPv6ThreadingHTTPServer(http.server.ThreadingHTTPServer): @@ -2527,18 +2747,45 @@ def serve( checkout_root=checkout_root)) httpd = build_server(web_dir, snapshot_path, checkout_root, host=host, port=port, quiet=quiet, actor=actor, **build_kwargs) - # FLUSHED before the process blocks (plan 034 T056): where standard - # output is a pipe or a file it is block-buffered, so an unflushed line - # never reaches a wrapper while the server runs, and the wrapper cannot - # learn an ephemeral port or tell that the server started. - print(f"serving ideation dashboard at {server_url(httpd, '/index.html')}", - flush=True) - try: - httpd.serve_forever() - except KeyboardInterrupt: - pass - finally: - httpd.server_close() + page = server_url(httpd, "/index.html") + # THE CONSOLE TOKEN'S PRIVATE COPY on a standalone plane (plan 034 T104), + # as `cli.cmd_generate_and_open` writes it: its PATH is printed, never the + # token, and it goes when the server does. A copy that cannot be written + # safely refuses the start (`console_access.ConsoleAccessRefused`). + # + # A plain `kill`, or a closed terminal, stops a standalone console the way + # Ctrl-C does, from BEFORE the copy is written to after it is removed, and + # a stop that arrives while the copy is written or removed is held until + # that is done (Copilot at openDox-code#84, r4175213864). A plane that + # writes no copy keeps the signals' defaults. + console = None + with console_access.terminate_as_interrupt(console_access.needs_copy(httpd)): + try: + try: + with console_access.deferred_termination(): + console = console_access.publish(httpd, page_url=page) + if console is not None: + print(f"console {console.file_url} (this user's private " + "copy, mode 0600: open it to open the console page)") + # A browser that cannot open it is told the way past it, + # in one line with no token (RULED, B3). + print(console_access.UNOPENABLE_HINT) + # FLUSHED before the process blocks (plan 034 T056): where + # standard output is a pipe or a file it is block-buffered, so + # an unflushed line never reaches a wrapper while the server + # runs, and the wrapper cannot learn an ephemeral port or tell + # that the server started. + print(f"serving ideation dashboard at {page}", flush=True) + httpd.serve_forever() + except KeyboardInterrupt: + pass + finally: + # The copy FIRST, while this process still holds the port, then + # the socket (`console_access.remove_private_copy`). A stop that + # arrives meanwhile lets both finish. + with console_access.deferred_termination(raise_pending=False): + console_access.remove_private_copy(console) + httpd.server_close() def _source_roots_from_args(values) -> dict: @@ -2766,6 +3013,11 @@ def main(argv: list[str] | None = None) -> int: # `--checkout-root` refusal above is. print(f"serve refused: {exc}", file=sys.stderr) return 1 + except console_access.ConsoleAccessRefused as exc: + # NO SAFE PRIVATE COPY, NO SERVE (plan 034 T104): a standalone console + # whose token nobody can be handed is refused, before it serves. + print(f"serve refused: {exc}", file=sys.stderr) + return 1 return 0 diff --git a/src/opendox/web/views/notebook.js b/src/opendox/web/views/notebook.js index 7e8015a8..81a11d5b 100644 --- a/src/opendox/web/views/notebook.js +++ b/src/opendox/web/views/notebook.js @@ -27,16 +27,111 @@ export function notebookCapable(caps) { return caps?.actions?.notebook === true; } +// ---- the console token, DELIVERED IN THE OPENED URL (plan 034 T104) -------- +// +// RULED openxFactory#656 `5963851934` (adversarial review 2's M5): on a +// STANDALONE plane the server no longer publishes the per-serve console token +// on `/capabilities`, where any loopback caller, another OS user included, +// could read it. `opendox generate-and-open` opens this page through a private +// 0600 file instead, which forwards here with the token in the URL FRAGMENT +// (`#console_token=…`). A fragment never reaches a server, so it is in no +// request line, no server log and no `Referer`. +// +// This module takes it the moment it is imported (before the shell's first +// fetch), keeps it in `sessionStorage` (this tab only, so a reload keeps it; +// memory where storage is blocked), and STRIPS the fragment from the address +// bar with `history.replaceState`. `probeCapabilities` then fills it into the +// payload where the server published none, so every view keeps reading +// `caps.console_token` exactly as before. A HOST's plane still publishes its +// own token on `/capabilities`, and that one always wins. +export const CONSOLE_TOKEN_FRAGMENT_KEY = "console_token"; +export const CONSOLE_TOKEN_STORAGE_KEY = "opendox.console-token"; +// The `/capabilities` field every view reads the token from (the server's +// `CONSOLE_TOKEN_FIELD`; `staging-workbench-model.js` spells it too, and this +// leaf module imports nothing). +const CAPS_CONSOLE_TOKEN_FIELD = "console_token"; +// `secrets.token_urlsafe`'s alphabet: a value outside it is not a token. +const CONSOLE_TOKEN_SHAPE = /^[A-Za-z0-9_-]{16,512}$/; + +function consoleStore(scope) { + try { + return scope && scope.sessionStorage ? scope.sessionStorage : null; + } catch { + return null; // blocked storage throws on access + } +} + +// pure over `scope` (`window` in the page) — node-testable. Returns the +// delivered token or null. A fragment that names the key is stripped from the +// address bar whatever it holds, so a malformed token does not linger either. +export function takeDeliveredConsoleToken(scope) { + const loc = scope && scope.location; + if (!loc || typeof loc.hash !== "string") return null; + let fromFragment = null; + if (loc.hash.length > 1) { + let raw = null; + try { + raw = new URLSearchParams(loc.hash.slice(1)).get(CONSOLE_TOKEN_FRAGMENT_KEY); + } catch { + raw = null; + } + if (raw !== null) { + try { + const history = scope.history; + if (history && typeof history.replaceState === "function") { + history.replaceState(history.state, "", + String(loc.pathname || "") + String(loc.search || "")); + } + } catch { + // an address bar that cannot be rewritten still yields the token + } + if (CONSOLE_TOKEN_SHAPE.test(raw)) fromFragment = raw; + } + } + const store = consoleStore(scope); + if (fromFragment) { + try { + if (store) store.setItem(CONSOLE_TOKEN_STORAGE_KEY, fromFragment); + } catch { + // memory only: this load still has it + } + return fromFragment; + } + let kept = null; + try { + kept = store ? store.getItem(CONSOLE_TOKEN_STORAGE_KEY) : null; + } catch { + kept = null; + } + return typeof kept === "string" && CONSOLE_TOKEN_SHAPE.test(kept) ? kept : null; +} + +// Taken ONCE, at import. Outside a page (node) there is no `location`, and +// this is null. +const DELIVERED_CONSOLE_TOKEN = takeDeliveredConsoleToken(globalThis); + +// pure — node-testable. The payload, with the delivered token filled in where +// the server published none. Mutated in place, so the object the shell holds +// is the object the views read. +export function withDeliveredConsoleToken(payload, token = DELIVERED_CONSOLE_TOKEN) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload; + const served = payload[CAPS_CONSOLE_TOKEN_FIELD]; + if (typeof served === "string" && served) return payload; + if (typeof token === "string" && token) payload[CAPS_CONSOLE_TOKEN_FIELD] = token; + return payload; +} + // Probe the local backend ONCE. Any non-OK response (the static image 404s this // route) or a network failure (file://) degrades to "not available" — never -// throws, never blocks the dashboard. `injectedFetch` is for tests. -export async function probeCapabilities(injectedFetch) { +// throws, never blocks the dashboard. `injectedFetch` is for tests; so is +// `deliveredToken`, which defaults to the one this page was opened with. +export async function probeCapabilities(injectedFetch, deliveredToken = DELIVERED_CONSOLE_TOKEN) { try { const response = injectedFetch ? await injectedFetch(CAPABILITIES_ROUTE, { cache: "no-store" }) : await fetch(CAPABILITIES_ROUTE, { cache: "no-store" }); if (!response?.ok) return { actions: { notebook: false } }; - return await response.json(); + return withDeliveredConsoleToken(await response.json(), deliveredToken); } catch { return { actions: { notebook: false } }; } diff --git a/tests/fixtures/web_boundary_census.yaml b/tests/fixtures/web_boundary_census.yaml index ebbbd252..cb39c87b 100644 --- a/tests/fixtures/web_boundary_census.yaml +++ b/tests/fixtures/web_boundary_census.yaml @@ -191,7 +191,7 @@ measured_at: "opensoft/openDox-code main a99eba03e31a0aee1cc15a061fdf718cc88a2c4 # shape and `test_the_declared_totals_are_re_derived_from_the_rows` can refuse a # drift between the two. Measured at slice S4 (see the S4 block above). totals: - A: {files: 26, loc: 18526} + A: {files: 26, loc: 18621} B: {files: 1, loc: 73} C: {files: 14, loc: 12757} "?": {files: 1, loc: 1586} @@ -350,8 +350,8 @@ files: - path: views/notebook.js class: A - loc: 109 - note: "open in NotebookLM tile action; both routes are openDox's serve.py" + loc: 204 + note: "open in NotebookLM tile action; both routes are openDox's serve.py loc 109 -> 204 on plan 034 T104 (RULED openxFactory#656 5963851934): the module also takes the console token from the opened URL's fragment at import, keeps it in sessionStorage, strips it with history.replaceState, and probeCapabilities fills it into a payload that carries none; still import-free, and still no route of another column" - path: views/outline-model.js class: C diff --git a/tests/standalone_child.py b/tests/standalone_child.py index 2956ae25..f0f78996 100644 --- a/tests/standalone_child.py +++ b/tests/standalone_child.py @@ -266,6 +266,19 @@ def refused(self) -> list[str]: return [] return self.refused_log.read_text(encoding="utf-8").split() + def console_token(self, port: int) -> str: + """The console token this child's STANDALONE plane delivers (plan 034 + T104; RULED openxFactory#656 `5963851934`), read as a user's browser + is handed it: from the 0600 private copy the entry point wrote in the + child's own state directory. `/capabilities` carries none on a + standalone plane, so this is the only place a case can take it from, + and reading it applies every check a real reader's does.""" + from opendox import console_access + + record = console_access.read_private_copy( + console_access.private_copy_path(self.state_dir, port)) + return record[console_access.FRAGMENT_KEY] + def run_module(workdir: Path, module: str, *args: str) -> tuple[Child, int]: """A child run to completion: `(child, exit status)`.""" diff --git a/tests/test_capability_honesty.py b/tests/test_capability_honesty.py index 30aa6ad1..1d2e4406 100644 --- a/tests/test_capability_honesty.py +++ b/tests/test_capability_honesty.py @@ -450,7 +450,10 @@ def test_the_three_crash_sites_answer_a_standalone_server( repo = _repository(tmp_path, identity=True) child, base, caps = _standalone(tmp_path, repo) try: - token = caps.get("console_token") + # THE TOKEN IS NOT ON `/capabilities` (plan 034 T104): a standalone + # plane delivers it in the opened URL, through its private copy. + assert "console_token" not in caps, caps + token = child.console_token(base[1]) assert caps["actions"]["session"] is True and token, caps abstract = _call(base, "POST", "/actions/workbench/document-abstract", body=_json(_ABSTRACT), token=token) @@ -493,7 +496,10 @@ def test_the_rails_thread_read_answers_a_standalone_server( repo = _repository(tmp_path, identity=True) child, base, caps = _standalone(tmp_path, repo) try: - token = caps.get("console_token") + # THE TOKEN IS NOT ON `/capabilities` (plan 034 T104): a standalone + # plane delivers it in the opened URL, through its private copy. + assert "console_token" not in caps, caps + token = child.console_token(base[1]) assert caps["actions"]["session"] is True and token, caps status, body, raw = _call( base, "GET", @@ -523,7 +529,8 @@ def test_an_unknown_tile_kind_on_the_thread_read_is_refused_not_dropped( base, "GET", "/workbench/thread?repository=fixture&ref=main&tile_kind=bogus" "&tile_id=barrel-rain&document=notes-rain-barrel-leak.md", - token=caps["console_token"]) + # a standalone plane's token, from its private copy (T104) + token=child.console_token(base[1])) assert body.get("error") == DOXBENCH_ERR_INVALID_TURN_REQUEST, (status, raw) assert child.interrupt() == 0, child.stderr_text() finally: @@ -585,7 +592,10 @@ def test_a_chat_turn_with_a_binding_configured_is_answered_standalone( assert status == 0, added.stderr_text() child, base, caps = _standalone(tmp_path, repo) try: - token = caps.get("console_token") + # THE TOKEN IS NOT ON `/capabilities` (plan 034 T104): a standalone + # plane delivers it in the opened URL, through its private copy. + assert "console_token" not in caps, caps + token = child.console_token(base[1]) assert caps["actions"]["session"] is True and token, caps answer = _call(base, "POST", "/actions/workbench/chat-turn", body=_json(_chat_turn()), token=token) @@ -748,7 +758,10 @@ def test_a_standalone_server_does_not_offer_an_intake_it_could_not_approve( before = document.read_bytes() child, base, caps = _standalone(tmp_path, repo) try: - token = caps.get("console_token") + # THE TOKEN IS NOT ON `/capabilities` (plan 034 T104): a standalone + # plane delivers it in the opened URL, through its private copy. + assert "console_token" not in caps, caps + token = child.console_token(base[1]) assert caps["actions"]["session"] is True and token, caps status, surface, raw = _call(base, "GET", "/workbench/model-intake", token=token) diff --git a/tests/test_chat_model_configuration.py b/tests/test_chat_model_configuration.py index 6ffd0067..a21efbe5 100644 --- a/tests/test_chat_model_configuration.py +++ b/tests/test_chat_model_configuration.py @@ -438,7 +438,10 @@ def standalone(tmp_path, monkeypatch): base = (match.group(2), int(match.group(3))) status, capabilities = _request(base, "GET", "/capabilities") assert status == 200 and capabilities["actions"]["session"] is True - yield base, capabilities["console_token"], child + # a standalone plane delivers its token through the private copy, and + # never on `/capabilities` (plan 034 T104) + assert "console_token" not in capabilities, sorted(capabilities) + yield base, child.console_token(base[1]), child assert child.interrupt() == 0, child.stderr_text() assert child.refused() == [], child.refused() assert "Traceback" not in child.stderr_text(), child.stderr_text() @@ -1042,7 +1045,7 @@ def postures(tmp_path_factory): status, capabilities = _request(base, "GET", "/capabilities") assert status == 200 and capabilities["actions"]["session"] is True started[name].base = base - started[name].token = capabilities["console_token"] + started[name].token = child.console_token(base[1]) yield started for posture in started.values(): assert posture.child.interrupt() == 0, posture.child.stderr_text() diff --git a/tests/test_console_token_delivery.py b/tests/test_console_token_delivery.py new file mode 100644 index 00000000..2c22d3b9 --- /dev/null +++ b/tests/test_console_token_delivery.py @@ -0,0 +1,3979 @@ +"""The console token travels in the opened URL, not `/capabilities` (plan 034 +T104; RULED openxFactory#656 comment `5963851934`, adversarial review 2's M5). + +A standalone openDox served its per-serve console token from `/capabilities` +to any loopback caller, other OS users of the machine included. The token +lets a page edit documents and run chat turns that spend the operator's model +credential. Only the DELIVERY changes, and this file holds each part of it: + +1. A STANDALONE plane (openDox's own default profile) carries no token on + `/capabilities`, and no route a second local user can ask serves it. A + HOST's plane (this suite's own `_SuiteProfile`, as openxFactory's) keeps + the `/capabilities` delivery it reads today. +2. The token travels in the opened URL's FRAGMENT and never in its query: the + URL, the private copy's forwarding targets, and what the entry point hands + the browser (a `file://` path) and prints (never the token). +3. The private copy is 0600 in a 0700 directory, this user's, and a copy that + is PLANTED, LINKED, hard-linked or loosened is refused, never followed. + Its record cannot break out of its `` or a tag is escaped, so the record ends where its element + does, every attribute is quoted, and it reads back as written.""" + from opendox import console_access + + hostile = ("http://127.0.0.1:8080/" + "/'\"&/index.html") + token = _token() + copy = _write(_state(tmp_path), token=token, page_url=hostile) + text = copy.path.read_text(encoding="utf-8") + assert text.count("") == 1, text + assert "" not in text, text + targets = _targets(copy.path) + (attrs, data), = targets.scripts + assert attrs == {"type": "application/json", "id": console_access.RECORD_ELEMENT_ID} + record = json.loads(data) + assert record["page_url"] == hostile and record["console_token"] == token + assert console_access.read_private_copy(copy.path) == record + for url in (*targets.refresh, *targets.links): + assert url == console_access.opened_url(hostile, token), url + + +def _generate_and_open_args(tmp_path: Path, repo: Path, *extra: str) -> argparse.Namespace: + """A run that SERVES: a `--no-serve` run publishes no copy (adversarial + review of openDox-code#84, B8), so every case built from these takes + `stopped_once_serving` too, and never blocks in a serve loop.""" + from opendox import cli + return cli.build_parser().parse_args([ + "generate-and-open", "--repo-root", str(repo), "--repository", "fixture", + "--run-dir", str(tmp_path / "run"), "--port", "0", "--no-validate", + *extra]) + + +@pytest.fixture() +def stopped_once_serving(monkeypatch): + """The serve loop, stopped as Ctrl-C stops it the moment it starts: what + it lists is every server that reached it, so a refused start can show it + never served.""" + reached: list[object] = [] + + def serve_forever(self, *args, **kwargs): + reached.append(self) + raise KeyboardInterrupt + + monkeypatch.setattr(socketserver.BaseServer, "serve_forever", serve_forever) + return reached + + +def test_generate_and_open_hands_the_browser_a_file_and_prints_no_token( + tmp_path, monkeypatch, capsys, standalone_profile, + stopped_once_serving) -> None: + """What the entry point gives the browser is the copy's `file://` path: + a URL handed to `webbrowser.open` sits on a command line every user can + read. The copy it names opens the page with the token in the fragment. + Nothing it prints carries the token. The copy goes when the run does.""" + from opendox import cli, console_access + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = _state(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + opened: list[tuple[str, dict]] = [] + + def opener(url): + path = Path(urllib.parse.urlsplit(url).path) + opened.append((url, console_access.read_private_copy(path))) + + assert cli._generate_and_open(_generate_and_open_args(tmp_path, repo), + opener=opener) == 0 + out = capsys.readouterr().out + (url, record), = opened + token = record["console_token"] + assert url.startswith("file://") and token not in url, url + _assert_fragment_only(record["opened_url"], token) + assert token not in out, "the token was printed" + console_line = next(line for line in out.splitlines() + if line.startswith(" console ")) + assert url in console_line, console_line + assert record["page_url"] in out.splitlines(), out + assert not list((state / console_access.CONSOLE_DIRNAME).iterdir()), \ + "the copy outlived the run" + assert len(stopped_once_serving) == 1, "the run never served" + + +def test_no_open_prints_the_copy_path_and_opens_nothing( + tmp_path, monkeypatch, capsys, standalone_profile, + stopped_once_serving) -> None: + """`--no-open`: no browser, and the way back to the page is still printed, + the copy's path, while the token is still never printed.""" + from opendox import cli + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(_state(tmp_path))) + opened: list[str] = [] + assert cli._generate_and_open( + _generate_and_open_args(tmp_path, repo, "--no-open"), + opener=opened.append) == 0 + out = capsys.readouterr().out + assert opened == [] + match = next(_CONSOLE.match(line) for line in out.splitlines() + if _CONSOLE.match(line)) + assert match.group(1).startswith("file://"), out + assert "console_token=" not in out, out + assert len(stopped_once_serving) == 1, "the run never served" + + +def test_generate_and_open_refuses_where_no_safe_copy_can_be_written( + tmp_path, monkeypatch, capsys, standalone_profile, + stopped_once_serving) -> None: + """A state directory another user could change refuses the run before it + serves, naming the directory; a console nobody can safely be handed is + not served as if it could be.""" + from opendox import cli + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = _state(tmp_path) + state.chmod(0o770) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + opened: list[str] = [] + assert cli._generate_and_open(_generate_and_open_args(tmp_path, repo), + opener=opened.append) == 1 + captured = capsys.readouterr() + assert opened == [] + assert "generate-and-open refused:" in captured.err + assert stopped_once_serving == [], "the refused run served" + assert str(state) in captured.err and "writable by its group" in captured.err + + +# --------------------------------------------------------------------------- +# 3 — the private copy: planted, linked, loosened +# --------------------------------------------------------------------------- + +def test_the_copy_is_born_0600_in_a_0700_tree_whatever_the_umask(tmp_path) -> None: + from opendox import console_access + + state = tmp_path / "fresh" / "state" # nothing exists yet + previous = os.umask(0) + try: + copy = _write(state) + finally: + os.umask(previous) + assert stat.S_IMODE(os.lstat(copy.path).st_mode) == console_access.PRIVATE_MODE == 0o600 + for directory in (state, copy.path.parent): + assert stat.S_IMODE(os.lstat(directory).st_mode) == 0o700, directory + assert console_access.read_private_copy(copy.path)["port"] == 8080 + + +def test_a_link_planted_at_the_copy_is_refused_and_never_followed(tmp_path) -> None: + """Another user, or anyone, puts a link where the copy goes, pointing at a + file they can read: the write refuses, the token goes nowhere, and a read + through the link refuses too.""" + from opendox import console_access + + state = _state(tmp_path) + console = state / console_access.CONSOLE_DIRNAME + console.mkdir(mode=0o700) + leak = tmp_path / "leak.html" + leak.write_text("nothing yet\n", encoding="utf-8") + planted = console_access.private_copy_path(state, 8080) + planted.symlink_to(leak) + token = _token() + with pytest.raises(console_access.ConsoleAccessRefused, match="symbolic link"): + _write(state, token=token) + assert leak.read_text(encoding="utf-8") == "nothing yet\n" + assert planted.is_symlink(), "the planted link was replaced, not refused" + # a real copy elsewhere, linked in: a read refuses the link itself + other = _write(_state(tmp_path / "other")) + planted.unlink() + planted.symlink_to(other.path) + with pytest.raises(console_access.ConsoleAccessRefused, match="symbolic link"): + console_access.read_private_copy(planted) + + +def test_a_dangling_link_planted_at_the_copy_creates_nothing(tmp_path) -> None: + from opendox import console_access + + state = _state(tmp_path) + (state / console_access.CONSOLE_DIRNAME).mkdir(mode=0o700) + target = tmp_path / "would-be-created.html" + console_access.private_copy_path(state, 8080).symlink_to(target) + with pytest.raises(console_access.ConsoleAccessRefused): + _write(state) + assert not target.exists() + + +def test_a_hard_linked_copy_is_refused(tmp_path) -> None: + from opendox import console_access + + state = _state(tmp_path) + copy = _write(state) + os.link(copy.path, tmp_path / "second-name.html") + with pytest.raises(console_access.ConsoleAccessRefused, match="2 hard links"): + console_access.read_private_copy(copy.path) + with pytest.raises(console_access.ConsoleAccessRefused, match="2 hard links"): + _write(state) + + +def test_a_loosened_copy_is_refused(tmp_path) -> None: + from opendox import console_access + + copy = _write(_state(tmp_path)) + copy.path.chmod(0o644) + with pytest.raises(console_access.ConsoleAccessRefused, match="mode 644"): + console_access.read_private_copy(copy.path) + + +def test_a_planted_directory_or_file_at_the_copy_is_refused(tmp_path) -> None: + from opendox import console_access + + state = _state(tmp_path) + (state / console_access.CONSOLE_DIRNAME).mkdir(mode=0o700) + console_access.private_copy_path(state, 8080).mkdir() + with pytest.raises(console_access.ConsoleAccessRefused, match="not a regular file"): + _write(state) + + +def test_a_linked_console_directory_is_refused(tmp_path) -> None: + from opendox import console_access + + state = _state(tmp_path) + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir(mode=0o700) + (state / console_access.CONSOLE_DIRNAME).symlink_to(elsewhere) + with pytest.raises(console_access.ConsoleAccessRefused, match="symbolic link"): + _write(state) + assert list(elsewhere.iterdir()) == [] + + +@pytest.mark.parametrize("mode, reason", [(0o770, "writable by its group"), + (0o707, "writable by every user")]) +def test_a_state_directory_others_can_write_is_refused(tmp_path, mode, reason) -> None: + from opendox import console_access + + state = _state(tmp_path) + state.chmod(mode) + with pytest.raises(console_access.ConsoleAccessRefused, match=reason): + _write(state) + assert not (state / console_access.CONSOLE_DIRNAME).exists() + + +def test_a_state_directory_below_a_non_sticky_shared_directory_is_refused(tmp_path) -> None: + from opendox import console_access + + shared = tmp_path / "shared" + shared.mkdir() + shared.chmod(0o777) + try: + with pytest.raises(console_access.ConsoleAccessRefused, match="not sticky"): + _write(shared / "state") + shared.chmod(0o1777) # sticky, as /tmp is: accepted + assert _write(shared / "state").path.exists() + finally: + shared.chmod(0o755) + + +def test_a_later_copy_replaces_this_users_earlier_one_and_removal_is_exact( + tmp_path) -> None: + """A server restarted on the same port, after the first ended without + removing its copy, replaces that copy. The first server's removal, had it + still run, leaves the later copy in place.""" + from opendox import console_access + + state = _state(tmp_path) + first = _write(state) + _abandon(first) # the first serve is gone + second_token = _token() + second = _write(state, token=second_token) + assert first.path == second.path + console_access.remove_private_copy(first) + assert console_access.read_private_copy(second.path)["console_token"] == second_token + console_access.remove_private_copy(second) + assert not second.path.exists() + console_access.remove_private_copy(second) # already gone: no error + console_access.remove_private_copy(None) + + +# --------------------------------------------------------------------------- +# 4 — the guarded routes still require it +# --------------------------------------------------------------------------- + +def test_every_route_that_requires_the_token_still_requires_it( + tmp_path, monkeypatch, standalone_profile) -> None: + """Only the delivery changed. Without the token, each guarded route + refuses with its console refusal; with the token read from the private + copy, the same request passes the console check.""" + from opendox import console_access, serve + from opendox.serve_wire import DOXBENCH_ERR_CONSOLE_REQUIRED + + with _serving(tmp_path, monkeypatch) as (httpd, base, _repo): + copy = console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(_state(tmp_path))}) + token = console_access.read_private_copy(copy.path)["console_token"] + assert token == httpd.console_token + catalog = "/workbench/model-catalog" + status, _h, raw = _call(base, "GET", catalog) + assert json.loads(raw).get("error") == DOXBENCH_ERR_CONSOLE_REQUIRED, raw + status, _h, raw = _call(base, "GET", catalog, token="x" * 43) + assert json.loads(raw).get("error") == DOXBENCH_ERR_CONSOLE_REQUIRED, raw + status, _h, raw = _call(base, "GET", catalog, token=token) + assert json.loads(raw).get("error") != DOXBENCH_ERR_CONSOLE_REQUIRED, raw + turn = "/actions/workbench/chat-turn" + status, _h, raw = _call(base, "POST", turn, body=b"{}") + assert json.loads(raw).get("error") == DOXBENCH_ERR_CONSOLE_REQUIRED, raw + status, _h, raw = _call(base, "POST", turn, body=b"{}", token=token) + assert json.loads(raw).get("error") != DOXBENCH_ERR_CONSOLE_REQUIRED, raw + + +def _stop(child: Child, signum: int) -> int: + """Signal the child and wait for it, WITHOUT `Child.interrupt()`, whose + `kill()` deletes the child's state directory and would hide a copy the + child failed to remove (Copilot at openDox-code#84, r4173806621). The + case's own `finally: child.kill()` cleans up afterwards.""" + import standalone_child + + child.process.send_signal(signum) + return child.process.wait(timeout=standalone_child.STOP_DEADLINE_SECONDS) + + +# --------------------------------------------------------------------------- +# 5 — the documented command, as a user runs it +# --------------------------------------------------------------------------- + +def test_the_documented_command_delivers_the_token_only_through_its_copy( + tmp_path, monkeypatch) -> None: + """`python -m opendox.cli generate-and-open --local --no-open`, in a + process of its own (openDox's default profile, no host, no sibling): the + console line names the copy, `/capabilities` carries no token, the copy's + token opens the catalog, nothing printed carries it, and the copy goes + when the server stops.""" + from opendox import console_access + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + child = Child(tmp_path, "opendox.cli", "generate-and-open", "--local", + "--repo-root", str(repo), "--repository", "fixture", + "--no-open", "--port", "0", "--run-dir", str(tmp_path / "run")) + try: + console = child.wait_for_line(_CONSOLE) + match = child.wait_for_line(_URL) + base, port = (match.group(2), int(match.group(3))), int(match.group(3)) + copy_path = console_access.private_copy_path(child.state_dir, port) + assert console.group(1) == copy_path.as_uri(), console.group(0) + token = child.console_token(port) + status, _h, raw = _call(base, "GET", "/capabilities") + assert status == 200 and "console_token" not in json.loads(raw) + assert token.encode() not in raw + status, _h, raw = _call(base, "GET", "/workbench/model-catalog", token=token) + assert status == 200, raw + # STOPPED, and looked at BEFORE `Child.kill()` deletes the state dir + assert _stop(child, signal.SIGINT) == 0, child.stderr_text() + assert not copy_path.exists(), "the copy outlived the server" + finally: + child.kill() + assert token not in child.stdout_text() + child.stderr_text() + assert child.refused() == [], child.refused() + + +_SERVE_URL = re.compile(r"^serving ideation dashboard at " + r"(http://([0-9.]+):([0-9]+))/index\.html$") +_SERVE_CONSOLE = re.compile(r"^console (file://\S+) ") + + +def test_the_servers_own_entry_point_delivers_the_token_the_same_way( + tmp_path, monkeypatch) -> None: + """`python -m opendox.serve`, the standalone secondary entry point: the + same private copy, the same printed path, no token on `/capabilities` or + on the output, and the copy gone when the server stops.""" + from opendox import console_access + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + child = Child(tmp_path, "opendox.serve", "--snapshot", str(snapshot), + "--checkout-root", str(repo), "--port", "0") + try: + console = child.wait_for_line(_SERVE_CONSOLE) + match = child.wait_for_line(_SERVE_URL) + base, port = (match.group(2), int(match.group(3))), int(match.group(3)) + copy_path = console_access.private_copy_path(child.state_dir, port) + assert console.group(1) == copy_path.as_uri() + token = child.console_token(port) + status, _h, raw = _call(base, "GET", "/capabilities") + assert status == 200 and token.encode() not in raw + status, _h, raw = _call(base, "GET", "/workbench/model-catalog", token=token) + assert json.loads(raw).get("error") != "console_required", raw + assert _stop(child, signal.SIGINT) == 0, child.stderr_text() + assert not copy_path.exists(), "the copy outlived the server" + finally: + child.kill() + assert token not in child.stdout_text() + child.stderr_text() + + +def test_the_servers_own_entry_point_refuses_where_no_safe_copy_can_be_written( + tmp_path, monkeypatch, capsys, standalone_profile) -> None: + from opendox import serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + state = _state(tmp_path) + state.chmod(0o707) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + monkeypatch.setattr(serve, "real_notebook_adapter", lambda *a, **k: None) + assert serve.main(["--snapshot", str(snapshot), "--checkout-root", str(repo), + "--port", "0"]) == 1 + err = capsys.readouterr().err + assert "serve refused:" in err and "writable by every user" in err, err + + +# --------------------------------------------------------------------------- +# 6 — never inside what the plane serves +# --------------------------------------------------------------------------- + +def _tree(path: Path) -> list[str]: + return sorted(str(p.relative_to(path)) for p in path.rglob("*")) + + +@pytest.mark.parametrize("where", ["the served root", "under the served root"]) +def test_a_state_directory_in_the_served_root_is_refused_before_any_write( + tmp_path, where) -> None: + """The holder's ruling on openxFactory#1220's review (Copilot + `r4171166321`), mirroring T100's served-repository boundary: the token's + copy must never sit inside what `/source` can serve. A state directory + that IS a served root, or lies inside one (a declared source root + included), is refused by name, and nothing is created or written.""" + from opendox import console_access + + served = tmp_path / "served" + served.mkdir(mode=0o700) + other = tmp_path / "other-source-root" + other.mkdir(mode=0o700) + before = (_tree(served), _tree(other)) + for root in (served, other): + state = root if where == "the served root" else root / "nested" / "state" + with pytest.raises(console_access.ConsoleAccessRefused) as refused: + console_access.write_private_copy( + state, page_url="http://127.0.0.1:8080/index.html", port=8080, + token=_token(), served_roots=(served, other)) + message = str(refused.value) + assert "OPENDOX_STATE_DIR" in message and str(root.resolve()) in message + assert (("is the served repository" if where == "the served root" + else "lies inside the served repository") in message), message + assert (_tree(served), _tree(other)) == before, "something was written" + + +def test_a_state_directory_reached_through_a_link_into_the_served_root_is_refused( + tmp_path) -> None: + """Judged on the RESOLVED path, so a link from outside that lands inside + the served root is the served root.""" + from opendox import console_access + + served = tmp_path / "served" + (served / "inside").mkdir(parents=True, mode=0o700) + link = tmp_path / "looks-outside" + link.symlink_to(served / "inside") + with pytest.raises(console_access.ConsoleAccessRefused, match="inside the served"): + console_access.write_private_copy( + link / "state", page_url="http://127.0.0.1:8080/index.html", + port=8080, token=_token(), served_roots=(served,)) + assert _tree(served / "inside") == [] + + +def test_generate_and_open_refuses_a_state_directory_inside_the_served_repository( + tmp_path, monkeypatch, capsys, standalone_profile, + stopped_once_serving) -> None: + """Through the entry point: `OPENDOX_STATE_DIR` inside the repository it + serves refuses the run, naming the setting, and the repository gains no + file.""" + from opendox import cli + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(repo / ".opendox-state")) + before = _tree(repo) + opened: list[str] = [] + assert cli._generate_and_open(_generate_and_open_args(tmp_path, repo), + opener=opened.append) == 1 + err = capsys.readouterr().err + assert opened == [] + assert "generate-and-open refused:" in err and "OPENDOX_STATE_DIR" in err + assert stopped_once_serving == [], "the refused run served" + assert "lies inside the served repository" in err, err + assert _tree(repo) == before + + +def test_the_plane_reports_every_root_it_serves(tmp_path, monkeypatch, + standalone_profile) -> None: + """`publish` reads every root the plane serves files from (Copilot at + openDox-code#84, r4173806506): the checkout, the static bundle's + directory, each declared source root, each registry entry's root, and the + sessions container every session worktree is made in.""" + from opendox import branch_session, serve + + with _serving(tmp_path, monkeypatch) as (httpd, _base, repo): + sessions = branch_session.sessions_root(repo) + assert httpd.served_roots[:2] == (repo.resolve(), WEB.resolve()) + assert sessions in httpd.served_roots + entries = httpd.RequestHandlerClass.func.source.registry.entries() + assert entries and all(Path(e.source_root).resolve() in httpd.served_roots + for e in entries if e.source_root) + other = tmp_path / "other-source-root" + other.mkdir() + snapshot = tmp_path / "snapshot.json" + declared = serve.build_server(WEB, snapshot, repo, port=0, quiet=True, + source_roots={"other": str(other)}) + try: + assert other.resolve() in declared.served_roots + assert repo.resolve() in declared.served_roots + finally: + declared.server_close() + + +@pytest.mark.parametrize("inside", ["the static bundle", "the sessions container"]) +def test_a_state_directory_in_another_served_root_is_refused_by_the_entry_point( + tmp_path, monkeypatch, standalone_profile, inside) -> None: + """The static handler serves every file under `--web-dir`, and `/source` + serves every session worktree, so a copy there would be served to anyone + who asks, its 0600 notwithstanding: the server reads it as its owner. + `publish` refuses both, before anything is written.""" + import shutil as _shutil + + from opendox import branch_session, console_access, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + web = tmp_path / "web" + _shutil.copytree(WEB, web) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + httpd = serve.build_server(web, snapshot, repo, port=0, quiet=True) + try: + assert httpd.console_token + state = (web / "state" if inside == "the static bundle" + else branch_session.sessions_root(repo) / "state") + with pytest.raises(console_access.ConsoleAccessRefused, + match="lies inside the served repository"): + console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state)}) + assert not state.exists(), "something was written" + finally: + httpd.server_close() + + +# --------------------------------------------------------------------------- +# 7 — the copy's removal: exact under a race, and on a plain kill +# --------------------------------------------------------------------------- + +def test_a_replacement_written_while_the_old_copy_is_removed_survives( + tmp_path, monkeypatch) -> None: + """Copilot at openDox-code#84, r4173806552. A replacement serve publishes + its copy for the same port at the instant the old serve removes its own: + the old serve takes the NAME first (an atomic rename), finds a file that + is not its own, and puts it back. The replacement's copy stands, whole.""" + from opendox import console_access + + state = _state(tmp_path) + first = _write(state) + second_token = _token() + real_rename = os.rename + raced: list = [] + + def racing(src, dst, *args, **kwargs): + if src == first.path.name and not raced: + raced.append(_publish_concurrently(state, second_token)) + return real_rename(src, dst, *args, **kwargs) + + monkeypatch.setattr(console_access.os, "rename", racing) + console_access.remove_private_copy(first) + monkeypatch.undo() + assert raced, "the race was never staged" + second = _settle(raced[0]) + record = console_access.read_private_copy(first.path) + assert record["console_token"] == second_token + assert sorted(p.name for p in first.path.parent.iterdir()) == [first.path.name] + console_access.remove_private_copy(second) + assert not first.path.exists() + + +def test_terminate_as_interrupt_reads_sigterm_as_ctrl_c_and_restores_the_handler( + ) -> None: + from opendox import console_access + + def sentinel(signum, frame): + raise AssertionError("the previous handler ran") + + previous = signal.signal(signal.SIGTERM, sentinel) + try: + with pytest.raises(KeyboardInterrupt): + with console_access.terminate_as_interrupt(True): + os.kill(os.getpid(), signal.SIGTERM) + signal.pthread_sigmask(signal.SIG_BLOCK, []) # deliver now + assert signal.getsignal(signal.SIGTERM) is sentinel + with console_access.terminate_as_interrupt(False): + assert signal.getsignal(signal.SIGTERM) is sentinel + finally: + signal.signal(signal.SIGTERM, previous) + + +_HOSTED = { + "OPENDOX_INSTALL_MODE": "hosted", + "OPENDOX_DATABASE_URL": "postgresql://serve@127.0.0.1:1/opendox", + "OPENDOX_MIGRATION_DATABASE_URL": "postgresql://migrate@127.0.0.1:1/opendox", + "OPENDOX_OIDC_AUDIENCE": "fixture", + "OPENDOX_OIDC_ISSUER": "https://issuer.example.invalid/realms/fixture", +} + + +@pytest.mark.parametrize("entry", ["serve", "generate-and-open --local", + "generate-and-open, hosted"]) +def test_a_plain_kill_removes_the_copy(tmp_path, monkeypatch, entry) -> None: + """Copilot at openDox-code#84, r4173806590. SIGTERM, which `kill` sends, + stops every standalone entry point through the code that removes its + copy, and the copy is looked for BEFORE the helper deletes the state + directory. Each exits 0, as Ctrl-C does.""" + from opendox import console_access + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + if entry == "serve": + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + child = Child(tmp_path, "opendox.serve", "--snapshot", str(snapshot), + "--checkout-root", str(repo), "--port", "0") + url = _SERVE_URL + else: + local = ["--local"] if entry.endswith("--local") else [] + child = Child(tmp_path, "opendox.cli", "generate-and-open", *local, + "--repo-root", str(repo), "--repository", "fixture", + "--no-open", "--port", "0", "--run-dir", str(tmp_path / "run"), + extra_env=None if local else _HOSTED) + url = _URL + try: + match = child.wait_for_line(url) + port = int(match.group(3)) + copy_path = console_access.private_copy_path(child.state_dir, port) + assert copy_path.exists(), child.stdout_text() + child.stderr_text() + assert _stop(child, signal.SIGTERM) == 0, child.stderr_text() + assert not copy_path.exists(), "a plain kill left the token's copy behind" + assert "Traceback" not in child.stderr_text(), child.stderr_text() + finally: + child.kill() + + +# --------------------------------------------------------------------------- +# 8 — the static handler never serves a private copy (Copilot review 2) +# --------------------------------------------------------------------------- + +def test_a_served_root_equal_to_the_console_directory_is_refused(tmp_path) -> None: + """Copilot at openDox-code#84, r4173889265. The state directory is outside + every served root, but the static bundle IS its `console/` directory, so + the copy would be `GET /.html`. The copy's own path is judged + against the served roots, so this is refused by name before anything is + written.""" + from opendox import console_access + + state = _state(tmp_path) + console = state / console_access.CONSOLE_DIRNAME + console.mkdir(mode=0o700) + with pytest.raises(console_access.ConsoleAccessRefused, + match="OPENDOX_STATE_DIR") as refused: + console_access.write_private_copy( + state, page_url="http://127.0.0.1:8080/index.html", port=8080, + token=_token(), served_roots=(console,)) + assert str(console.resolve()) in str(refused.value) + assert list(console.iterdir()) == [], "something was written" + + +def test_a_static_link_out_of_the_bundle_never_serves_a_private_copy( + tmp_path, monkeypatch, standalone_profile) -> None: + """Copilot at openDox-code#84, r4173889294. The static handler follows a + link inside `--web-dir` (a governed host's composed web root is MADE of + such links, so they cannot be refused wholesale). A link that leads into + the state directory must still never serve a copy: every static request + whose resolved target is the private-copy directory, or inside it, is + answered 404, for GET and HEAD, the copy, the directory listing, and + another port's copy alike. The bundle itself still answers.""" + import shutil as _shutil + + from opendox import console_access, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + web = tmp_path / "web" + _shutil.copytree(WEB, web) + state = _state(tmp_path) + (web / "state-alias").symlink_to(state) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + httpd = serve.build_server(web, snapshot, repo, port=0, quiet=True) + worker = threading.Thread(target=httpd.serve_forever, daemon=True) + worker.start() + try: + base = httpd.server_address[:2] + copy = console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state)}) + other = _write(state, port=9, token=_token()) # another serve's copy + token = httpd.console_token + for path in (f"/state-alias/console/{copy.path.name}", + f"/state-alias/console/{other.path.name}", + "/state-alias/console/", "/state-alias/console"): + for method in ("GET", "HEAD"): + status, headers, raw = _call(base, method, path) + assert status == 404, (method, path, status) + assert token.encode() not in raw and copy.path.name.encode() not in raw + assert all(token not in str(v) for v in headers.values()) + status, _headers, raw = _call(base, "GET", "/index.html") + assert status == 200 and b" None: + """The REVERSE nesting. A served root that IS, or lies inside, the state + directory (`/console` itself, the bundle's socket tree, anything) + would let the plane serve the state directory's contents, the copy among + them. The state directory and every served root may not overlap in either + direction: refused by name, before anything is written.""" + from opendox import console_access + + state = _state(tmp_path) + served = (state / inside).resolve() if inside != "." else state.resolve() + served.mkdir(parents=True, exist_ok=True, mode=0o700) + before = _tree(state) + with pytest.raises(console_access.ConsoleAccessRefused, + match="OPENDOX_STATE_DIR") as refused: + console_access.write_private_copy( + state, page_url="http://127.0.0.1:8080/index.html", port=8080, + token=_token(), served_roots=(served,)) + assert str(served) in str(refused.value), str(refused.value) + assert _tree(state) == before, "something was written" + + +def test_a_source_link_into_the_state_directory_never_serves_the_copy( + tmp_path, monkeypatch, standalone_profile) -> None: + """Pinned: `/source` confines every path to its root AFTER resolving links + (`default_registry.resolve_within`), so a link inside the served checkout + that points at the state directory reaches nothing in it. The copy, its + directory and the state directory itself answer 404, unkeyed and keyed, + for GET and HEAD, and no answer carries the token.""" + from opendox import console_access, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = _state(tmp_path) + (repo / "state-link").symlink_to(state) + (repo / "copy-link.md").symlink_to( + console_access.private_copy_path(state, 1)) # dangling until written + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + httpd = serve.build_server(WEB, snapshot, repo, port=0, quiet=True) + worker = threading.Thread(target=httpd.serve_forever, daemon=True) + worker.start() + try: + base = httpd.server_address[:2] + copy = console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state)}) + _write(state, port=1) # copy-link.md now resolves + token = httpd.console_token + name = copy.path.name + for tail in (f"state-link/console/{name}", "state-link/console/1.html", + "copy-link.md", "state-link/console/", "state-link/"): + for prefix in ("/source/", "/source/fixture@main/"): + for method in ("GET", "HEAD"): + status, headers, raw = _call(base, method, prefix + tail) + assert status == 404, (method, prefix + tail, status, raw[:200]) + assert token.encode() not in raw + assert all(token not in str(v) for v in headers.values()) + document = next(p.name for p in sorted(repo.glob("*.md")) if not p.is_symlink()) + status, _headers, raw = _call(base, "GET", f"/source/{document}") + assert status == 200 and raw == (repo / document).read_bytes() + finally: + httpd.shutdown() + httpd.server_close() + worker.join(timeout=10) + + +# --------------------------------------------------------------------------- +# 10 — Copilot review 4: a directory's index page, and a FIFO at the copy +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("index", ["index.html", "index.htm"]) +def test_a_directory_index_linked_to_a_private_copy_is_never_served( + tmp_path, monkeypatch, standalone_profile, index) -> None: + """Copilot at openDox-code#84, r4174674625. For a directory request the + stdlib handler serves the directory's first index page that exists + (`index.html`, then `index.htm`), so judging only the directory let + `GET /sub/` serve `web/sub/`, a link to the console token's copy, + while `/sub/` itself answered 404. The index page the handler + would serve is judged too: GET and HEAD of the directory answer 404, the + redirect of the bare name carries nothing, and no answer carries the + token. A directory whose index page is the bundle's own still serves it.""" + import shutil as _shutil + + from opendox import console_access, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + web = tmp_path / "web" + _shutil.copytree(WEB, web) + (web / "sub").mkdir() + (web / "plain").mkdir() + (web / "plain" / index).write_text("plain index", encoding="utf-8") + state = _state(tmp_path) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + httpd = serve.build_server(web, snapshot, repo, port=0, quiet=True) + worker = threading.Thread(target=httpd.serve_forever, daemon=True) + worker.start() + try: + base = httpd.server_address[:2] + copy = console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state)}) + (web / "sub" / index).symlink_to(copy.path) + token = httpd.console_token + for path in ("/sub/", f"/sub/{index}", "/sub/?x=1"): + for method in ("GET", "HEAD"): + status, headers, raw = _call(base, method, path) + assert status == 404, (method, path, status) + assert token.encode() not in raw + assert all(token not in str(v) for v in headers.values()) + status, headers, raw = _call(base, "GET", "/sub") + assert status != 200, status + assert token.encode() not in raw + assert all(token not in str(v) for v in headers.values()) + for method in ("GET", "HEAD"): + status, _headers, raw = _call(base, method, "/plain/") + assert status == 200, (method, status) + assert b"plain index" in _call(base, "GET", "/plain/")[2] + finally: + httpd.shutdown() + httpd.server_close() + worker.join(timeout=10) + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="no FIFOs on this platform") +def test_a_fifo_at_the_copy_is_refused_without_blocking(tmp_path) -> None: + """Copilot at openDox-code#84, r4174674702. A FIFO planted at + `console/.html`, with no writer, blocked a read in its `open` + before the descriptor's regular-file check could run, so the reader + hung instead of refusing. The read opens without blocking and refuses it + as not a regular file. A write refuses it too, by its name, and neither + ever waits on it.""" + from opendox import console_access + + state = _state(tmp_path) + (state / console_access.CONSOLE_DIRNAME).mkdir(mode=0o700) + fifo = console_access.private_copy_path(state, 8080) + os.mkfifo(fifo, 0o600) + outcome: dict = {} + + def attempt(name, call) -> None: + try: + call() + except BaseException as exc: # noqa: BLE001 — judged below + outcome[name] = exc + else: + outcome[name] = None + + for name, call in (("read", lambda: console_access.read_private_copy(fifo)), + ("write", lambda: _write(state))): + worker = threading.Thread(target=attempt, args=(name, call), daemon=True) + worker.start() + worker.join(timeout=10) + if worker.is_alive(): + # Release the blocked open, so the case fails rather than hangs. + with contextlib.suppress(OSError): + os.close(os.open(fifo, os.O_WRONLY | os.O_NONBLOCK)) + worker.join(timeout=10) + pytest.fail(f"the {name} blocked on a FIFO at {fifo}") + assert isinstance(outcome[name], console_access.ConsoleAccessRefused), ( + name, outcome[name]) + assert "not a regular file" in str(outcome[name]), str(outcome[name]) + assert stat.S_ISFIFO(os.lstat(fifo).st_mode), "the FIFO was replaced" + + +# --------------------------------------------------------------------------- +# 11 — #1144 12.4a as amended by T007 batch N (openxFactory#1222, landed +# bdd0f586), clause by clause, and the opener file's lifecycle +# --------------------------------------------------------------------------- + +def _publish_concurrently(state: Path, token: str, port: int = 8080) -> dict: + """Publish a copy from ANOTHER thread, as another serve would, and give it + time to finish or to wait on the console directory's lock.""" + import time + + outcome: dict = {} + + def publish() -> None: + try: + outcome["copy"] = _write(state, port=port, token=token) + except BaseException as exc: # noqa: BLE001 — judged by `_settle` + outcome["error"] = exc + + outcome["thread"] = threading.Thread(target=publish, daemon=True) + outcome["thread"].start() + time.sleep(0.5) + return outcome + + +def _settle(outcome: dict): + outcome["thread"].join(timeout=30) + assert not outcome["thread"].is_alive(), "the concurrent publication never finished" + assert "error" not in outcome, outcome.get("error") + return outcome["copy"] + + +def _free_port() -> int: + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +#: What 12.4a says may be at the copy's path, other than an earlier copy of +#: this user's, and the reason each is refused by. +_PLANTED = { + "a symbolic link": "is a symbolic link", + "a directory": "is not a regular file", + "a FIFO": "is not a regular file", + "a hard-linked copy": "has 2 hard links", + "a loosened copy": "has mode 644, not 600", +} + + +def _plant(state: Path, port: int, kind: str, tmp_path: Path) -> Path: + from opendox import console_access + + (state / console_access.CONSOLE_DIRNAME).mkdir(mode=0o700, exist_ok=True) + path = console_access.private_copy_path(state, port) + if kind == "a symbolic link": + bait = tmp_path / "bait.html" + bait.write_text("bait\n", encoding="utf-8") + path.symlink_to(bait) + elif kind == "a directory": + path.mkdir() + elif kind == "a FIFO": + os.mkfifo(path, 0o600) + else: + written = _write(state, port=port) + if kind == "a hard-linked copy": + os.link(written.path, tmp_path / "second-name.html") + else: + written.path.chmod(0o644) + return path + + +def _fingerprint(path: Path) -> tuple: + info = os.lstat(path) + return (stat.S_IFMT(info.st_mode), stat.S_IMODE(info.st_mode), info.st_ino, + info.st_nlink, os.readlink(path) if stat.S_ISLNK(info.st_mode) else None) + + +def _port_is_free(port: int) -> bool: + with socket.socket() as probe: + try: + probe.bind(("127.0.0.1", port)) + except OSError: + return False + return True + + +def test_a_loosened_own_copy_is_refused_by_the_writer_and_left_as_it_is( + tmp_path) -> None: + """12.4a: a file at the copy's path is replaced ONLY when it is this + user's own regular file of mode 0600 with one link. A loosened one is + refused by name, never replaced (batch N's gap (a) at `c979747a`: the + writer asked for the type, the owner and the link count, not the mode).""" + from opendox import console_access + + state = _state(tmp_path) + first = _write(state) + first.path.chmod(0o644) + before = (first.path.read_bytes(), _fingerprint(first.path)) + with pytest.raises(console_access.ConsoleAccessRefused, + match="has mode 644, not 600") as refused: + _write(state) + assert str(first.path) in str(refused.value) + assert (first.path.read_bytes(), _fingerprint(first.path)) == before + + +def test_another_users_file_at_the_copy_is_refused_by_the_writer( + tmp_path, monkeypatch) -> None: + """12.4a: another user's file at the copy's path refuses the write by + name and is never replaced. Simulated through the writer's own `stat` of + that name, so the tree above it is still this user's.""" + from opendox import console_access + + state = _state(tmp_path) + first = _write(state) + before = (first.path.read_bytes(), _fingerprint(first.path)) + real_stat = os.stat + + def foreign(path, *args, **kwargs): + info = real_stat(path, *args, **kwargs) + if path == first.path.name and kwargs.get("dir_fd") is not None: + values = list(info[:10]) + values[4] = info.st_uid + 1 # st_uid + return os.stat_result(values) + return info + + monkeypatch.setattr(console_access.os, "stat", foreign) + with pytest.raises(console_access.ConsoleAccessRefused, + match="not by this user"): + _write(state) + monkeypatch.undo() + assert (first.path.read_bytes(), _fingerprint(first.path)) == before + + +@pytest.mark.parametrize("kind", sorted(_PLANTED)) +def test_generate_and_open_refuses_by_name_what_was_planted_at_the_copy( + tmp_path, monkeypatch, capsys, standalone_profile, + stopped_once_serving, kind) -> None: + """12.4a, through the entry point: anything at the copy's path but an + earlier copy of this user's refuses the START by name. Nothing is printed + that serves, no browser is opened, the planted thing is untouched, and the + listening socket is closed, so the server never serves.""" + from opendox import cli + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = _state(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + port = _free_port() + planted = _plant(state, port, kind, tmp_path) + before = _fingerprint(planted) + opened: list[str] = [] + assert cli._generate_and_open( + _generate_and_open_args(tmp_path, repo, "--port", str(port)), + opener=opened.append) == 1 + out, err = capsys.readouterr() + assert opened == [] + assert "generate-and-open refused:" in err, err + assert stopped_once_serving == [], "the refused run served" + assert str(planted) in err and _PLANTED[kind] in err, err + assert " console " not in out and f":{port}/" not in out, out + assert _fingerprint(planted) == before + assert _port_is_free(port), "the refused start kept its socket" + + +@pytest.mark.parametrize("kind", sorted(_PLANTED)) +def test_the_servers_own_entry_point_refuses_by_name_what_was_planted_at_the_copy( + tmp_path, monkeypatch, capsys, standalone_profile, kind) -> None: + """The same, through `python -m opendox.serve`'s `main`. A start that + did not refuse would serve: the serve loop here fails the case instead of + blocking it.""" + from opendox import serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + state = _state(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + monkeypatch.setattr(serve, "real_notebook_adapter", lambda *a, **k: None) + + def served(self, *args, **kwargs): + raise AssertionError("the server served") + + monkeypatch.setattr(socketserver.BaseServer, "serve_forever", served) + port = _free_port() + planted = _plant(state, port, kind, tmp_path) + before = _fingerprint(planted) + assert serve.main(["--snapshot", str(snapshot), "--checkout-root", str(repo), + "--port", str(port)]) == 1 + out, err = capsys.readouterr() + assert "serve refused:" in err and str(planted) in err, err + assert _PLANTED[kind] in err, err + assert "serving ideation dashboard at" not in out, out + assert _fingerprint(planted) == before + assert _port_is_free(port), "the refused start kept its socket" + + +@pytest.mark.parametrize("inside", ["console", "postgres/run", "."]) +def test_a_served_root_that_is_a_link_into_the_state_directory_is_refused( + tmp_path, inside) -> None: + """Batch N's gap (e): a served root named through a SYMBOLIC LINK that + leads to the state directory, or into it, is judged where it leads. It + is refused by name, and nothing is written.""" + from opendox import console_access + + state = _state(tmp_path) + target = state if inside == "." else state / inside + target.mkdir(parents=True, exist_ok=True, mode=0o700) + alias = tmp_path / "served-alias" + alias.symlink_to(target) + before = _tree(state) + with pytest.raises(console_access.ConsoleAccessRefused, + match="OPENDOX_STATE_DIR") as refused: + console_access.write_private_copy( + state, page_url="http://127.0.0.1:8080/index.html", port=8080, + token=_token(), served_roots=(alias,)) + assert str(target.resolve()) in str(refused.value), str(refused.value) + assert _tree(state) == before, "something was written" + + +def test_the_servers_own_entry_point_refuses_a_bundle_linked_into_the_state_directory( + tmp_path, monkeypatch, capsys, standalone_profile) -> None: + """The same, through the entry point: `--web-dir` names a link that leads + to `/console`. The start is refused by name, and the static + handler never serves the directory the copies live in.""" + from opendox import console_access, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + state = _state(tmp_path) + console = state / console_access.CONSOLE_DIRNAME + console.mkdir(mode=0o700) + alias = tmp_path / "web-alias" + alias.symlink_to(console) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + monkeypatch.setattr(serve, "real_notebook_adapter", lambda *a, **k: None) + + def served(self, *args, **kwargs): + raise AssertionError("the server served") + + monkeypatch.setattr(socketserver.BaseServer, "serve_forever", served) + assert serve.main(["--web-dir", str(alias), "--snapshot", str(snapshot), + "--checkout-root", str(repo), "--port", "0"]) == 1 + err = capsys.readouterr().err + assert "serve refused:" in err and str(console.resolve()) in err, err + assert list(console.iterdir()) == [] + + +@pytest.mark.parametrize("mode", [0o755, 0o750, 0o711]) +def test_a_console_directory_that_is_not_0700_is_refused(tmp_path, mode) -> None: + """12.4a: the copy is mode 0600 "in a directory of mode 0700". A + `console/` directory loosened after it was made, even where no one else + can write it, is refused by name by the reader and by the writer, and the + copy in it is left as it is.""" + from opendox import console_access + + state = _state(tmp_path) + copy = _write(state) + copy.path.parent.chmod(mode) + try: + expected = f"has mode {mode:o}, not 700" + with pytest.raises(console_access.ConsoleAccessRefused, match=expected): + console_access.read_private_copy(copy.path) + with pytest.raises(console_access.ConsoleAccessRefused, match=expected): + _write(state) + assert copy.path.exists() + finally: + copy.path.parent.chmod(0o700) + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root can write any directory") +def test_a_copy_that_cannot_be_written_refuses_the_start_by_name( + tmp_path, monkeypatch, capsys, standalone_profile, + stopped_once_serving) -> None: + """The lifecycle self-pass: a state directory its parent will not let + this user make (an operating-system refusal, not a rule of this module) + used to escape as a raw `PermissionError`, a traceback and no refusal. + It refuses by name, through the writer and through the entry point.""" + from opendox import cli, console_access + + locked = tmp_path / "locked" + locked.mkdir(mode=0o700) + locked.chmod(0o500) + try: + state = locked / "state" + with pytest.raises(console_access.ConsoleAccessRefused, + match="cannot be written") as refused: + _write(state) + assert str(console_access.private_copy_path(state, 8080)) in str(refused.value) + _clean_git(monkeypatch) + repo = _repository(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + opened: list[str] = [] + assert cli._generate_and_open(_generate_and_open_args(tmp_path, repo), + opener=opened.append) == 1 + err = capsys.readouterr().err + assert opened == [] + assert "generate-and-open refused:" in err and "cannot be written" in err, err + assert stopped_once_serving == [], "the refused run served" + assert not state.exists() + finally: + locked.chmod(0o700) + + +def test_a_copy_that_fails_its_own_read_back_is_not_left_behind( + tmp_path, monkeypatch) -> None: + """The lifecycle self-pass: the writer reads back what it wrote, as any + reader would, and refuses on a failure. The copy it wrote then goes with + the refusal, rather than outliving a start that never served.""" + from opendox import console_access + + state = _state(tmp_path) + + def refusing(path): + raise console_access.ConsoleAccessRefused("staged: the read-back refused") + + monkeypatch.setattr(console_access, "read_private_copy", refusing) + with pytest.raises(console_access.ConsoleAccessRefused, match="staged"): + _write(state) + monkeypatch.undo() + assert list((state / console_access.CONSOLE_DIRNAME).iterdir()) == [] + + +def test_another_serves_copy_survives_a_removal_where_hard_links_fail( + tmp_path, monkeypatch) -> None: + """The lifecycle self-pass, on removal: the name is taken and judged, and + another serve's copy is put back. Putting it back by a hard link fails on + a filesystem without them (EPERM), and that used to DELETE the other + serve's copy. It is put back by renaming it, where the name is free.""" + from opendox import console_access + + state = _state(tmp_path) + first = _write(state) + _abandon(first) # its reservation lost + second_token = _token() + second = _write(state, token=second_token) # another serve's, over the first + + def no_hard_links(*args, **kwargs): + raise PermissionError(errno.EPERM, "Operation not permitted") + + monkeypatch.setattr(console_access.os, "link", no_hard_links) + console_access.remove_private_copy(first) + monkeypatch.undo() + assert console_access.read_private_copy(first.path)["console_token"] == second_token + assert sorted(p.name for p in first.path.parent.iterdir()) == [first.path.name] + + +def test_terminate_as_interrupt_reads_a_hangup_as_ctrl_c_unless_it_is_ignored( + ) -> None: + """The lifecycle self-pass, on stopping: closing the terminal sends + SIGHUP, whose default action ends the process without removing the copy. + While a copy exists, SIGHUP is read as Ctrl-C, as SIGTERM is. A SIGHUP + the process was started ignoring (`nohup`) stays ignored.""" + from opendox import console_access + + previous = signal.signal(signal.SIGHUP, signal.SIG_DFL) + try: + with pytest.raises(KeyboardInterrupt): + with console_access.terminate_as_interrupt(True): + # never deliver a hangup that would END this test process + assert signal.getsignal(signal.SIGHUP) not in ( + signal.SIG_DFL, signal.SIG_IGN), "no hangup handler" + os.kill(os.getpid(), signal.SIGHUP) + signal.pthread_sigmask(signal.SIG_BLOCK, []) # deliver now + assert signal.getsignal(signal.SIGHUP) == signal.SIG_DFL + signal.signal(signal.SIGHUP, signal.SIG_IGN) # as `nohup` starts it + with console_access.terminate_as_interrupt(True): + assert signal.getsignal(signal.SIGHUP) == signal.SIG_IGN + assert signal.getsignal(signal.SIGHUP) == signal.SIG_IGN + finally: + signal.signal(signal.SIGHUP, previous) + + +@pytest.mark.parametrize("entry", ["serve", "generate-and-open --local", + "generate-and-open, hosted"]) +def test_a_hangup_removes_the_copy(tmp_path, monkeypatch, entry) -> None: + """SIGHUP, as a closed terminal sends it, stops every standalone entry + point through the code that removes its copy, and each exits 0. + + The child is started with SIGHUP at its DEFAULT action, whatever this + runner inherited: an ignored signal stays ignored across `exec`, so a + suite run under `nohup` would hand every child an ignored SIGHUP, which + the entry points rightly keep ignoring.""" + from opendox import console_access + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + inherited = signal.signal(signal.SIGHUP, signal.SIG_DFL) + try: + if entry == "serve": + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + child = Child(tmp_path, "opendox.serve", "--snapshot", str(snapshot), + "--checkout-root", str(repo), "--port", "0") + url = _SERVE_URL + else: + local = ["--local"] if entry.endswith("--local") else [] + child = Child(tmp_path, "opendox.cli", "generate-and-open", *local, + "--repo-root", str(repo), "--repository", "fixture", + "--no-open", "--port", "0", + "--run-dir", str(tmp_path / "run"), + extra_env=None if local else _HOSTED) + url = _URL + finally: + signal.signal(signal.SIGHUP, inherited) + try: + match = child.wait_for_line(url) + copy_path = console_access.private_copy_path(child.state_dir, + int(match.group(3))) + assert copy_path.exists(), child.stdout_text() + child.stderr_text() + assert _stop(child, signal.SIGHUP) == 0, child.stderr_text() + assert not copy_path.exists(), "a hangup left the token's copy behind" + assert "Traceback" not in child.stderr_text(), child.stderr_text() + finally: + child.kill() + + +@pytest.mark.parametrize("local", [False, True], ids=["hosted", "--local"]) +def test_a_kill_while_the_browser_opens_removes_the_copy( + tmp_path, monkeypatch, local) -> None: + """The lifecycle self-pass: the copy exists from the moment it is + written, and the browser opener can take seconds. A SIGTERM then, before + the serve loop, used to take SIGTERM's default action on a hosted + standalone plane, ending the process with the copy left behind. The + window from the write to the stop is covered whole. Here the browser + (`BROWSER`, a script) kills its own parent while it opens.""" + import standalone_child + from opendox import console_access + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + browser = tmp_path / "kill-the-opener" + browser.write_text('#!/bin/sh\nkill -TERM "$PPID"\n', encoding="utf-8") + browser.chmod(0o700) + env = {"BROWSER": str(browser), **({} if local else _HOSTED)} + child = Child(tmp_path, "opendox.cli", "generate-and-open", + *(["--local"] if local else []), + "--repo-root", str(repo), "--repository", "fixture", + "--port", "0", "--run-dir", str(tmp_path / "run"), extra_env=env) + try: + match = child.wait_for_line(_URL) + copy_path = console_access.private_copy_path(child.state_dir, + int(match.group(3))) + code = child.process.wait(timeout=standalone_child.STOP_DEADLINE_SECONDS) + assert code == 0, (code, child.stderr_text()) + assert not copy_path.exists(), "a kill while the browser opened left the copy" + assert "Traceback" not in child.stderr_text(), child.stderr_text() + finally: + child.kill() + + +def test_a_console_directory_with_a_setgid_bit_is_still_0700(tmp_path) -> None: + """Only the permission bits are judged: a directory made under a setgid + parent inherits the setgid bit, which grants no one access, so a + `console/` of mode 2700 is accepted by the writer and the reader alike.""" + from opendox import console_access + + state = _state(tmp_path) + copy = _write(state) + copy.path.parent.chmod(0o2700) + if not os.lstat(copy.path.parent).st_mode & stat.S_ISGID: + pytest.skip("this filesystem keeps no setgid bit on a directory") + assert console_access.read_private_copy(copy.path)["port"] == 8080 + _abandon(copy) + assert _write(state).path == copy.path + + +def test_a_write_that_fails_part_way_leaves_no_partial_copy( + tmp_path, monkeypatch) -> None: + """The lifecycle self-pass, on writing: the copy is written under a + temporary name and renamed into place. A failure part way (here the + disk is full at the `fsync`) refuses by name and removes the temporary + file, so nothing partial is left beside the name.""" + from opendox import console_access + + state = _state(tmp_path) + + def full(handle): + raise OSError(errno.ENOSPC, "No space left on device") + + monkeypatch.setattr(console_access.os, "fsync", full) + with pytest.raises(console_access.ConsoleAccessRefused, + match="cannot be written.*No space left on device"): + _write(state) + monkeypatch.undo() + assert list((state / console_access.CONSOLE_DIRNAME).iterdir()) == [] + + +# --------------------------------------------------------------------------- +# 12 — the state directory is walked ONCE, every link and directory on the +# way judged, and the write is anchored to that walk (Copilot at +# openDox-code#84, r4174785933) +# --------------------------------------------------------------------------- + +def _hop_layout(tmp_path: Path, shared_mode: int) -> dict: + """Copilot's layout: `OPENDOX_STATE_DIR=alias/state`, `alias -> + shared/hop`, `hop -> private`. Only `alias` is on the configured path; + `shared` is passed through by way of a link's target.""" + shared = tmp_path / "shared" + shared.mkdir() + private = tmp_path / "private" + private.mkdir(mode=0o700) + served = tmp_path / "served" + served.mkdir(mode=0o700) + (shared / "hop").symlink_to(private) + (tmp_path / "alias").symlink_to(shared / "hop") + shared.chmod(shared_mode) + return {"shared": shared, "private": private, "served": served, + "hop": shared / "hop", "state": tmp_path / "alias" / "state"} + + +def test_a_directory_passed_through_by_an_intermediate_link_is_judged( + tmp_path) -> None: + """`shared` is neither on the configured path nor above the resolved + one, and it was never judged: mode 0777 and not sticky, so another user + could replace `hop`. It is judged now, as every directory the walk passes + through is, and the write and the read are both refused by name.""" + from opendox import console_access + + layout = _hop_layout(tmp_path, 0o777) + try: + with pytest.raises(console_access.ConsoleAccessRefused, + match="writable by every user and is not sticky") as refused: + _write(layout["state"]) + assert str(layout["shared"]) in str(refused.value), str(refused.value) + assert _tree(layout["private"]) == [], "something was written" + # a copy that is there already is refused when read through that way + written = _write(layout["private"] / "state") + through = layout["state"] / console_access.CONSOLE_DIRNAME / written.path.name + with pytest.raises(console_access.ConsoleAccessRefused, + match="writable by every user and is not sticky"): + console_access.read_private_copy(through) + finally: + layout["shared"].chmod(0o755) + + +def test_a_link_swapped_after_the_checks_never_redirects_the_write( + tmp_path, monkeypatch) -> None: + """The race: `hop` is pointed at a served root after the served-root + check, and the write used to walk the configured path AGAIN, so the + token's copy landed in the served root, where `/source` serves it. The + path is walked once, and the write is anchored to that walk: the copy + is where the checks saw the state directory, and the served root gains + nothing.""" + from opendox import console_access + + layout = _hop_layout(tmp_path, 0o755) + real = console_access._refuse_a_served_state_dir + + def then_swap(*args, **kwargs): + real(*args, **kwargs) + layout["hop"].unlink() + layout["hop"].symlink_to(layout["served"]) + + monkeypatch.setattr(console_access, "_refuse_a_served_state_dir", then_swap) + copy = console_access.write_private_copy( + layout["state"], page_url="http://127.0.0.1:8080/index.html", port=8080, + token=_token(), served_roots=(layout["served"],)) + monkeypatch.undo() + assert _tree(layout["served"]) == [], "the swapped link redirected the write" + expected = (layout["private"] / "state").resolve() + assert copy.path == console_access.private_copy_path(expected, 8080) + assert console_access.read_private_copy(copy.path)["port"] == 8080 + + +# --------------------------------------------------------------------------- +# 13 — Copilot's review at 182cac76: the snapshot files are served roots; +# publication and removal are serialized; a stop during publication +# --------------------------------------------------------------------------- + +def test_a_snapshot_inside_the_state_directory_refuses_the_start( + tmp_path, monkeypatch, capsys, standalone_profile) -> None: + """Copilot at openDox-code#84, r4175213798. `/snapshot.json` reads its + file directly, not through the static handler, so a `--snapshot` named + at an earlier copy, `/console/.html`, would have been + replaced by the new copy and served to anyone. The snapshot file, each + registered entry's snapshot and the session snapshots' container are + served roots, so the start is refused by name, through a link to it as + well, and the earlier copy is left as it was.""" + from opendox import console_access, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = _state(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + monkeypatch.setattr(serve, "real_notebook_adapter", lambda *a, **k: None) + + def served(self, *args, **kwargs): + raise AssertionError("the server served") + + monkeypatch.setattr(socketserver.BaseServer, "serve_forever", served) + port = _free_port() + earlier = _write(state, port=port) + alias = tmp_path / "snapshot-alias.json" + alias.symlink_to(earlier.path) + for named in (earlier.path, alias): + before = (earlier.path.read_bytes(), _fingerprint(earlier.path)) + assert serve.main(["--snapshot", str(named), "--checkout-root", str(repo), + "--port", str(port)]) == 1 + err = capsys.readouterr().err + assert "serve refused:" in err and "OPENDOX_STATE_DIR" in err, err + assert str(earlier.path.resolve()) in err, err + assert (earlier.path.read_bytes(), _fingerprint(earlier.path)) == before + assert _port_is_free(port) + + +def test_the_plane_reports_its_snapshot_files_as_served_roots( + tmp_path, monkeypatch, standalone_profile) -> None: + """The roots `/snapshot.json` serves from: the configured snapshot, each + registered entry's snapshot file, and, on a loopback plane, the container + each session's snapshot is written in.""" + from opendox import branch_session + + with _serving(tmp_path, monkeypatch) as (httpd, _base, repo): + snapshot = (tmp_path / "snapshot.json").resolve() + assert snapshot in httpd.served_roots + assert branch_session.snapshots_root(repo) in httpd.served_roots + entries = httpd.RequestHandlerClass.func.source.registry.entries() + for entry in entries: + if getattr(entry, "snapshot_path", None): + assert Path(entry.snapshot_path).resolve() in httpd.served_roots + # ...and a registered entry whose snapshot is ANOTHER file + from opendox import default_registry, serve + + other = tmp_path / "elsewhere" / "other.snapshot.json" + other.parent.mkdir() + other.write_text(json.dumps({"generation": {}}), encoding="utf-8") + source = default_registry.SnapshotSource( + baked_snapshot=tmp_path / "snapshot.json", checkout_root=repo) + source.registry.register(default_registry.entry_from_snapshot_file( + other, repository="other", ref="main")) + declared = serve.build_server(WEB, tmp_path / "snapshot.json", repo, + port=0, quiet=True, snapshot_source=source) + try: + assert other.resolve() in declared.served_roots + finally: + declared.server_close() + + +def test_a_copy_published_during_a_rename_back_is_never_overwritten( + tmp_path, monkeypatch) -> None: + """Copilot at openDox-code#84, r4175213842. Where a hard link cannot be + made, another serve's copy is put back by a rename where the name is + free, and a still newer copy published between that check and the + rename would have been overwritten by the older one. Publication and + removal are serialized on the console directory's lock, so the newer + copy, published at exactly that moment, waits and then stands.""" + from opendox import console_access + + state = _state(tmp_path) + first = _write(state) + _abandon(first) # its reservation lost + second_token, third_token = _token(), _token() + second = _write(state, token=second_token) # another serve's, over the first + _abandon(second) # ...whose serve is gone too + real_exists = console_access._name_exists + raced: list = [] + + def then_publish(name, directory): + free = real_exists(name, directory) + if not raced: + raced.append(_publish_concurrently(state, third_token)) + return free + + def no_hard_links(*args, **kwargs): + raise PermissionError(errno.EPERM, "Operation not permitted") + + monkeypatch.setattr(console_access, "_name_exists", then_publish) + monkeypatch.setattr(console_access.os, "link", no_hard_links) + console_access.remove_private_copy(first) + monkeypatch.undo() + assert raced, "the race was never staged" + _settle(raced[0]) + record = console_access.read_private_copy(first.path) + assert record["console_token"] == third_token, "an older copy overwrote the newest" + assert sorted(p.name for p in first.path.parent.iterdir()) == [first.path.name] + + +def test_deferred_termination_holds_a_stop_until_the_block_ends() -> None: + """While a copy is being published or removed, SIGTERM is held and not + raised in the middle of it: publication raises it once the copy is + in hand, and removal, already a stop, lets it go.""" + from opendox import console_access + + # Every interrupt is CAUGHT here and judged, so a stop raised where it + # should have been held fails this case instead of ending the session. + reached = [] + with console_access.terminate_as_interrupt(True): + assert signal.getsignal(signal.SIGTERM) not in (signal.SIG_DFL, signal.SIG_IGN) + try: + with console_access.deferred_termination(): + try: + os.kill(os.getpid(), signal.SIGTERM) + signal.pthread_sigmask(signal.SIG_BLOCK, []) # deliver now + except KeyboardInterrupt: + pytest.fail("the stop was raised inside the block, not held") + reached.append("held") + except KeyboardInterrupt: + reached.append("raised once the block was done") + assert reached == ["held", "raised once the block was done"], reached + try: + with console_access.deferred_termination(raise_pending=False): + os.kill(os.getpid(), signal.SIGTERM) + signal.pthread_sigmask(signal.SIG_BLOCK, []) + except KeyboardInterrupt: + pytest.fail("a stop held during a removal was raised") + try: + with console_access.deferred_termination(): + pass # nothing left over + except KeyboardInterrupt: + pytest.fail("a stop that was let go came back") + + +@pytest.mark.parametrize("entry", ["serve", "generate-and-open"]) +def test_a_stop_during_publication_removes_the_copy( + tmp_path, monkeypatch, capsys, standalone_profile, entry) -> None: + """Copilot at openDox-code#84, r4175213864. SIGTERM's handling began + only after the copy was published, so a SIGTERM during publication (here + in the copy's read-back, after its rename into place) took its default + action and left the token's copy behind. It is installed BEFORE + publication on a plane that writes a copy, and held through it, so the + stop ends the start cleanly, before the startup line, with no copy left.""" + from opendox import cli, console_access, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = _state(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + monkeypatch.setattr(serve, "real_notebook_adapter", lambda *a, **k: None) + real_read = console_access.read_private_copy + stopped: list = [] + + def read_back(path): + if not stopped: + stopped.append(path) + # never deliver a SIGTERM that would END this test process + assert signal.getsignal(signal.SIGTERM) not in ( + signal.SIG_DFL, signal.SIG_IGN), "no handler during publication" + os.kill(os.getpid(), signal.SIGTERM) + return real_read(path) + + def served(self, *args, **kwargs): + raise AssertionError("the server served after a stop") + + monkeypatch.setattr(console_access, "read_private_copy", read_back) + monkeypatch.setattr(socketserver.BaseServer, "serve_forever", served) + if entry == "serve": + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + assert serve.main(["--snapshot", str(snapshot), "--checkout-root", str(repo), + "--port", "0"]) == 0 + startup = "serving ideation dashboard at" + else: + args = cli.build_parser().parse_args([ + "generate-and-open", "--repo-root", str(repo), "--repository", "fixture", + "--run-dir", str(tmp_path / "run"), "--port", "0", "--no-validate", + "--no-open"]) + assert cli._generate_and_open(args, opener=lambda url: None) == 0 + startup = " serving " + out = capsys.readouterr().out + assert stopped, "the stop was never staged" + assert startup not in out and "console " not in out, out + assert list((state / console_access.CONSOLE_DIRNAME).iterdir()) == [] + + +def test_a_stop_during_removal_lets_the_removal_finish( + tmp_path, monkeypatch, capsys, standalone_profile) -> None: + """The handler stays installed through removal, and a SIGTERM that + arrives while the copy is being removed is held, not raised in the middle + of it: the removal finishes, nothing is left, and the stop is clean.""" + from opendox import console_access, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = _state(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + monkeypatch.setattr(serve, "real_notebook_adapter", lambda *a, **k: None) + + def stopped_at_once(self, *args, **kwargs): + raise KeyboardInterrupt # Ctrl-C, at once + + real_rename = os.rename + taken: list = [] + + def take(src, dst, *args, **kwargs): + if not taken and ".removing-" in str(dst): + taken.append(dst) + assert signal.getsignal(signal.SIGTERM) not in ( + signal.SIG_DFL, signal.SIG_IGN), "no handler during removal" + os.kill(os.getpid(), signal.SIGTERM) + return real_rename(src, dst, *args, **kwargs) + + monkeypatch.setattr(socketserver.BaseServer, "serve_forever", stopped_at_once) + monkeypatch.setattr(console_access.os, "rename", take) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + assert serve.main(["--snapshot", str(snapshot), "--checkout-root", str(repo), + "--port", "0"]) == 0 + monkeypatch.undo() + assert taken, "the stop was never staged" + assert list((state / console_access.CONSOLE_DIRNAME).iterdir()) == [] + + +# --------------------------------------------------------------------------- +# 14 — Copilot's review at ddb26c34: every operating-system refusal on the +# way to the copy is a refusal by name, the walk's included +# --------------------------------------------------------------------------- + +def _overlong(tmp_path: Path) -> Path: + return tmp_path / ("x" * 300) / "state" # ENAMETOOLONG (errno 36) + + +def _unsearchable(tmp_path: Path) -> Path: + locked = tmp_path / "unsearchable" + locked.mkdir(mode=0o700) + locked.chmod(0o600) # no search (x) bit + return locked / "state" + + +@pytest.mark.parametrize("make", [_overlong, _unsearchable], + ids=["an overlong component", "an unsearchable parent"]) +def test_a_state_path_the_walk_cannot_take_is_refused_by_name(tmp_path, make) -> None: + """Copilot at openDox-code#84, r4177975898. The walk and the served-root + check ran outside the writer's conversion of operating-system errors, so + an overlong component (ENAMETOOLONG) or an unsearchable parent (EACCES) + escaped as a raw `OSError`, a traceback and no refusal by name. Each is + a `ConsoleAccessRefused` naming the copy, for the writer and the reader.""" + from opendox import console_access + + if make is _unsearchable and os.geteuid() == 0: + pytest.skip("root searches any directory") + state = make(tmp_path) + try: + with pytest.raises(console_access.ConsoleAccessRefused, + match="cannot be written") as refused: + _write(state) + assert str(console_access.private_copy_path(state, 8080)) in str(refused.value) + with pytest.raises(console_access.ConsoleAccessRefused, match="cannot be read"): + console_access.read_private_copy( + console_access.private_copy_path(state, 8080)) + finally: + if make is _unsearchable: + state.parent.chmod(0o700) + + +@pytest.mark.parametrize("make", [_overlong, _unsearchable], + ids=["an overlong component", "an unsearchable parent"]) +@pytest.mark.parametrize("entry", ["serve", "generate-and-open"]) +def test_a_state_path_the_walk_cannot_take_refuses_the_start( + tmp_path, monkeypatch, capsys, standalone_profile, make, entry) -> None: + """The same, through both entry points: exit 1 with the refusal named, + nothing printed that serves, and the listening socket closed.""" + from opendox import cli, serve + + if make is _unsearchable and os.geteuid() == 0: + pytest.skip("root searches any directory") + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = make(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + monkeypatch.setattr(serve, "real_notebook_adapter", lambda *a, **k: None) + + def served(self, *args, **kwargs): + raise AssertionError("the server served") + + monkeypatch.setattr(socketserver.BaseServer, "serve_forever", served) + port = _free_port() + try: + if entry == "serve": + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + assert serve.main(["--snapshot", str(snapshot), "--checkout-root", + str(repo), "--port", str(port)]) == 1 + refused, startup = "serve refused:", "serving ideation dashboard at" + else: + assert cli._generate_and_open( + _generate_and_open_args(tmp_path, repo, "--port", str(port)), + opener=lambda url: None) == 1 + refused, startup = "generate-and-open refused:", " serving " + out, err = capsys.readouterr() + assert refused in err and "cannot be written" in err, err + assert startup not in out, out + assert _port_is_free(port), "the refused start kept its socket" + finally: + if make is _unsearchable: + state.parent.chmod(0o700) + + +# --------------------------------------------------------------------------- +# 15 — Copilot's review at 9f328892: Ctrl-C is held like SIGTERM while a copy +# is written or removed (r4178041022) +# --------------------------------------------------------------------------- + +def _ctrl_c_is_held() -> None: + """Never send a SIGINT that Python's own handler would raise at once: a + raw KeyboardInterrupt aborts the whole pytest session, not one case.""" + assert signal.getsignal(signal.SIGINT) is not signal.default_int_handler, ( + "Ctrl-C still has Python's immediate handler") + + +def test_terminate_as_interrupt_takes_ctrl_c_only_from_its_default() -> None: + """Ctrl-C is held like SIGTERM, where it still has Python's own handler; + an ignored one, or a host's own handler, is left exactly as it was.""" + from opendox import console_access + + previous = signal.getsignal(signal.SIGINT) + try: + signal.signal(signal.SIGINT, signal.default_int_handler) + with console_access.terminate_as_interrupt(True): + _ctrl_c_is_held() + assert signal.getsignal(signal.SIGINT) is signal.default_int_handler + + def host(signum, frame): # a host's own handler + raise AssertionError("never sent") + + for kept in (signal.SIG_IGN, host): + signal.signal(signal.SIGINT, kept) + with console_access.terminate_as_interrupt(True): + assert signal.getsignal(signal.SIGINT) is kept + assert signal.getsignal(signal.SIGINT) is kept + finally: + signal.signal(signal.SIGINT, previous) + + +@pytest.mark.parametrize("entry", ["serve", "generate-and-open"]) +def test_ctrl_c_just_after_the_copys_rename_leaves_no_copy( + tmp_path, monkeypatch, capsys, standalone_profile, entry) -> None: + """The publication window: Ctrl-C right after the copy is renamed into + place, before the caller holds it, used to raise at once, and the + caller's cleanup, holding nothing, left the copy behind. It is held until + the copy is in hand, then the start stops cleanly with no copy left.""" + from opendox import cli, console_access, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = _state(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + monkeypatch.setattr(serve, "real_notebook_adapter", lambda *a, **k: None) + previous = signal.signal(signal.SIGINT, signal.default_int_handler) + real_replace = os.replace + sent: list = [] + + def then_ctrl_c(src, dst, *args, **kwargs): + real_replace(src, dst, *args, **kwargs) + if not sent and str(dst).endswith(".html"): + sent.append(dst) + _ctrl_c_is_held() + os.kill(os.getpid(), signal.SIGINT) + signal.pthread_sigmask(signal.SIG_BLOCK, []) # deliver now + + def served(self, *args, **kwargs): + raise AssertionError("the server served after a stop") + + monkeypatch.setattr(console_access.os, "replace", then_ctrl_c) + monkeypatch.setattr(socketserver.BaseServer, "serve_forever", served) + try: + if entry == "serve": + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + assert serve.main(["--snapshot", str(snapshot), "--checkout-root", + str(repo), "--port", "0"]) == 0 + else: + args = cli.build_parser().parse_args([ + "generate-and-open", "--repo-root", str(repo), "--repository", + "fixture", "--run-dir", str(tmp_path / "run"), "--port", "0", + "--no-validate", "--no-open"]) + assert cli._generate_and_open(args, opener=lambda url: None) == 0 + finally: + signal.signal(signal.SIGINT, previous) + out = capsys.readouterr().out + assert sent, "the Ctrl-C was never staged" + assert "console " not in out, out + assert list((state / console_access.CONSOLE_DIRNAME).iterdir()) == [] + + +def test_a_second_ctrl_c_after_the_removal_rename_leaves_nothing( + tmp_path, monkeypatch, capsys, standalone_profile) -> None: + """The removal window: a second Ctrl-C right after the removal renamed + the copy to its temporary name used to raise there and leave a + `.removing-*` file holding the token. It is held, the removal finishes, + and `console/` is left empty.""" + from opendox import console_access, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = _state(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + monkeypatch.setattr(serve, "real_notebook_adapter", lambda *a, **k: None) + previous = signal.signal(signal.SIGINT, signal.default_int_handler) + real_rename = os.rename + sent: list = [] + + def stopped_at_once(self, *args, **kwargs): + raise KeyboardInterrupt # the first Ctrl-C + + def then_ctrl_c(src, dst, *args, **kwargs): + real_rename(src, dst, *args, **kwargs) + if not sent and ".removing-" in str(dst): + sent.append(dst) + _ctrl_c_is_held() + os.kill(os.getpid(), signal.SIGINT) # the second + signal.pthread_sigmask(signal.SIG_BLOCK, []) + + monkeypatch.setattr(socketserver.BaseServer, "serve_forever", stopped_at_once) + monkeypatch.setattr(console_access.os, "rename", then_ctrl_c) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + try: + assert serve.main(["--snapshot", str(snapshot), "--checkout-root", + str(repo), "--port", "0"]) == 0 + finally: + signal.signal(signal.SIGINT, previous) + monkeypatch.undo() + assert sent, "the second Ctrl-C was never staged" + assert list((state / console_access.CONSOLE_DIRNAME).iterdir()) == [] + + +# --------------------------------------------------------------------------- +# 16 — Copilot's review at fb8a1cc4: a live console's copy is reserved for +# its server's life (r4178133814); every unguarded file read refuses a +# private copy by the file's own identity (r4178133842) +# --------------------------------------------------------------------------- + +def _abandon(copy) -> None: + """The serve that wrote `copy` is gone without removing it (a crash, a + SIGKILL): its reservation is released, as the kernel releases it.""" + reservation = getattr(copy, "reservation", None) + if reservation is not None: + reservation.close() + + +def test_a_running_consoles_copy_is_never_replaced(tmp_path) -> None: + """Two consoles can share a port number (127.0.0.1 and ::1) and a state + directory. The first's copy is reserved while it runs: a second + publication on that port is refused by name, and the first's copy is left + exactly as it was, still opening the first console. Once the first stops + and removes its copy, the port's copy can be written again.""" + from opendox import console_access + + state = _state(tmp_path) + first = _write(state) + before = (first.path.read_bytes(), _fingerprint(first.path)) + with pytest.raises(console_access.ConsoleAccessRefused, + match="still running") as refused: + _write(state) + assert str(first.path) in str(refused.value) + assert (first.path.read_bytes(), _fingerprint(first.path)) == before + console_access.remove_private_copy(first) + assert _write(state).path == first.path + + +def test_a_copy_whose_console_died_is_replaced(tmp_path) -> None: + """A copy whose writer died without removing it (here a process that + writes one and exits at once) holds no reservation, since the kernel + released it with the process. It is a stale copy, and is replaced.""" + import subprocess + import sys + + from opendox import console_access + + state = _state(tmp_path) + env = {k: v for k, v in os.environ.items() if not k.startswith(("GIT_", "XF_"))} + env["PYTHONPATH"] = os.pathsep.join([str(ROOT / "src"), env.get("PYTHONPATH", "")]) + done = subprocess.run( + [sys.executable, "-c", + "import sys; from opendox import console_access, serve\n" + "console_access.write_private_copy(sys.argv[1], " + "page_url='http://127.0.0.1:8080/index.html', port=8080, " + "token=serve.mint_console_token(), served_roots=())\n", + str(state)], env=env, capture_output=True, text=True, timeout=60) + assert done.returncode == 0, done.stderr + stale = console_access.private_copy_path(state, 8080) + old = console_access.read_private_copy(stale)["console_token"] + token = _token() + assert _write(state, token=token).path == stale + assert console_access.read_private_copy(stale)["console_token"] == token != old + + +@pytest.mark.skipif(not socket.has_ipv6, reason="no IPv6 on this platform") +def test_two_consoles_on_one_port_number_never_share_a_copy( + tmp_path, monkeypatch, standalone_profile) -> None: + """Copilot's layout, as two real planes: one bound to 127.0.0.1 and one + to ::1, on the same port number and the same state directory. The second + is refused by name, and the first's copy still opens the first.""" + from opendox import console_access, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = _state(tmp_path) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + v4 = serve.build_server(WEB, snapshot, repo, host="127.0.0.1", port=0, quiet=True) + port = v4.server_address[1] + try: + v6 = serve.build_server(WEB, snapshot, repo, host="::1", port=port, quiet=True) + except OSError as exc: + v4.server_close() + pytest.skip(f"no IPv6 loopback here: {exc}") + try: + env = {"OPENDOX_STATE_DIR": str(state)} + first = console_access.publish( + v4, page_url=serve.server_url(v4, "/index.html"), env=env) + with pytest.raises(console_access.ConsoleAccessRefused, match="still running"): + console_access.publish( + v6, page_url=serve.server_url(v6, "/index.html"), env=env) + record = console_access.read_private_copy(first.path) + assert record["console_token"] == v4.console_token + assert "127.0.0.1" in record["page_url"] + console_access.remove_private_copy(first) + finally: + v4.server_close() + v6.server_close() + + +def _guarded_plane(tmp_path, monkeypatch, **build): + from opendox import serve + + _clean_git(monkeypatch) + repo = build.pop("repo", None) or _repository(tmp_path) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + web = build.pop("web", WEB) + httpd = serve.build_server(web, snapshot, repo, port=0, quiet=True, **build) + worker = threading.Thread(target=httpd.serve_forever, daemon=True) + worker.start() + return httpd, repo, worker + + +def _stop_plane(httpd, worker) -> None: + httpd.shutdown() + httpd.server_close() + worker.join(timeout=10) + + +def _assert_never_served(base, token: str, paths) -> None: + for path in paths: + for method in ("GET", "HEAD"): + status, headers, raw = _call(base, method, path) + assert status == 404, (method, path, status) + assert token.encode() not in raw, (method, path) + assert all(token not in str(v) for v in headers.values()), path + + +def test_a_source_root_retargeted_after_publication_never_serves_the_copy( + tmp_path, monkeypatch, standalone_profile) -> None: + """Copilot at openDox-code#84, r4178133842. A declared source root named + through a link is judged where the link leads when the copy is + published, but `/source` resolves it again on every request. Re-pointed + at the state directory afterwards, it used to serve the copy to anyone. + Every `/source` read is judged by the identity of the file it opened, so + the copy is never served, whatever the root leads to now.""" + from opendox import console_access, serve + + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + (elsewhere / "note.md").write_text("# a note\n", encoding="utf-8") + alias = tmp_path / "root-alias" + alias.symlink_to(elsewhere) + state = _state(tmp_path) + httpd, _repo, worker = _guarded_plane( + tmp_path, monkeypatch, repository="other", source_roots={"other": str(alias)}) + try: + base = httpd.server_address[:2] + copy = console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state)}) + assert _call(base, "GET", "/source/note.md")[0] == 200 + alias.unlink() + alias.symlink_to(state) # retargeted after the check + name = copy.path.name + _assert_never_served(base, httpd.console_token, + (f"/source/console/{name}", + f"/source/other@main/console/{name}")) + finally: + _stop_plane(httpd, worker) + + +def test_a_snapshot_retargeted_after_publication_never_serves_the_copy( + tmp_path, monkeypatch, standalone_profile) -> None: + """The same for `/snapshot.json`, which reads a registered entry's file + directly: an entry whose snapshot is named through a link re-pointed at + the copy after publication is a 404, never the copy.""" + from opendox import console_access, default_registry, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + real = tmp_path / "other.snapshot.json" + real.write_text(json.dumps({"generation": {}}), encoding="utf-8") + alias = tmp_path / "snapshot-alias.json" + alias.symlink_to(real) + source = default_registry.SnapshotSource( + baked_snapshot=tmp_path / "snapshot.json", checkout_root=repo) + source.registry.register(default_registry.entry_from_snapshot_file( + alias, repository="other", ref="main")) + state = _state(tmp_path) + httpd, _repo, worker = _guarded_plane(tmp_path, monkeypatch, repo=repo, + snapshot_source=source) + try: + base = httpd.server_address[:2] + copy = console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state)}) + path = "/snapshot.json?repository=other&ref=main" + assert _call(base, "GET", path)[0] == 200 + alias.unlink() + alias.symlink_to(copy.path) # retargeted after the check + _assert_never_served(base, httpd.console_token, (path,)) + finally: + _stop_plane(httpd, worker) + + +@pytest.mark.parametrize("where", ["the static bundle", "the served checkout"]) +def test_a_hard_link_to_the_copy_is_never_served( + tmp_path, monkeypatch, standalone_profile, where) -> None: + """A path cannot tell a hard link from the file itself: `web/x.html`, or + `checkout/x.md`, hard-linked to the copy, resolves to a name outside the + state directory. The file's own identity tells, so the static handler + and `/source` answer 404 and never send the copy.""" + import shutil as _shutil + + from opendox import console_access, serve + + web = tmp_path / "web" + _shutil.copytree(WEB, web) + state = _state(tmp_path) + httpd, repo, worker = _guarded_plane(tmp_path, monkeypatch, web=web) + try: + base = httpd.server_address[:2] + copy = console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state)}) + if where == "the static bundle": + os.link(copy.path, web / "hard.html") + paths = ("/hard.html",) + else: + os.link(copy.path, repo / "hard.md") + paths = ("/source/hard.md",) + _assert_never_served(base, httpd.console_token, paths) + assert _call(base, "GET", "/index.html")[0] == 200 + finally: + _stop_plane(httpd, worker) + + +def _raw_get(base, path: str) -> bytes: + """Every byte the server sends for `GET path`, read until it closes: + a response cut short is read as far as it went, never raised.""" + with socket.create_connection(base, timeout=30) as conn: + conn.sendall(f"GET {path} HTTP/1.0\r\nHost: {base[0]}:{base[1]}\r\n\r\n" + .encode("ascii")) + received = b"" + while True: + chunk = conn.recv(65536) + if not chunk: + return received + received += chunk + + +def test_the_static_backstop_never_sends_a_copy_swapped_in_after_the_check( + tmp_path, monkeypatch, standalone_profile) -> None: + """The file the stdlib handler opens is judged after it opens, against a + link swapped in between the handler's own check and that open. Staged by + blinding the first check: the copy's bytes are still never sent.""" + import shutil as _shutil + + from opendox import console_access, serve + + web = tmp_path / "web" + _shutil.copytree(WEB, web) + state = _state(tmp_path) + httpd, _repo, worker = _guarded_plane(tmp_path, monkeypatch, web=web) + try: + base = httpd.server_address[:2] + copy = console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state)}) + os.link(copy.path, web / "swapped.html") + monkeypatch.setattr(console_access, "opens_a_private_file", + lambda path, roots: False) # the race, won + received = _raw_get(base, "/swapped.html") + assert httpd.console_token.encode() not in received, received[:300] + assert b"opendox-console" not in received + assert b"index" in _raw_get(base, "/index.html").lower() + finally: + _stop_plane(httpd, worker) + + +def test_a_snapshot_named_at_a_copy_not_yet_written_refuses_the_start( + tmp_path, monkeypatch, capsys, standalone_profile) -> None: + """The configured snapshot is a served root even when its file does not + exist yet, and so is registered as no entry: `/snapshot.json` falls back + to reading that path. Named at the copy this start is about to write, + `/console/.html`, it refuses the start by name, and no copy + is written.""" + from opendox import console_access, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = _state(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + monkeypatch.setattr(serve, "real_notebook_adapter", lambda *a, **k: None) + + def served(self, *args, **kwargs): + raise AssertionError("the server served") + + monkeypatch.setattr(socketserver.BaseServer, "serve_forever", served) + port = _free_port() + future = console_access.private_copy_path(state, port) + assert serve.main(["--snapshot", str(future), "--checkout-root", str(repo), + "--port", str(port)]) == 1 + err = capsys.readouterr().err + assert "serve refused:" in err and "OPENDOX_STATE_DIR" in err, err + assert not future.exists() + assert _port_is_free(port) + + +# --------------------------------------------------------------------------- +# 17 — the holder's adversarial review of fb8a1cc4 and 73df8bac +# (openDox-code#84; B1, B2, B4, B5, B6, B8, B9). The cases named +# `test_b*` are the reviewer's own, kept as they were written. +# --------------------------------------------------------------------------- + +def _adv_env(state: Path | None = None) -> dict: + env = {k: v for k, v in os.environ.items() + if not k.startswith(("GIT_", "XF_", "OPENDOX_"))} + env.update(GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_SYSTEM=os.devnull, + LANG="C.UTF-8", PYTHONUNBUFFERED="1", PYTHONPATH=str(ROOT / "src")) + if state is not None: + env["OPENDOX_STATE_DIR"] = str(state) + return env + + +def _adv_repo(where: Path, *, identity: bool = True) -> Path: + """A checkout; with no `identity`, its plane resolves no actor, so it has + no session verbs and mints no token.""" + import subprocess + + def run(*args: str) -> None: + subprocess.run(["git", *args], cwd=where, env=_adv_env(), check=True, + capture_output=True) + + where.mkdir(parents=True, exist_ok=True) + run("init", "-q", "-b", "main") + if identity: + run("config", "user.name", "fixture") + run("config", "user.email", "fixture@example.invalid") + (where / "README.md").write_text("# fixture\n") + run("add", ".") + run("-c", "user.name=x", "-c", "user.email=x@example.invalid", + "commit", "-qm", "init") + return where + + +def _adv_serve(tmp: Path, repo: Path, state: Path, port: int, name: str, + code: str | None = None): + """`python -m opendox.serve` as a user starts it, or `code` and then + `serve.main`, waited for until it serves or ends.""" + import subprocess + import sys + import time + + snapshot = tmp / f"{name}.json" + snapshot.write_text(json.dumps({"generation": {}})) + out = tmp / f"{name}.out" + argv = ["--snapshot", str(snapshot), "--checkout-root", str(repo), + "--port", str(port)] + if code is None: + cmd = [sys.executable, "-m", "opendox.serve", *argv] + else: + cmd = [sys.executable, "-c", code + f"\nsys.exit(serve.main({argv!r}))"] + parent = os.getpid() + proc = subprocess.Popen(cmd, cwd=tmp, env=_adv_env(state), + stdout=out.open("w"), stderr=subprocess.STDOUT, + start_new_session=True, + preexec_fn=lambda: _child_setup(parent)) + _SPAWNED.append(proc) + for _ in range(300): + if "serving ideation dashboard" in out.read_text() or proc.poll() is not None: + break + time.sleep(0.1) + return proc, out + + +#: Every server child `_adv_serve` started, reaped after each case +#: (`_reap_spawned_servers`), whether the case passed, failed or raised. +_SPAWNED: list = [] +#: `prctl(2)`, loaded in the test process, before any fork, on Linux only. +_PRCTL = None +if sys.platform.startswith("linux"): + import ctypes as _ctypes + + with contextlib.suppress(OSError, AttributeError): + _PRCTL = _ctypes.CDLL(None, use_errno=True).prctl +_PR_SET_PDEATHSIG = 1 + + +def _child_setup(parent: int) -> None: + """In the child, before it runs. A child started from a background job + inherits SIGINT ignored, and one under `nohup` SIGHUP: give it a + terminal's, so its stops are read. And on Linux, have the kernel send it + SIGTERM if the test process dies first (a killed run runs no teardown), + so no server outlives the run that started it.""" + signal.signal(signal.SIGINT, signal.default_int_handler) + signal.signal(signal.SIGHUP, signal.SIG_DFL) + if _PRCTL is not None: + _PRCTL(_PR_SET_PDEATHSIG, int(signal.SIGTERM), 0, 0, 0) + if os.getppid() != parent: # the parent died before prctl + os._exit(1) + + +def _adv_stop(proc) -> int: + if proc.poll() is None: + proc.send_signal(signal.SIGTERM) + return proc.wait(30) + + +def _reap(proc, wait: float = 15) -> None: + """Stop `proc` and its process group (its own session, so nothing else + is in it): SIGTERM, a bounded wait, then SIGKILL. A child that already + ended is only collected.""" + import subprocess + + if proc.poll() is not None: + return + for sent in (signal.SIGTERM, signal.SIGKILL): + with contextlib.suppress(ProcessLookupError, PermissionError): + os.killpg(proc.pid, sent) + try: + proc.wait(wait) + return + except subprocess.TimeoutExpired: + continue + + +@pytest.fixture(autouse=True) +def _reap_spawned_servers(): + """No server child outlives its case: every one `_adv_serve` started is + stopped with its process group at teardown, the failing cases' included + (a refusal that did not come, say, leaves a plane serving).""" + yield + while _SPAWNED: + _reap(_SPAWNED.pop()) + + +def test_b1_a_tokenless_sibling_plane_never_serves_another_planes_copy( + tmp_path) -> None: + """B1, as the reviewer staged it, with two real planes. Plane A (a git + identity, so a token) publishes into the shared state directory. Plane B + (no identity, so no token) serves a checkout that HOLDS that directory. + B used to ask no boundary and mark no private root, so its `/source` + served A's copy, token and all. It is the same plane's boundary now, + token or not: B refuses its start by name, and serves nothing.""" + from opendox import console_access + + repo_a = _adv_repo(tmp_path / "a") + outer = _adv_repo(tmp_path / "outer", identity=False) + state = _state(outer) + port_a, port_b = _free_port(), _free_port() + a, _out_a = _adv_serve(tmp_path, repo_a, state, port_a, "a") + try: + assert a.poll() is None, _out_a.read_text() + token = console_access.read_private_copy( + console_access.private_copy_path(state, port_a))["console_token"] + b, out_b = _adv_serve(tmp_path, outer, state, port_b, "b") + rc = b.wait(60) + text = out_b.read_text() + assert rc == 1 and "serve refused:" in text, text + assert "OPENDOX_STATE_DIR" in text and str(outer.resolve()) in text, text + assert "Traceback" not in text and token not in text, text + assert _port_is_free(port_b), "the refused plane kept its socket" + assert a.poll() is None, "plane A went down with B's refusal" + finally: + _adv_stop(a) # B, if it never refused, is reaped at teardown + + +def test_a_tokenless_standalone_plane_keeps_the_boundary( + tmp_path, monkeypatch, standalone_profile) -> None: + """B1 in the process: a standalone plane that minted no token is still a + standalone plane (its delivery does not depend on the token), and its + publication still asks the boundary, refusing by name a state directory + inside its checkout. It writes nothing, and the sibling's copy there is + left as it was.""" + from opendox import console_access, serve + + _clean_git(monkeypatch) + outer = fresh_repository(PLAIN, tmp_path / "b") + state = _state(outer) + other = _write(state, port=9) # a sibling plane's copy + before = (other.path.read_bytes(), _fingerprint(other.path)) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + httpd = serve.build_server(WEB, snapshot, outer, port=0, quiet=True) + try: + assert httpd.console_token is None, "the case is vacuous: a token was minted" + assert httpd.console_token_delivery == console_access.DELIVERY_OPENED_URL + assert not console_access.needs_copy(httpd) + with pytest.raises(console_access.ConsoleAccessRefused, + match="OPENDOX_STATE_DIR") as refused: + console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state)}) + assert "lies inside the served repository" in str(refused.value) + assert str(outer.resolve()) in str(refused.value) + finally: + httpd.server_close() + assert sorted(p.name for p in (state / console_access.CONSOLE_DIRNAME).iterdir()) \ + == [other.path.name] + assert (other.path.read_bytes(), _fingerprint(other.path)) == before + console_access.remove_private_copy(other) + + +def test_a_tokenless_standalone_plane_never_serves_a_siblings_copy( + tmp_path, monkeypatch, standalone_profile) -> None: + """B1, the reviewer's `--web-dir` link variant: no root of the tokenless + plane holds the state directory, but a link inside its static bundle + leads there, and a hard link in its checkout is the copy by another name. + The copies' directory is marked private on this plane too, so the + sibling's copy and the directory's listing are 404, through the static + handler and `/source` alike, and the bundle still answers.""" + import shutil as _shutil + + from opendox import console_access, serve + + web = tmp_path / "web" + _shutil.copytree(WEB, web) + state = _state(tmp_path) + (web / "state-alias").symlink_to(state) + outer = fresh_repository(PLAIN, tmp_path / "b") + httpd, _repo, worker = _guarded_plane(tmp_path, monkeypatch, repo=outer, web=web) + try: + base = httpd.server_address[:2] + assert httpd.console_token is None, "the case is vacuous: a token was minted" + other = _write(state, port=9) # a sibling plane's copy + token = console_access.read_private_copy(other.path)["console_token"] + assert console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state)}) is None + assert sorted(p.name for p in other.path.parent.iterdir()) == [other.path.name] + os.link(other.path, outer / "hard.md") + _assert_never_served(base, token, ( + f"/state-alias/console/{other.path.name}", "/state-alias/console/", + "/source/hard.md")) + status, _headers, raw = _call(base, "GET", "/state-alias/console/") + assert other.path.name.encode() not in raw + assert _call(base, "GET", "/index.html")[0] == 200 + finally: + _stop_plane(httpd, worker) + + +def test_b2_a_platform_without_the_posix_primitives_refuses_by_name( + tmp_path) -> None: + """B2, as the reviewer staged it: on Windows `os.getuid`, `O_NOFOLLOW` and + `O_DIRECTORY` do not exist. The standalone start is a refusal by name, as + `bundle.unsupported_platform` names the same gap, and never an + `AttributeError` traceback.""" + import textwrap + + repo = _adv_repo(tmp_path / "r") + state = _state(tmp_path) + code = textwrap.dedent(""" + import os, sys + from opendox import serve + for name in ('getuid', 'O_NOFOLLOW', 'O_DIRECTORY'): + delattr(os, name) + """) + proc, out = _adv_serve(tmp_path, repo, state, _free_port(), "w", code=code) + rc = proc.wait(60) + text = out.read_text() + assert "Traceback" not in text, text + assert rc == 1 and "serve refused:" in text, text + assert "needs a POSIX platform" in text and "os.getuid" in text, text + assert not (state / "console").exists() + + +@pytest.mark.parametrize("gap", ["os.O_NOFOLLOW", "a directory descriptor"]) +def test_the_writer_and_the_reader_refuse_a_platform_without_the_primitives( + tmp_path, monkeypatch, gap) -> None: + """B2, by part: the writer refuses before it writes anything, the reader + before it reads, each naming the gap (`unsupported_platform`).""" + from opendox import console_access + + state = _state(tmp_path) + copy = _write(state) # while the primitives exist + if gap == "os.O_NOFOLLOW": + monkeypatch.delattr(os, "O_NOFOLLOW") + named = "os.O_NOFOLLOW" + else: + monkeypatch.setattr(console_access, "_DIR_FD_CALLS", False) + named = "calls relative to a directory's descriptor" + try: + assert named in (console_access.unsupported_platform() or "") + with pytest.raises(console_access.ConsoleAccessRefused, + match="needs a POSIX platform") as refused: + _write(state, port=9) + assert named in str(refused.value) + assert not console_access.private_copy_path(state, 9).exists() + with pytest.raises(console_access.ConsoleAccessRefused, + match="needs a POSIX platform"): + console_access.read_private_copy(copy.path) + finally: + monkeypatch.undo() + assert console_access.unsupported_platform() is None + console_access.remove_private_copy(copy) + + +def _two_spellings(monkeypatch, real: Path, alias: Path) -> None: + """A case-insensitive filesystem, as far as `os.stat` and `os.listdir` + can tell: `alias`, and every name under it, is `real`. Linux cannot spell + one directory two ways (a bind mount needs root), so the two calls a + second spelling reaches are told so; `os.lstat`, and so resolving, still + sees `alias` as a name that does not exist, as macOS's resolving keeps + the case it was given.""" + real_stat, real_listdir = os.stat, os.listdir + + def mapped(path): + if isinstance(path, (str, os.PathLike)): + text = os.fspath(path) + if isinstance(text, str) and (text == str(alias) + or text.startswith(str(alias) + os.sep)): + return str(real) + text[len(str(alias)):] + return path + + monkeypatch.setattr(os, "stat", lambda path, *a, **k: real_stat(mapped(path), *a, **k)) + monkeypatch.setattr(os, "listdir", + lambda path=".", *a, **k: real_listdir(mapped(path), *a, **k)) + + +@pytest.mark.parametrize("served", ["the state directory", "a root inside it", + "a root holding it"]) +def test_the_boundary_knows_a_second_spelling_by_its_identity( + tmp_path, monkeypatch, served) -> None: + """B4, the overlap: on a case-insensitive filesystem `/OUTER/state` + IS `/outer/state`, though no name says so. The boundary compares the + directories' identities too, so the second spelling of the state + directory, of a root inside it, or of a root holding it, refuses by name + before anything is written.""" + from opendox import console_access + + outer = tmp_path / "outer" + outer.mkdir() + state = _state(outer) + alias = tmp_path / "OUTER" + _two_spellings(monkeypatch, outer, alias) + root = {"the state directory": alias / "state", + "a root inside it": alias / "state" / "inner", + "a root holding it": alias}[served] + with pytest.raises(console_access.ConsoleAccessRefused, + match="OPENDOX_STATE_DIR") as refused: + console_access.write_private_copy( + state, page_url="http://127.0.0.1:8080/index.html", port=8080, + token=_token(), served_roots=(root,)) + assert str(root) in str(refused.value) + assert not (state / console_access.CONSOLE_DIRNAME).exists() + + +def test_a_second_spelling_of_the_copies_directory_is_never_listed( + tmp_path, monkeypatch, standalone_profile) -> None: + """B4, the static guard, in the reviewer's r9 layout: `web/state-alias` + leads to the state directory, and on a case-insensitive filesystem + `/state-alias/CONSOLE/` lists the copies' directory under a name no + private root spells. The guard knows the directory by its identity, so + that listing is a 404 like the plain spelling's.""" + import shutil as _shutil + + from opendox import console_access, serve + + web = tmp_path / "web" + _shutil.copytree(WEB, web) + state = _state(tmp_path) + (web / "state-alias").symlink_to(state) + httpd, _repo, worker = _guarded_plane(tmp_path, monkeypatch, web=web) + try: + base = httpd.server_address[:2] + copy = console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state)}) + _two_spellings(monkeypatch, copy.path.parent, state / "CONSOLE") + assert console_access.within_private_roots( + web / "state-alias" / "CONSOLE", httpd.private_roots) + for path in ("/state-alias/CONSOLE/", "/state-alias/console/"): + for method in ("GET", "HEAD"): + status, _headers, raw = _call(base, method, path) + assert status == 404, (method, path, status) + assert copy.path.name.encode() not in raw, (method, path) + assert _call(base, "GET", "/index.html")[0] == 200 + finally: + _stop_plane(httpd, worker) + + +def test_b3_a_second_stop_before_the_cleanup_hold_leaves_no_copy(tmp_path) -> None: + """B5 (the reviewer's `test_b3`): a double Ctrl-C, or SIGTERM and then the + SIGHUP of a closing terminal. The second stop lands after the first + unwound the serve loop and before the cleanup's + `deferred_termination(raise_pending=False)` holds anything; it is + delivered here at exactly that point. Only the first stop is raised, so + the cleanup runs to its end: no copy, no traceback, exit 0.""" + import textwrap + + repo = _adv_repo(tmp_path / "r") + state = _state(tmp_path) + port = _free_port() + code = textwrap.dedent(""" + import os, signal, sys + from opendox import console_access as ca, serve + real = ca.deferred_termination + def window(*, raise_pending=True): + if not raise_pending: + os.kill(os.getpid(), signal.SIGINT) + for _ in range(1000): + pass + return real(raise_pending=raise_pending) + ca.deferred_termination = window + """) + proc, out = _adv_serve(tmp_path, repo, state, port, "s", code=code) + copy = state / "console" / f"{port}.html" + assert copy.exists(), out.read_text() + rc = _adv_stop(proc) + assert not copy.exists(), "the copy outlived the stop" + assert "Traceback" not in out.read_text() and rc == 0, out.read_text() + + +def test_only_the_first_stop_is_raised() -> None: + """B5 in the process: once a stop has been raised, a later one is only + recorded, held or not; and a console that starts again raises its own + first stop.""" + from opendox import console_access + + def stop() -> None: + os.kill(os.getpid(), signal.SIGTERM) + signal.pthread_sigmask(signal.SIG_BLOCK, []) # deliver now + + for _attempt in range(2): + raised: list[str] = [] + with console_access.terminate_as_interrupt(True): + try: + stop() + except KeyboardInterrupt: + raised.append("first") + try: + stop() + for _ in range(1000): + pass + except KeyboardInterrupt: + pytest.fail("a second stop was raised") + try: + with console_access.deferred_termination(): + stop() + except KeyboardInterrupt: + pytest.fail("a stop held after the first was raised") + assert raised == ["first"], raised + + +def test_b6a_a_link_another_user_owns_on_the_state_path_is_refused( + tmp_path, monkeypatch) -> None: + """B6(a): `_walked`'s link-owner rule, the only guard against a link + another user owns (and can re-point) on OPENDOX_STATE_DIR. Simulated by + reporting the link's owner as another uid.""" + from opendox import console_access + + private = _state(tmp_path / "private") + alias = tmp_path / "alias" + alias.symlink_to(private) + real_lstat = os.lstat + + def lstat(path, *a, **k): + info = real_lstat(path, *a, **k) + if Path(path) == alias: + fields = list(info) + fields[stat.ST_UID] = os.getuid() + 1 + return os.stat_result(fields) + return info + + monkeypatch.setattr(console_access.os, "lstat", lstat) + with pytest.raises(console_access.ConsoleAccessRefused, + match="symbolic link owned by uid"): + console_access.write_private_copy( + alias, page_url="http://127.0.0.1:8080/index.html", port=8080, + token="t" * 43, served_roots=()) + assert not (private / "console").exists() + + +def test_b6b_the_reader_judges_the_state_directory_again(tmp_path) -> None: + """B6(b): `read_private_copy` asks all of it again: a state directory + loosened to 1777 after the copy was written (sticky, so the walk's rule + for the directories above lets it pass) is refused by the reader.""" + from opendox import console_access + + state = _state(tmp_path / "state") + copy = console_access.write_private_copy( + state, page_url="http://127.0.0.1:8080/index.html", port=8080, + token="t" * 43, served_roots=()) + state.chmod(0o1777) + try: + with pytest.raises(console_access.ConsoleAccessRefused, + match="writable by every user"): + console_access.read_private_copy(copy.path) + finally: + state.chmod(0o700) + console_access.remove_private_copy(copy) + + +def test_b6c_the_copy_is_0600_under_a_umask_that_strips_owner_write(tmp_path) -> None: + """B6(c): `os.fchmod(handle, 0o600)` is what makes the copy 0600 where the + umask removes an owner bit. The umask case of section 3 uses umask 0, + where `os.open`'s mode alone gives 0600.""" + from opendox import console_access + + state = _state(tmp_path / "state") + previous = os.umask(0o277) + try: + copy = console_access.write_private_copy( + state, page_url="http://127.0.0.1:8080/index.html", port=8080, + token="t" * 43, served_roots=()) + finally: + os.umask(previous) + assert stat.S_IMODE(os.lstat(copy.path).st_mode) == 0o600 + console_access.remove_private_copy(copy) + + +def test_a_no_serve_run_publishes_opens_and_prints_no_copy( + tmp_path, monkeypatch, capsys, standalone_profile) -> None: + """B8: `--no-serve` closes the server once it has printed the URL, so a + copy written for it opened a console page nothing answered, and was gone + as the run returned. No copy is written, none is opened, and no console + line is printed; the page's URL is still printed and opened, as it was + before T104, and carries no token.""" + from opendox import cli, console_access + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = _state(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + written: list[object] = [] + real = console_access.write_private_copy + + def recording(*args, **kwargs): + written.append(args) + return real(*args, **kwargs) + + def served(self, *args, **kwargs): + raise AssertionError("a --no-serve run served") + + monkeypatch.setattr(console_access, "write_private_copy", recording) + monkeypatch.setattr(socketserver.BaseServer, "serve_forever", served) + opened: list[str] = [] + assert cli._generate_and_open( + _generate_and_open_args(tmp_path, repo, "--no-serve"), + opener=opened.append) == 0 + out = capsys.readouterr().out + assert written == [], "a --no-serve run wrote a copy" + assert not (state / console_access.CONSOLE_DIRNAME).exists() + assert " console " not in out and "console_token" not in out, out + assert console_access.UNOPENABLE_HINT not in out, out + (url,), = [opened] + assert url.startswith("http://") and url.endswith("/index.html"), url + assert url in out.splitlines(), out + + +def _own_file(directory: Path, name: str, mode: int = 0o600) -> Path: + path = directory / name + path.write_text("left behind\n", encoding="utf-8") + path.chmod(mode) + return path + + +def test_a_publication_sweeps_the_copies_whose_consoles_died(tmp_path) -> None: + """B9: a serve that died (SIGKILL, an out-of-memory kill) left its copy, + a token in it, until a later serve took the same port. A publication now + sweeps every copy whose reservation is free, and the temporary and + taken names a writer or a remover left mid-way. A running console's copy + is never swept, and neither is anything that is not this user's own + copy-shaped file of mode 0600: a loosened copy (refused by name, never + replaced), a link, a file of another name.""" + from opendox import console_access + + state = _state(tmp_path) + dead = _write(state, port=9) + _abandon(dead) # its server died + alive = _write(state, port=10) # its server still runs + console = alive.path.parent + left = [_own_file(console, ".11.html.opendox-424242"), + _own_file(console, ".12.html.removing-1-0123456789ab")] + kept = [_own_file(console, "13.html", mode=0o644), + _own_file(console, "notes.txt")] + (console / "14.html").symlink_to(tmp_path / "elsewhere.html") + fresh = _write(state, port=8080) + names = sorted(p.name for p in console.iterdir()) + assert dead.path.name not in names, "a dead console's copy was not swept" + assert not any(p.name in names for p in left), names + assert sorted([alive.path.name, fresh.path.name, "13.html", "14.html", + "notes.txt"]) == names + assert console_access.read_private_copy(alive.path)["console_token"] + for copy in (alive, fresh): + console_access.remove_private_copy(copy) + + +def test_a_sigkilled_serves_copy_is_swept_by_the_next_serve(tmp_path) -> None: + """B9, as the reviewer staged it: one serve is killed with SIGKILL, which + runs no cleanup, and another starts on another port. The killed serve's + copy is swept when the second publishes, and the second's goes when it + stops: nothing is left.""" + repo = _adv_repo(tmp_path / "r") + state = _state(tmp_path) + first_port = _free_port() + first, _out = _adv_serve(tmp_path, repo, state, first_port, "first") + assert (state / "console" / f"{first_port}.html").exists(), _out.read_text() + first.send_signal(signal.SIGKILL) + first.wait(20) + assert (state / "console" / f"{first_port}.html").exists() + second, out = _adv_serve(tmp_path, repo, state, _free_port(), "second") + try: + assert second.poll() is None, out.read_text() + assert not (state / "console" / f"{first_port}.html").exists(), \ + "the killed serve's copy was not swept" + finally: + assert _adv_stop(second) == 0 + assert list((state / "console").iterdir()) == [] + + +@pytest.mark.parametrize("entry", ["serve", "generate-and-open"]) +def test_the_start_prints_the_unopenable_hint_and_never_the_token( + tmp_path, monkeypatch, capsys, standalone_profile, entry) -> None: + """B3, RULED by Brett ("Hint line, accepted limit", 2026-10-04): a snap + or Flatpak browser cannot open a file under a hidden directory such as + `~/.local/state`, and a Windows browser under WSL may not open a Linux + path at all. The token is never printed, so beside the copy's path the + start prints ONE line saying how to move the state directory, and no + line it prints, that one included, carries the token.""" + from opendox import cli, console_access, serve + + _clean_git(monkeypatch) + repo = _repository(tmp_path) + state = _state(tmp_path) + monkeypatch.setenv("OPENDOX_STATE_DIR", str(state)) + monkeypatch.setattr(serve, "real_notebook_adapter", lambda *a, **k: None) + tokens: list[str] = [] + + def serve_forever(self, *args, **kwargs): + copy = console_access.private_copy_path(state, self.server_address[1]) + tokens.append(console_access.read_private_copy(copy)["console_token"]) + raise KeyboardInterrupt + + monkeypatch.setattr(socketserver.BaseServer, "serve_forever", serve_forever) + if entry == "serve": + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + assert serve.main(["--snapshot", str(snapshot), "--checkout-root", str(repo), + "--port", "0"]) == 0 + prefix = "console file://" + else: + assert cli._generate_and_open( + _generate_and_open_args(tmp_path, repo, "--no-open"), + opener=lambda url: None) == 0 + prefix = " console file://" + out, err = capsys.readouterr() + (token,) = tokens + lines = out.splitlines() + at = next(i for i, line in enumerate(lines) if line.startswith(prefix)) + hint = lines[at + 1] + assert hint.strip() == console_access.UNOPENABLE_HINT, lines + assert "OPENDOX_STATE_DIR" in hint and "not hidden" in hint, hint + assert sum(console_access.UNOPENABLE_HINT in line for line in lines) == 1, lines + assert all(token not in line for line in (out + err).splitlines()), \ + "a line the start printed carries the token" + + +def _held_open(copy) -> int: + """The descriptor that reserves `copy`, checked to be the copy's own.""" + fd = copy.reservation._fd + assert fd is not None, "the copy was written unreserved" + info = os.fstat(fd) + assert (info.st_dev, info.st_ino) == copy.identity + return fd + + +def _assert_released(fd: int, identity: tuple[int, int]) -> None: + """No descriptor `fd` of this process is still the copy's file: it is + closed, or the number has gone to another file since.""" + try: + info = os.fstat(fd) + except OSError as exc: + assert exc.errno == errno.EBADF, exc + return + assert (info.st_dev, info.st_ino) != identity, \ + "the removal kept the copy's reservation open" + + +@pytest.mark.parametrize("how", ["removed", "its directory gone"]) +def test_a_removal_releases_the_copys_reservation(tmp_path, how) -> None: + """The reservation goes with the copy (mutant run 18's M36c). Removing a + copy closes the descriptor that reserved it, so a stopped console holds + no file of its own open, and the token's file, unlinked, is not kept + alive by it. The same holds where the directory is gone already and there + is nothing to remove. A second release is harmless.""" + from opendox import console_access + + state = _state(tmp_path) + copy = _write(state) + fd = _held_open(copy) + if how == "its directory gone": + os.rename(state, tmp_path / "moved") + console_access.remove_private_copy(copy) + _assert_released(fd, copy.identity) + copy.reservation.close() + console_access.remove_private_copy(copy) + if how == "removed": + assert not copy.path.exists() + + +def test_a_copys_repr_never_carries_its_token(tmp_path) -> None: + """A log line, a traceback or a failed assertion that prints a + `PrivateCopy` prints its path, its page and its identity, and never the + token: the opened URL is kept out of its `repr`.""" + from opendox import console_access + + token = _token() + copy = _write(_state(tmp_path), token=token) + try: + assert token in copy.opened_url + assert token not in repr(copy) and token not in str(copy) + assert str(copy.path) in repr(copy) + finally: + console_access.remove_private_copy(copy) + + +# --------------------------------------------------------------------------- +# 18 — Copilot's review at 0539f8c0: the tokenless plane walks the state +# directory once (r4179091592) and refuses an unsupported platform +# first (r4179091624) +# --------------------------------------------------------------------------- + +def test_a_tokenless_planes_state_link_retargeted_mid_guard_marks_the_real_directory( + tmp_path, monkeypatch, standalone_profile) -> None: + """r4179091592, Copilot's layout. `OPENDOX_STATE_DIR` names a link to the + real state directory, and the tokenless plane's `--web-dir` holds an + outward link to it, where a sibling plane's copy lies. The link is + re-pointed at a decoy between the boundary check and the marking. The + guard walks once and marks what that walk reached, so the sibling's copy + stays a 404; the marking used to resolve the link again and name the + decoy, and the copy was served.""" + import shutil as _shutil + + from opendox import console_access, serve + + web = tmp_path / "web" + _shutil.copytree(WEB, web) + real = _state(tmp_path) + decoy = tmp_path / "decoy" + decoy.mkdir(mode=0o700) + alias = tmp_path / "state-link" + alias.symlink_to(real) + (web / "state-alias").symlink_to(real) + outer = fresh_repository(PLAIN, tmp_path / "b") + httpd, _repo, worker = _guarded_plane(tmp_path, monkeypatch, repo=outer, web=web) + real_boundary = console_access._refuse_a_served_state_dir + + def then_retarget(*args, **kwargs): + real_boundary(*args, **kwargs) + alias.unlink() + alias.symlink_to(decoy) # re-pointed between the two + + try: + base = httpd.server_address[:2] + assert httpd.console_token is None, "the case is vacuous: a token was minted" + other = _write(real, port=9) # a sibling plane's copy + token = console_access.read_private_copy(other.path)["console_token"] + monkeypatch.setattr(console_access, "_refuse_a_served_state_dir", then_retarget) + assert console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(alias)}) is None + assert alias.resolve() == decoy.resolve(), "the retarget was never staged" + (marked,) = httpd.private_roots + assert marked.resolve() == (real / console_access.CONSOLE_DIRNAME).resolve() + _assert_never_served(base, token, ( + f"/state-alias/console/{other.path.name}", "/state-alias/console/")) + assert _call(base, "GET", "/index.html")[0] == 200 + console_access.remove_private_copy(other) + finally: + _stop_plane(httpd, worker) + + +def test_a_tokenless_planes_unsafe_state_path_refuses_its_start( + tmp_path, monkeypatch, standalone_profile) -> None: + """r4179091592: the guard judges the state directory's path as the writer + does, so a directory on the way that another user could change (here a + world-writable, non-sticky one) refuses the tokenless start by name too, + and nothing is marked.""" + from opendox import console_access, serve + + shared = tmp_path / "shared" + shared.mkdir() + shared.chmod(0o777) + try: + state = _state(shared / "inner") + outer = fresh_repository(PLAIN, tmp_path / "b") + _clean_git(monkeypatch) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + httpd = serve.build_server(WEB, snapshot, outer, port=0, quiet=True) + try: + assert httpd.console_token is None, "the case is vacuous: a token was minted" + with pytest.raises(console_access.ConsoleAccessRefused, + match="not sticky") as refused: + console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state)}) + assert str(shared) in str(refused.value) + assert not getattr(httpd, "private_roots", ()) + finally: + httpd.server_close() + finally: + shared.chmod(0o700) + + +def test_a_tokenless_plane_refuses_a_platform_without_the_primitives( + tmp_path, monkeypatch, standalone_profile) -> None: + """r4179091624, in the process: a tokenless standalone plane used to skip + the platform check, start, and mark a private root its handlers then + judged with the missing `O_NONBLOCK`, dropping every static request. It + refuses by name now, before it marks anything.""" + from opendox import console_access, serve + + _clean_git(monkeypatch) + outer = fresh_repository(PLAIN, tmp_path / "b") + state = _state(tmp_path) + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps({"generation": {}}), encoding="utf-8") + httpd = serve.build_server(WEB, snapshot, outer, port=0, quiet=True) + try: + assert httpd.console_token is None, "the case is vacuous: a token was minted" + monkeypatch.delattr(os, "O_NONBLOCK") + with pytest.raises(console_access.ConsoleAccessRefused, + match="needs a POSIX platform") as refused: + console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state)}) + assert "os.O_NONBLOCK" in str(refused.value) + assert not getattr(httpd, "private_roots", ()) + finally: + monkeypatch.undo() + httpd.server_close() + + +def test_a_tokenless_start_without_the_posix_primitives_refuses_by_name( + tmp_path) -> None: + """r4179091624, as a user starts it: the no-identity variant of the + reviewer's B2 child. With `os.getuid`, `O_NOFOLLOW`, `O_DIRECTORY` and + `O_NONBLOCK` gone, as on Windows, a checkout with no git identity (so no + token) refuses its start by name, and never serves.""" + import textwrap + + repo = _adv_repo(tmp_path / "r", identity=False) + state = _state(tmp_path) + code = textwrap.dedent(""" + import os, sys + from opendox import serve + for name in ('getuid', 'O_NOFOLLOW', 'O_DIRECTORY', 'O_NONBLOCK'): + delattr(os, name) + """) + proc, out = _adv_serve(tmp_path, repo, state, _free_port(), "w", code=code) + if proc.poll() is None: + _adv_stop(proc) + pytest.fail("the tokenless plane started serving: " + out.read_text()) + rc = proc.wait(60) + text = out.read_text() + assert "Traceback" not in text, text + assert rc == 1 and "serve refused:" in text, text + assert "needs a POSIX platform" in text and "os.O_NONBLOCK" in text, text + + +# --------------------------------------------------------------------------- +# 19 — Copilot's review at af2a2efb: a copy is known by what it holds, in any +# state directory (r4179239380) and mid-removal (r4179239411); a scan +# that fails denies (r4179239424); an entry's payload comes first +# --------------------------------------------------------------------------- + +def test_another_state_directorys_copy_is_never_served( + tmp_path, monkeypatch, standalone_profile) -> None: + """r4179239380, Copilot's layout: two standalone planes of one user with + DIFFERENT state directories. Plane A's `--web-dir` links to plane B's + state directory, and A's checkout holds a hard link to B's copy. B's + copy lies in no directory A marked, but it holds a console record, so A + answers 404 for it through the static handler and `/source`, for GET and + HEAD, while its bundle still answers.""" + import shutil as _shutil + + from opendox import console_access, serve + + web = tmp_path / "web" + _shutil.copytree(WEB, web) + state_a = _state(tmp_path / "a") + state_b = _state(tmp_path / "b") + (web / "state-b").symlink_to(state_b) + httpd, repo, worker = _guarded_plane(tmp_path, monkeypatch, web=web) + try: + base = httpd.server_address[:2] + own = console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state_a)}) + assert own is not None + theirs = _write(state_b, port=9) # plane B's copy + token = console_access.read_private_copy(theirs.path)["console_token"] + os.link(theirs.path, repo / "theirs.md") + _assert_never_served(base, token, ( + f"/state-b/console/{theirs.path.name}", "/source/theirs.md")) + assert _call(base, "GET", "/index.html")[0] == 200 + console_access.remove_private_copy(theirs) + console_access.remove_private_copy(own) + finally: + _stop_plane(httpd, worker) + + +def test_a_copy_removed_during_the_scan_is_never_served( + tmp_path, monkeypatch) -> None: + """r4179239411, the interleaving: a read opens the copy, and the copy is + removed (taken under another name, then unlinked) before the private + directory's scan can stat its name. Nothing in the directory matches + then, but the file already open is still the copy, and is judged by what + it holds: the read is refused.""" + from opendox import console_access, serve + + state = _state(tmp_path) + copy = _write(state) + roots = (copy.path.parent,) + real_open = open + staged: list[str] = [] + + def opened_then_removed(path, mode="r", *args, **kwargs): + stream = real_open(path, mode, *args, **kwargs) + if not staged: # removed after the open + staged.append(str(path)) + console_access.remove_private_copy(copy) + return stream + + monkeypatch.setattr("builtins.open", opened_then_removed) + try: + assert serve.read_unless_private(copy.path, roots) is None + finally: + monkeypatch.undo() + assert staged, "the removal was never staged" + assert not copy.path.exists() + assert list(copy.path.parent.iterdir()) == [], "the removal left a name" + + +@pytest.mark.parametrize("failure", [errno.EMFILE, errno.EACCES], ids=["EMFILE", "EACCES"]) +def test_a_private_directory_that_cannot_be_scanned_denies_the_read( + tmp_path, monkeypatch, failure) -> None: + """r4179239424: a private directory that exists and cannot be listed + (out of descriptors, EMFILE, simulated here; or refused) cannot clear the + file being read, so the read is denied, an ordinary file's as well as a + copy's. A private directory that does not exist holds no copy, and the + ordinary file is read as before.""" + from opendox import console_access, serve + + state = _state(tmp_path) + copy = _write(state) + ordinary = tmp_path / "ordinary.md" + ordinary.write_text("# plain\n", encoding="utf-8") + roots = (copy.path.parent,) + + def cannot_list(path): + raise OSError(failure, os.strerror(failure), str(path)) + + monkeypatch.setattr(console_access.os, "scandir", cannot_list) + assert serve.read_unless_private(ordinary, roots) is None + assert serve.read_unless_private(copy.path, roots) is None + monkeypatch.undo() + absent = (tmp_path / "no-such-state" / console_access.CONSOLE_DIRNAME,) + assert serve.read_unless_private(ordinary, absent) == b"# plain\n" + assert serve.read_unless_private(ordinary, roots) == b"# plain\n" + console_access.remove_private_copy(copy) + + +def test_a_name_whose_status_cannot_be_read_denies_the_read( + tmp_path, monkeypatch) -> None: + """r4179239424, per name: a name in the private directory whose status + cannot be read, for any reason but its removal, cannot be told apart + from the file being read, so the read is denied. A name removed + meanwhile is skipped.""" + from opendox import console_access, serve + + state = _state(tmp_path) + copy = _write(state) + ordinary = tmp_path / "ordinary.md" + ordinary.write_text("# plain\n", encoding="utf-8") + roots = (copy.path.parent,) + real_scandir = os.scandir + + class _Entry: + def __init__(self, raised): + self.raised = raised + + def stat(self, follow_symlinks=True): + raise self.raised + + def entries_failing(raised): + def scandir(path): + listing = [*real_scandir(path), _Entry(raised)] + return contextlib.nullcontext(iter(listing)) + return scandir + + monkeypatch.setattr(console_access.os, "scandir", + entries_failing(PermissionError(errno.EACCES, "denied"))) + assert serve.read_unless_private(ordinary, roots) is None + monkeypatch.setattr(console_access.os, "scandir", + entries_failing(FileNotFoundError(errno.ENOENT, "gone"))) + assert serve.read_unless_private(ordinary, roots) == b"# plain\n" + monkeypatch.undo() + console_access.remove_private_copy(copy) + + +@pytest.mark.parametrize("file", ["missing", "present", "a copy"]) +def test_an_entrys_payload_comes_before_its_guarded_file( + tmp_path, file) -> None: + """Copilot's review at af2a2efb ("previously missed"): an entry with an + in-memory payload serves that payload first, as `SnapshotEntry. + read_bytes` does, whether its file is missing, present, or even a + private copy; only the file fallback is guarded. An entry with no + payload reads its file through the guard.""" + import types + + from opendox import console_access, default_registry, serve + + state = _state(tmp_path) + copy = _write(state) + path = {"missing": tmp_path / "missing.json", + "present": tmp_path / "present.json", + "a copy": copy.path}[file] + if file == "present": + path.write_bytes(b'{"from": "file"}') + handler = types.SimpleNamespace( + server=types.SimpleNamespace(private_roots=(copy.path.parent,))) + with_payload = default_registry.SnapshotEntry( + repository="fixture", snapshot_path=path, payload=b'{"from": "payload"}') + assert serve.DashboardHandler._entry_bytes(handler, with_payload) == \ + b'{"from": "payload"}' + without = default_registry.SnapshotEntry(repository="fixture", snapshot_path=path) + expected = {"missing": None, "present": b'{"from": "file"}', "a copy": None}[file] + assert serve.DashboardHandler._entry_bytes(handler, without) == expected + console_access.remove_private_copy(copy) + + +def test_a_file_whose_head_cannot_be_read_is_denied(tmp_path, monkeypatch) -> None: + """r4179239424, by content: a regular file whose head cannot be read + cannot be cleared of holding a console record, so the read is denied. + A file that holds none is read as before.""" + from opendox import console_access, serve + + state = _state(tmp_path) + copy = _write(state) + ordinary = tmp_path / "ordinary.md" + ordinary.write_text("# plain\n", encoding="utf-8") + roots = (copy.path.parent,) + assert serve.read_unless_private(ordinary, roots) == b"# plain\n" + + def unreadable(*args, **kwargs): + raise OSError(errno.EIO, os.strerror(errno.EIO)) + + monkeypatch.setattr(console_access.os, "pread", unreadable) + assert serve.read_unless_private(ordinary, roots) is None + monkeypatch.undo() + console_access.remove_private_copy(copy) + + +# --------------------------------------------------------------------------- +# 20 — no test server outlives its case, or its run +# --------------------------------------------------------------------------- + +def test_a_server_left_running_is_reaped_with_its_group(tmp_path) -> None: + """The teardown's reaper: a server child still serving is stopped by + its process group, SIGTERM first, so it removes its copy and frees its + port. A child that ignores SIGTERM is killed after the bounded wait.""" + import subprocess + + repo = _adv_repo(tmp_path / "r") + state = _state(tmp_path) + port = _free_port() + proc, out = _adv_serve(tmp_path, repo, state, port, "left") + assert proc.poll() is None, out.read_text() + assert os.getpgid(proc.pid) == proc.pid, "the server is not in its own group" + assert (state / "console" / f"{port}.html").exists() + _reap(proc) + assert proc.returncode == 0, out.read_text() + assert not (state / "console" / f"{port}.html").exists() + assert _port_is_free(port) + stubborn = subprocess.Popen( + [sys.executable, "-c", + "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); " + "print('ready', flush=True); time.sleep(120)"], + stdout=subprocess.PIPE, start_new_session=True) + try: + assert stubborn.stdout.readline().strip() == b"ready" + _reap(stubborn, wait=2) + assert stubborn.returncode == -signal.SIGKILL + finally: + if stubborn.poll() is None: + stubborn.kill() + stubborn.wait(10) + stubborn.stdout.close() + + +def test_a_server_outlives_no_killed_run(tmp_path) -> None: + """A run that is killed runs no teardown. On Linux the kernel stops a + server child when the process that started it dies (`_child_setup`'s + parent-death signal): here an intermediate process starts a child the + way `_adv_serve` does and is SIGKILLed, and the child ends with it. + Elsewhere the teardown alone answers for it.""" + import subprocess + import time + + if _PRCTL is None: + return # no parent-death signal on this platform + script = ( + "import os, subprocess, sys, time\n" + f"sys.path.insert(0, {str(Path(__file__).resolve().parent)!r})\n" + "import test_console_token_delivery as t\n" + "parent = os.getpid()\n" + "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(120)'],\n" + " start_new_session=True, preexec_fn=lambda: t._child_setup(parent))\n" + "print(child.pid, flush=True)\n" + "time.sleep(120)\n") + middle = subprocess.Popen([sys.executable, "-c", script], + stdout=subprocess.PIPE, cwd=tmp_path, + env=_adv_env()) + try: + child = int(middle.stdout.readline()) + middle.kill() + middle.wait(10) + for _ in range(150): + try: + state = Path(f"/proc/{child}/stat").read_text().rsplit(")", 1)[1].split()[0] + except FileNotFoundError: + break + if state == "Z": + break + time.sleep(0.1) + else: + os.kill(child, signal.SIGKILL) + pytest.fail("the child outlived the killed run that started it") + finally: + if middle.poll() is None: + middle.kill() + middle.wait(10) + middle.stdout.close() + + +def test_a_file_in_the_copies_directory_is_refused_by_its_place(tmp_path) -> None: + """Defence in depth: a file in the copies' directory is refused by its + identity whatever it holds, even where its bytes are not recognized as a + copy's (here a token-bearing line with no marker and no record). + Hard-linked under a served root, it is still never read out.""" + from opendox import console_access, serve + + state = _state(tmp_path) + copy = _write(state) + token = console_access.read_private_copy(copy.path)["console_token"] + stray = copy.path.parent / f".{copy.path.name}.opendox-424242" + stray.write_text(f"\n", encoding="utf-8") + stray.chmod(0o600) + assert token in stray.read_text(encoding="utf-8") + served = tmp_path / "served" + served.mkdir() + os.link(stray, served / "stray.md") + with open(served / "stray.md", "rb") as stream: + assert not console_access.is_copy_bytes(stream.read()), "the case is vacuous" + assert serve.read_unless_private(served / "stray.md", (copy.path.parent,)) is None + console_access.remove_private_copy(copy) + + +# --------------------------------------------------------------------------- +# 21 — Copilot's review at 1e114a19 (r4179793524): a copy caught part way +# through its write, in another state directory, and a file that grows +# after it was judged +# --------------------------------------------------------------------------- + +def _partial_copy_bytes(tmp_path: Path) -> tuple[bytes, str]: + """A real copy's bytes, cut just after the token's first appearance (the + meta refresh), before the record's element is written: what another + plane's writer leaves for a moment in its temporary file.""" + from opendox import console_access + + scratch = _state(tmp_path / "scratch") + copy = _write(scratch, port=7) + data = copy.path.read_bytes() + token = console_access.read_private_copy(copy.path)["console_token"] + console_access.remove_private_copy(copy) + cut = data.index(token.encode()) + len(token) + assert b"" not in data[:cut], "the cut is not part way" + return data[:cut], token + + +def test_a_copy_starts_with_its_marker_before_any_token_byte(tmp_path) -> None: + """r4179793524: every copy is written from `COPY_MARKER`, so any part of + one that holds a byte of the token holds the whole marker first, and is + known for a copy (`is_copy_bytes`); fewer bytes than the marker hold no + token and are not.""" + from opendox import console_access + + copy = _write(_state(tmp_path)) + data = copy.path.read_bytes() + token = console_access.read_private_copy(copy.path)["console_token"].encode() + assert data.startswith(console_access.COPY_MARKER) + assert data.index(token) >= len(console_access.COPY_MARKER) + for cut in range(len(console_access.COPY_MARKER), len(data) + 1, 37): + assert console_access.is_copy_bytes(data[:cut]), cut + for cut in range(len(console_access.COPY_MARKER)): + assert token not in data[:cut] + assert not console_access.is_copy_bytes(data[:cut]), cut + assert not console_access.is_copy_bytes(b"# a document\n") + console_access.remove_private_copy(copy) + + +def test_another_state_directorys_partial_copy_is_never_served( + tmp_path, monkeypatch, standalone_profile) -> None: + """r4179793524, Copilot's layout: plane B's writer has its temporary file + part written, the token in its meta refresh and no record yet, in B's own + state directory. Plane A's `--web-dir` links there, A's checkout holds a + hard link to it, and A's snapshot file is one too. The static handler, + `/source` and `/snapshot.json` all refuse it, for GET and HEAD.""" + import shutil as _shutil + + from opendox import console_access, serve + + partial, token = _partial_copy_bytes(tmp_path) + web = tmp_path / "web" + _shutil.copytree(WEB, web) + state_a = _state(tmp_path / "a") + state_b = _state(tmp_path / "b") + console_b = state_b / console_access.CONSOLE_DIRNAME + console_b.mkdir(mode=0o700) + temporary = console_b / ".9.html.opendox-4242" + temporary.write_bytes(partial) + temporary.chmod(0o600) + (web / "state-b").symlink_to(state_b) + httpd, repo, worker = _guarded_plane(tmp_path, monkeypatch, web=web) + try: + base = httpd.server_address[:2] + own = console_access.publish( + httpd, page_url=serve.server_url(httpd, "/index.html"), + env={"OPENDOX_STATE_DIR": str(state_a)}) + os.link(temporary, repo / "partial.md") + snapshot = tmp_path / "snapshot.json" + snapshot.unlink() + os.link(temporary, snapshot) + for path in (f"/state-b/console/{temporary.name}", "/source/partial.md", + "/snapshot.json"): + for method in ("GET", "HEAD"): + status, headers, raw = _call(base, method, path) + assert token.encode() not in raw, (method, path, status) + assert all(token not in str(v) for v in headers.values()), path + assert status != 200, (method, path, status) + assert token.encode() not in _raw_get(base, f"/state-b/console/{temporary.name}") + assert _call(base, "GET", "/index.html")[0] == 200 + console_access.remove_private_copy(own) + finally: + _stop_plane(httpd, worker) + + +def test_a_file_that_grows_after_its_static_check_never_sends_a_token( + tmp_path, monkeypatch, standalone_profile) -> None: + """r4179793524, a file that grows: when the static handler judges it, both + before the stdlib opens it and after, the other plane's temporary file + holds only the start of the marker, and no token; it grows to hold one + right after the second judgment, before the body is copied. The body + sent is judged again as it is read, and never carries the token, whatever + reaches the wire.""" + import shutil as _shutil + + from opendox import console_access + + partial, token = _partial_copy_bytes(tmp_path) + first = partial[:10] + web = tmp_path / "web" + _shutil.copytree(WEB, web) + state_b = _state(tmp_path / "b") + console_b = state_b / console_access.CONSOLE_DIRNAME + console_b.mkdir(mode=0o700) + growing = console_b / ".9.html.opendox-4242" + growing.write_bytes(first) + growing.chmod(0o600) + (web / "state-b").symlink_to(state_b) + real = console_access.is_private_file + judged: list[bool] = [] + grown: list[int] = [] + + def then_grow(handle, roots): + answer = real(handle, roots) + info = os.fstat(handle) + if (info.st_dev, info.st_ino) == (growing.stat().st_dev, + growing.stat().st_ino): + judged.append(answer) + if len(judged) == 2 and not grown: # after the backstop + grown.append(1) + with open(growing, "ab") as more: + more.write(partial[len(first):]) + return answer + + httpd, _repo, worker = _guarded_plane(tmp_path, monkeypatch, web=web) + try: + base = httpd.server_address[:2] + own = console_access.publish( + httpd, page_url="http://127.0.0.1:%d/index.html" % base[1], + env={"OPENDOX_STATE_DIR": str(_state(tmp_path / "a"))}) + monkeypatch.setattr(console_access, "is_private_file", then_grow) + raw = _raw_get(base, f"/state-b/console/{growing.name}") + assert grown and judged == [False, False], ("the growth was never " + "staged after both checks", judged) + assert token.encode() not in raw + console_access.remove_private_copy(own) + finally: + _stop_plane(httpd, worker) + + +def test_a_copy_replaced_after_its_read_is_never_returned( + tmp_path, monkeypatch) -> None: + """r4179793524, the other way round: the bytes read are a copy's, and the + file is emptied right after the read, before anything judges it by its + descriptor. What was read is judged too, so the copy's bytes are never + returned.""" + from opendox import console_access, serve + + own = _write(_state(tmp_path / "a")) + other = _write(_state(tmp_path / "b"), port=9) + token = console_access.read_private_copy(other.path)["console_token"] + served = tmp_path / "served.json" + os.link(other.path, served) + real_open = open + emptied: list[int] = [] + + class _EmptiedAfterRead: + def __init__(self, stream): + self.stream = stream + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.stream.close() + + def fileno(self): + return self.stream.fileno() + + def read(self, *args): + data = self.stream.read(*args) + os.truncate(served, 0) # emptied right after the read + emptied.append(1) + return data + + def opening(path, mode="r", *args, **kwargs): + stream = real_open(path, mode, *args, **kwargs) + return _EmptiedAfterRead(stream) if str(path) == str(served) else stream + + monkeypatch.setattr("builtins.open", opening) + try: + data = serve.read_unless_private(served, (own.path.parent,)) + finally: + monkeypatch.undo() + assert emptied, "the replacement was never staged" + assert data is None or token.encode() not in data + console_access.remove_private_copy(own) + console_access.remove_private_copy(other) + + +def test_a_file_that_grows_after_its_read_check_never_returns_a_token( + tmp_path, monkeypatch) -> None: + """r4179793524, for `/source` and `/snapshot.json`'s reader: the file is + read first and the bytes read are judged, so a file that holds no token + when it is judged cannot hand one out after.""" + from opendox import console_access, serve + + partial, token = _partial_copy_bytes(tmp_path) + own = _write(_state(tmp_path / "a")) + growing = tmp_path / "growing.json" + growing.write_bytes(partial[:10]) + real = console_access.is_private_file + grown: list[int] = [] + + def then_grow(handle, roots): + answer = real(handle, roots) + if not grown: + grown.append(1) + with open(growing, "ab") as more: + more.write(partial[10:]) + return answer + + monkeypatch.setattr(console_access, "is_private_file", then_grow) + data = serve.read_unless_private(growing, (own.path.parent,)) + assert grown, "the growth was never staged" + assert data is None or token.encode() not in data + monkeypatch.undo() + assert serve.read_unless_private(growing, (own.path.parent,)) is None + console_access.remove_private_copy(own) + + +# --------------------------------------------------------------------------- +# 22 — Copilot's review at a2e36652 (r4180089809): the page URL is judged +# as a browser reads it +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("bad", [ + "http://evil.example\\@127.0.0.1:8080/index.html", + "http://127.0.0.1:8080\\@evil.example/index.html", + "http://evil.example@127.0.0.1:8080/index.html", + "http://user:pass@127.0.0.1:8080/index.html", + "http://127.0.0.1:8080/\\\\evil.example/index.html", + "http://127.0.0.1:8080/\tindex.html", + "http://127.0.0.1:99999/index.html", + "http://127.0.0.1:8080:9/index.html", +], ids=["backslash-userinfo", "backslash-after-port", "userinfo", "user-and-password", + "backslash-in-path", "control-character", "port-out-of-range", "two-ports"]) +def test_a_page_url_a_browser_reads_as_another_host_is_refused( + tmp_path, bad) -> None: + """r4180089809, Copilot's case first: `urlsplit` reads + `http://evil.example\\@127.0.0.1:8080/` as user information at + `127.0.0.1`, and a browser, taking the backslash for a slash, navigates + to `evil.example`, whose page could read the token's fragment. The + authority must be a loopback host and an optional port, exactly, and a + backslash or a control character anywhere is refused. Nothing is + written.""" + from opendox import console_access + + token = _token() + with pytest.raises(console_access.ConsoleAccessRefused): + console_access.opened_url(bad, token) + state = _state(tmp_path) + with pytest.raises(console_access.ConsoleAccessRefused) as refused: + console_access.write_private_copy( + state, page_url=bad, port=8080, token=token, served_roots=()) + assert token not in str(refused.value) + assert not (state / console_access.CONSOLE_DIRNAME).exists() + + +@pytest.mark.parametrize("good", [ + "http://127.0.0.1:8080/index.html", "http://[::1]:8080/index.html", + "http://localhost:8080/index.html", "http://127.0.0.1/index.html"]) +def test_every_loopback_page_url_a_plane_announces_is_accepted(good) -> None: + """The spellings `serve.server_url` announces a plane at, with and + without a port, are still accepted.""" + from opendox import console_access + + token = _token() + assert console_access.opened_url(good, token).startswith(good + "#") diff --git a/tests/test_console_token_view.py b/tests/test_console_token_view.py new file mode 100644 index 00000000..47cfdf34 --- /dev/null +++ b/tests/test_console_token_view.py @@ -0,0 +1,194 @@ +"""The page's half of plan 034 T104: `web/views/notebook.js` takes the +console token from the opened URL's FRAGMENT, keeps it, and strips it +(RULED openxFactory#656 comment `5963851934`). + +On a standalone plane `/capabilities` carries no token. The page is opened at +`…/index.html#console_token=` (`opendox.console_access.opened_url`), and +the module takes it the moment it is imported: it keeps it in +`sessionStorage`, or in memory where storage is blocked, and rewrites the +address bar with `history.replaceState` so the token does not stay in it. +`probeCapabilities` then fills it into a payload that carries none, so every +view keeps reading `caps.console_token`, and a host's own published token +always wins. Each case runs in node, against the module itself, with the +browser globals stood in; a fresh module instance per case (`?case=`). + +A CREATED FILE, with no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +NOTEBOOK_JS = ROOT / "src" / "opendox" / "web" / "views" / "notebook.js" +NODE = shutil.which("node") + +TOKEN = "Tk_" + "a1B2-c3D4" * 4 # `secrets.token_urlsafe`'s alphabet +SERVED = "Served_" + "z9Y8-x7W6" * 4 + +_HARNESS = r""" +const TOKEN = %(token)s, SERVED = %(served)s; +const out = {}; + +function storage({ throwing = false, seed = null } = {}) { + const map = new Map(seed ? [["opendox.console-token", seed]] : []); + const api = { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => { map.set(k, String(v)); }, + removeItem: (k) => { map.delete(k); }, + dump: () => Object.fromEntries(map), + }; + return throwing ? null : api; +} + +function scope({ hash = "", search = "", store = storage(), throwing = false } = {}) { + const calls = []; + const s = { + location: { hash, search, pathname: "/index.html" }, + history: { state: { kept: 1 }, replaceState: (st, title, url) => { calls.push([st, title, url]); } }, + calls, + store, + }; + if (throwing) { + Object.defineProperty(s, "sessionStorage", { get() { throw new Error("blocked"); } }); + } else { + s.sessionStorage = store; + } + return s; +} + +function install(s) { + for (const name of ["location", "history"]) globalThis[name] = s[name]; + try { delete globalThis.sessionStorage; } catch {} + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + get() { return s.sessionStorage; }, + }); +} + +const ok = (payload) => async () => ({ ok: true, json: async () => payload }); + +// 1. THE MODULE ITSELF, opened with the token in the fragment. +let s = scope({ hash: "#console_token=" + TOKEN }); +install(s); +let m = await import("./notebook.js?case=fragment"); +out.fragment = { + replaced: s.calls, + stored: s.store.dump(), + probed: await m.probeCapabilities(ok({ actions: { session: true } })), + hostWins: await m.probeCapabilities(ok({ actions: {}, console_token: SERVED })), + absentRoute: await m.probeCapabilities(async () => ({ ok: false })), + offline: await m.probeCapabilities(async () => { throw new Error("file://"); }), + notAnObject: await m.probeCapabilities(ok([1, 2])), +}; + +// 2. A RELOAD: no fragment, the tab's storage holds the token. +s = scope({ store: storage({ seed: TOKEN }) }); +install(s); +m = await import("./notebook.js?case=reload"); +out.reload = { replaced: s.calls, + probed: await m.probeCapabilities(ok({ actions: {} })) }; + +// 3. THE QUERY STRING IS NEVER READ: a token there is not taken. +s = scope({ search: "?console_token=" + TOKEN }); +install(s); +m = await import("./notebook.js?case=query"); +out.query = { replaced: s.calls, stored: s.store.dump(), + probed: await m.probeCapabilities(ok({ actions: {} })) }; + +// 4. A MALFORMED TOKEN: stripped from the address bar, not kept. +s = scope({ hash: "#console_token=%%3Cscript%%3E" }); +install(s); +m = await import("./notebook.js?case=malformed"); +out.malformed = { replaced: s.calls, stored: s.store.dump(), + probed: await m.probeCapabilities(ok({ actions: {} })) }; + +// 5. BLOCKED STORAGE: the token is still taken, in memory. +s = scope({ hash: "#console_token=" + TOKEN, throwing: true }); +install(s); +m = await import("./notebook.js?case=blocked"); +out.blocked = { replaced: s.calls, + probed: await m.probeCapabilities(ok({ actions: {} })) }; + +// 6. THE PURE FUNCTIONS, with no page at all. +out.pure = { + noScope: m.takeDeliveredConsoleToken(undefined), + noHash: m.takeDeliveredConsoleToken({ location: {} }), + otherFragment: m.takeDeliveredConsoleToken(scope({ hash: "#section-2" })), + explicitNull: m.withDeliveredConsoleToken({ actions: {} }, null), + keys: [m.CONSOLE_TOKEN_FRAGMENT_KEY, m.CONSOLE_TOKEN_STORAGE_KEY], +}; + +process.stdout.write(JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def page(tmp_path_factory): + if NODE is None: + pytest.skip("node not available for the console-token page probe") + root = tmp_path_factory.mktemp("console-token-view") + shutil.copy(NOTEBOOK_JS, root / "notebook.js") + (root / "package.json").write_text('{"type": "module"}', encoding="utf-8") + harness = root / "harness.mjs" + harness.write_text(_HARNESS % {"token": json.dumps(TOKEN), + "served": json.dumps(SERVED)}, + encoding="utf-8") + proc = subprocess.run([NODE, str(harness)], capture_output=True, text=True, + timeout=60) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_the_fragment_token_is_taken_kept_and_stripped(page) -> None: + case = page["fragment"] + # stripped: the address bar is rewritten to the path, the state kept + assert case["replaced"] == [[{"kept": 1}, "", "/index.html"]] + assert case["stored"] == {"opendox.console-token": TOKEN} + assert case["probed"] == {"actions": {"session": True}, "console_token": TOKEN} + + +def test_a_hosts_published_token_wins_and_a_degraded_probe_gets_none(page) -> None: + case = page["fragment"] + assert case["hostWins"]["console_token"] == SERVED + assert case["absentRoute"] == {"actions": {"notebook": False}} + assert case["offline"] == {"actions": {"notebook": False}} + assert case["notAnObject"] == [1, 2] + + +def test_a_reload_keeps_the_token_from_the_tabs_storage(page) -> None: + assert page["reload"]["replaced"] == [] + assert page["reload"]["probed"]["console_token"] == TOKEN + + +def test_a_token_in_the_query_string_is_never_read(page) -> None: + case = page["query"] + assert case["replaced"] == [] and case["stored"] == {} + assert "console_token" not in case["probed"] + + +def test_a_malformed_token_is_stripped_and_not_kept(page) -> None: + case = page["malformed"] + assert case["replaced"] == [[{"kept": 1}, "", "/index.html"]] + assert case["stored"] == {} + assert "console_token" not in case["probed"] + + +def test_blocked_storage_still_yields_the_token_in_memory(page) -> None: + assert page["blocked"]["replaced"] == [[{"kept": 1}, "", "/index.html"]] + assert page["blocked"]["probed"]["console_token"] == TOKEN + + +def test_the_pure_readers_take_nothing_from_nothing(page) -> None: + case = page["pure"] + assert case["noScope"] is None and case["noHash"] is None + assert case["otherFragment"] is None + assert case["explicitNull"] == {"actions": {}} + # the page reads the key the server writes (`console_access.FRAGMENT_KEY`) + from opendox import console_access + assert case["keys"] == [console_access.FRAGMENT_KEY, "opendox.console-token"] diff --git a/tests/test_doxbench_defaults.py b/tests/test_doxbench_defaults.py index 6c96b523..d456e34a 100644 --- a/tests/test_doxbench_defaults.py +++ b/tests/test_doxbench_defaults.py @@ -438,8 +438,10 @@ def _get(base: tuple[str, int], path: str, headers: dict | None = None): def served_catalog(child: Child) -> dict: """The served model catalog, read as the chat rail reads it: the console - token from `/capabilities`, then `GET /workbench/model-catalog`. Asserts - the route ANSWERS, with an envelope openDox's own validator accepts.""" + token the page was opened with (on a standalone plane, its private copy, + plan 034 T104; never `/capabilities`), then `GET + /workbench/model-catalog`. Asserts the route ANSWERS, with an envelope + openDox's own validator accepts.""" assert ACTOR in GATE_TEST_PRINCIPALS match = child.wait_for_line(_URL) base = (match.group(2), int(match.group(3))) @@ -447,8 +449,9 @@ def served_catalog(child: Child) -> dict: assert status == 200, status capabilities = json.loads(body) assert capabilities["actions"]["session"] is True, capabilities + assert "console_token" not in capabilities, capabilities status, body = _get(base, "/workbench/model-catalog", - {"X-XF-Console-Token": capabilities["console_token"]}) + {"X-XF-Console-Token": child.console_token(base[1])}) assert status == 200, (status, body) envelope = json.loads(body) assert envelope["kind"] == serve_wire.DOXBENCH_MODEL_CATALOG_KIND diff --git a/tests/test_loopback_host_gate.py b/tests/test_loopback_host_gate.py index 4f6ba7a2..71702dbb 100644 --- a/tests/test_loopback_host_gate.py +++ b/tests/test_loopback_host_gate.py @@ -492,7 +492,16 @@ def test_every_route_class_refuses_a_foreign_host_on_a_real_local_serve( "/capabilities", good) assert status == 200, payload[:200] caps = json.loads(payload) - assert ("console_token" in caps) is identity, sorted(caps) + # A STANDALONE plane never publishes its token on `/capabilities` + # (plan 034 T104): with an identity one is minted and delivered + # through the private copy the entry point wrote, and with none + # there is no token and no copy. + assert "console_token" not in caps, sorted(caps) + from opendox import console_access + copy = console_access.private_copy_path(child.state_dir, base[1]) + assert copy.exists() is identity, copy + if identity: + assert child.console_token(base[1]) # L2's block, served to its own Host and to no other assert caps["install"]["mode"] == "local", caps.get("install") violations = table_violations(base, route_classes(contributed=False)) diff --git a/tests/test_neutral_turn_scope.py b/tests/test_neutral_turn_scope.py index df6a88ba..51dcf91e 100644 --- a/tests/test_neutral_turn_scope.py +++ b/tests/test_neutral_turn_scope.py @@ -261,9 +261,12 @@ def test_a_standalone_turn_over_the_tiles_own_document_is_answered( base = (match.group(2), int(match.group(3))) status, caps, raw = _call(base, "GET", "/capabilities") assert status == 200 and caps["actions"]["session"] is True, raw + # a standalone plane delivers its token in the opened URL, through + # its private copy, and never on `/capabilities` (plan 034 T104) + assert "console_token" not in caps, caps status, body, raw = _call(base, "POST", "/actions/workbench/chat-turn", body=_turn(repo, OWN), - token=caps["console_token"]) + token=child.console_token(base[1])) # past the validators, the guard and identity, answered by the model # step: never `turn_scope_refused`, never a dropped connection assert body.get("error") == DOXBENCH_ERR_MODEL_UNAVAILABLE, (status, raw) diff --git a/tests/test_source_core_arm.py b/tests/test_source_core_arm.py index 4d579781..a9c9d13f 100644 --- a/tests/test_source_core_arm.py +++ b/tests/test_source_core_arm.py @@ -387,7 +387,18 @@ def test_the_route_is_read_only(): body = _source_of(_method("_serve_source")) for writer in ("write_bytes", "write_text", "unlink", "mkdir", "rename"): assert writer not in body, f"_serve_source calls {writer}" - assert "read_bytes()" in body + # It READS through `read_unless_private` (plan 034 T104; Copilot at + # openDox-code#84, r4178133842), which judges the file it opened and + # never sends a console token's private copy. That reader is read-only + # too: it opens for reading, and writes nothing. + assert "read_unless_private(" in body + reader = next((_source_of(node) for node in SERVE_TREE.body + if isinstance(node, ast.FunctionDef) + and node.name == "read_unless_private"), "") + assert 'open(path, "rb")' in reader, "read_unless_private reads nothing" + for writer in ("write_bytes", "write_text", "unlink", "mkdir", "rename", + '"w', '"a'): + assert writer not in reader, f"read_unless_private calls {writer}" # ---------------------------------------------------------------------------