diff --git a/README.md b/README.md index f2403b9..cd873d8 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,8 @@ knoten attach hyp-idea run.py accuracy.png # the code and the plot knoten validate # enforce this graph's rules knoten hook # make `git commit` refuse a broken graph -knoten hook --server g.git # ...and `git push`, for every contributor +knoten remote create g --on https://graphs.example # share it +knoten invite maria --role write # let someone in knoten viz --open # the whole graph as one HTML file ``` @@ -137,35 +138,43 @@ tags: [decoding, reasoning, prompting, evaluation] ## A shared graph -A graph is a folder in git, so a whole lab can work in one, and "has this been tried?" -stops quietly meaning "have *I* tried this?". Sharing one needs no knoten at all: +One graph, several people, one set of rules enforced for all of them. A remote is a +`knoten serve` process on any machine you can reach over HTTPS: a box you own behind a +reverse proxy or a tunnel, a small VPS, or a hosted knoten. ```bash -git init --bare lab-graph.git # on any box you can all reach -git clone you@box:lab-graph.git # everyone else, human or agent +# you, once, in your graph +knoten remote create trading --on https://graphs.example +knoten invite maria --role write # prints a one-time code + +# maria, anywhere +knoten join https://graphs.example/trading --invite 7f3a9c... +knoten frontier # her clone; the loop is unchanged from here +knoten push # over HTTPS, through the gate ``` -What knoten adds is the gate. `knoten hook` protects the person who ran it, in the clone -they ran it in, and `git commit --no-verify` walks past it. `knoten hook --server -lab-graph.git` installs a `pre-receive` hook on the repo everyone pushes *to*: it unpacks -each pushed tree, finds every graph in it, runs `knoten validate` on each and refuses the -push if any fails. No CI, no runner, no minutes, and nobody can skip it from a laptop. - -That gate matters more here than it looks. The parser refuses rather than skips, on -purpose, so one malformed node does not quietly vanish from the graph. On one machine -that is a good trade. On a shared one it means a single bad push breaks `frontier`, -`index` and `gates` for **everyone** until somebody fixes a file they did not write. - -Two people working at once do not collide: an edge is declared once, on the subject, and -back-links are generated at load, so adding connections touches two different files and -git merges them. Pull before you work, or your frontier will recommend something a -collaborator killed on Tuesday. - -Reading needs nothing installed. Nodes are markdown, so any forge renders them, and -`knoten viz` writes the graph as one self-contained HTML file you can hand to someone who -has never heard of knoten. For invitations, per-user permissions or required approvals, -put the repo on a forge such as [Forgejo](https://forgejo.org) and let it handle the -people; the gate above still does the part no forge can. +Every push runs `knoten validate` on the server before the ref moves, so a node that +breaks the graph's rules is refused for everyone, including whoever wrote the rules, and +including anyone who never installed `knoten hook`. That matters more here than it +looks: the parser refuses rather than skips, on purpose, so on one machine a malformed +node is your problem and on a shared one it would be everybody's. + +`read` can clone and pull. `write` can push. `admin` can invite and revoke. Tokens say +who is connecting and nothing else; what a token can do is the role the admin gave it. + +Reading needs nothing installed. Nodes are markdown, and `knoten viz` writes the graph as +one self-contained HTML file you can hand to someone who has never heard of knoten. + +To run the server: + +```bash +knoten serve --data ~/knoten-remotes # prints the owner secret once; keep it +``` + +It binds localhost and speaks plain HTTP. Put TLS in front before anyone outside the +machine connects. For a graph that lives in a bare repo you administer yourself, without +a server, `knoten hook --server ` installs the same gate as a `pre-receive` +hook. ## For agents diff --git a/SKILL.md b/SKILL.md index 9c4d2e7..4f92d8d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -70,9 +70,9 @@ or retracts the old one, never an edit to it. ## Before you work -If the graph is shared, `git pull` FIRST. Every read below answers from the files on -disk, so a stale clone reports work a collaborator settled days ago as still open. That -is the exact failure this graph exists to prevent, arriving through the back door. +If the graph has a remote, `knoten pull` FIRST. Every read below answers from the files +on disk, so a stale clone reports work a collaborator settled days ago as still open. +That is the exact failure this graph exists to prevent, arriving through the back door. 1. `knoten frontier` — what is worth doing next: open work, dead ends whose stated reopen condition may now hold, and gates nothing has been through. @@ -91,7 +91,9 @@ is the exact failure this graph exists to prevent, arriving through the back doo graph and the one that would otherwise be lost. Use `knoten update --status dead --append --field cause=` instead if you opened the node earlier. 6. `knoten attach ` — the script that ran it and the plot that shows it. - A claim nobody can re-run is a claim nobody trusts in six months. + A claim nobody can re-run is a claim nobody trusts in six months. If the graph has a + remote, `knoten push` when the node is filed; the server runs the same rules and + refuses what this clone would have. ## When you run out of ideas diff --git a/SPEC.md b/SPEC.md index 391d542..1f3de83 100644 --- a/SPEC.md +++ b/SPEC.md @@ -51,8 +51,10 @@ A system that stores only conclusions would have preserved ~15% of that. ## 2. Non-goals -- **We do not build a Git host.** Git already provides versioning, branching, blame, - diffs, PRs (peer review!) and hosting. Reimplementing any of it is madness. +- **We do not build a general git host.** We host knoten graphs, because the permission + model (who may write, who verified what) is part of the graph and no general host can + enforce it. Git still provides versioning, branching, blame, diffs and history; we + write none of that. - **We do not build a UI first.** A static site generator over the graph is a phase-3 nicety, and it can emit its own JSON when it exists. - **We do not invent a vocabulary.** Micropublications and nanopublications already @@ -377,7 +379,7 @@ body the day a graph outgrows a tag-filtered index — which the 1k–5k node ca | **2** | **Tool-protocol server** | ✅ done — later demoted to a fallback, then removed (§8) | | **2.5** | CLI becomes the primary agent surface: `ops.py` as the one implementation behind every read, `--json` on every read, `commit`/`update` on the CLI, `SKILL.md` | ✅ done | | 3 | Static-site graph viewer → GitHub Pages | free hosting | -| 4 | Hosted multi-graph service | probably never needed | +| 4 | **Remote graphs**: `knoten serve`, invites, roles, the gate on push | ✅ transport done; signed identity and verification follow | Phase 0 **validated the schema against real content** — including retractions, structural blockers, and prose that no JSON schema could hold. diff --git a/src/knoten/cli.py b/src/knoten/cli.py index 42f43b6..cffb009 100644 --- a/src/knoten/cli.py +++ b/src/knoten/cli.py @@ -15,6 +15,9 @@ from .commit import commit from .core import GraphError, ID_RE, LOCK, find_root, node_path, today from .hook import install as install_hook, install_server +from .registry import ROLES, Registry +from .serve import make_server +from . import remote from .validate import _csv, applies, load_config # Keyed by the uppercase word `ops` puts in `verdict` — not by raw status, which is @@ -226,6 +229,63 @@ def server_hook(repo, force) -> int: return 0 +def remote_cmd(root, args) -> int: + if args.remote_cmd == "create": + url = remote.remote_create(root, args.name, args.on, admin=args.admin, + owner_secret=args.owner_secret) + print(f" ✓ {url}") + print(" invite someone: knoten invite --role write") + return 0 + remote.remote_add(root, args.url) + print(" ✓ origin set. `knoten pull` and `knoten push` now use it.") + return 0 + + +def invite_cmd(root, name, role, days) -> int: + code = remote.invite(root, name, role, days) + print(f" ✓ {name} may join as {role} for {days} day(s). Send them this code, once:") + print(f" {code}") + return 0 + + +def revoke_cmd(root, name) -> int: + remote.revoke(root, name) + print(f" ✓ {name} can no longer connect. What they already pushed stays.") + return 0 + + +def serve_cmd(data, bind) -> int: + """Run on the server. Prints the owner secret the first time a data directory is + used, because that is the one moment the owner is certainly at the keyboard.""" + host, _, port_str = bind.rpartition(":") + host = host or "127.0.0.1" + # Parse and validate the port before any file is written; a crash after the owner + # secret exists orphans it, shown to nobody, forever. + try: + port = int(port_str) + except ValueError: + raise GraphError(f"--bind wants HOST:PORT with a numeric port, got '{bind}'") from None + reg = Registry(Path(data)) + srv = make_server(reg, host, port) + # Only now, after the server socket is open, check and display the owner secret. + first = not (reg.data / "owner").exists() + secret = reg.owner_secret() + if first: + print(f" owner secret (shown once, keep it somewhere safe): {secret}") + if host not in ("127.0.0.1", "localhost"): + print(" warning: plain HTTP on a non-local address. Put TLS in front (a reverse " + "proxy or a tunnel) before anyone outside this machine connects.", + file=sys.stderr) + print(f" serving {reg.data} on http://{host}:{srv.server_address[1]} (ctrl-c to stop)") + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + finally: + srv.server_close() + return 0 + + def render_get(payload: dict) -> None: print(f"{payload['id']} [{MARK.get(payload['verdict'], payload['verdict'])}] " f"type={payload['type']}\n") @@ -597,6 +657,40 @@ def _parser() -> argparse.ArgumentParser: s.add_argument("--force", action="store_true", help="overwrite a hook knoten did not write") + s = sub.add_parser("serve", help="host remote graphs (run this on the server)") + s.add_argument("--data", required=True, metavar="DIR", help="where graphs and tokens live") + s.add_argument("--bind", default="127.0.0.1:8899", metavar="HOST:PORT") + + # git runs this; nobody types it. `!knoten credential` is set in every clone's config. + s = sub.add_parser("credential", help=argparse.SUPPRESS) + s.add_argument("action", nargs="?") + + s = sub.add_parser("remote", help="connect this graph to a knoten server") + rs = s.add_subparsers(dest="remote_cmd", required=True) + c = rs.add_parser("create", help="create this graph on a server and push it") + c.add_argument("name") + c.add_argument("--on", required=True, metavar="URL", help="the server, e.g. https://graphs.example") + c.add_argument("--as", dest="admin", metavar="NAME", help="your contributor name (default: git user.name)") + c.add_argument("--owner-secret", help="the server's owner secret (asked for if not stored)") + a = rs.add_parser("add", help="point this clone at an existing remote graph") + a.add_argument("url", help="the graph's URL, e.g. https://graphs.example/trading") + + sub.add_parser("push", help="push this graph to its remote, through the gate") + sub.add_parser("pull", help="fetch what collaborators pushed") + + s = sub.add_parser("invite", help="admin: let someone in (prints a one-time code)") + s.add_argument("name", help="their contributor name, kebab-case") + s.add_argument("--role", default="write", choices=ROLES) + s.add_argument("--expires", type=int, default=7, metavar="DAYS") + + s = sub.add_parser("join", help="redeem an invite and clone the graph") + s.add_argument("url", help="the graph's URL, e.g. https://graphs.example/trading") + s.add_argument("--invite", required=True, metavar="CODE") + s.add_argument("--dest", metavar="DIR", help="where to clone (default: the graph's name)") + + s = sub.add_parser("revoke", help="admin: remove a contributor's access") + s.add_argument("name") + s = sub.add_parser("show", help="the node, its edges and its attachments") s.add_argument("node") s.add_argument("--json", action="store_true", help="emit the raw payload") @@ -648,6 +742,20 @@ def main(argv=None) -> int: if args.cmd == "hook" and args.server is not None: return server_hook(args.server, args.force) + if args.cmd == "serve": + return serve_cmd(args.data, args.bind) + + if args.cmd == "credential": + if args.action == "get": + sys.stdout.write(remote.credential_helper(sys.stdin.read())) + return 0 # store/erase: git manages nothing here; knoten does + + if args.cmd == "join": + clone, name, role = remote.join(args.url, args.invite, args.dest) + print(f" ✓ joined as {name} ({role}), cloned to {clone}/") + print(f" cd {clone} && knoten frontier") + return 0 + root = find_root() return { "query": lambda: query(root, args.term, args.json), @@ -669,6 +777,11 @@ def main(argv=None) -> int: "hook": lambda: hook(root, args.force), "attach": lambda: attach(root, args.node, args.files), "detach": lambda: detach(root, args.node, args.file), + "remote": lambda: remote_cmd(root, args), + "push": lambda: remote.push(root), + "pull": lambda: remote.pull(root), + "invite": lambda: invite_cmd(root, args.name, args.role, args.expires), + "revoke": lambda: revoke_cmd(root, args.name), }[args.cmd]() except (GraphError, OSError) as e: # OSError: a typo'd --frontmatter/--body/--append path is ordinary user error, diff --git a/src/knoten/registry.py b/src/knoten/registry.py new file mode 100644 index 0000000..b1a55ff --- /dev/null +++ b/src/knoten/registry.py @@ -0,0 +1,182 @@ +"""What a server knows that the graph does not. + +A knoten server hosts graphs, and for each one it holds exactly three things the graph +itself cannot: who may connect (tokens), who has been invited but not yet arrived +(invites), and who owns the server (one secret). Everything about the graph's meaning +stays in the graph. Losing this directory loses availability, not the answer to "who +verified this". + +Files, not a database, under the same lock the graph uses for its own read-modify-write +windows. Tokens and invite codes are stored hashed: a leaked tokens.json yields nothing. +""" +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import secrets +import shutil +import subprocess +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from .core import GraphError, ID_RE, graph_lock, write_atomic +from .hook import install_server + +ROLES = ("read", "write", "admin") + + +def _hash(secret: str) -> str: + return hashlib.sha256((secret or "").encode()).hexdigest() + + +# Every secret below that a human ever passes on a command line (owner_secret, +# invite codes) is generated with token_hex, not token_urlsafe: token_urlsafe's +# alphabet includes '-', and a secret that begins with '-' reads to argparse as +# a flag, not a value — `knoten remote create ... --owner-secret ` then +# failed one run in five with a perfectly valid secret. mint()'s tokens travel +# only as a git HTTP password, never argv, so they keep token_urlsafe. + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class Registry: + def __init__(self, data: Path): + self.data = Path(data) + (self.data / "graphs").mkdir(parents=True, exist_ok=True) + + # ---------------------------------------------------------------- owner + + def owner_secret(self) -> str: + """Created on first call, 0600, printed once by `knoten serve` and never again.""" + p = self.data / "owner" + if not p.exists(): + fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as fh: + fh.write(secrets.token_hex(32)) + return p.read_text(encoding="utf-8").strip() + + def check_owner(self, secret: str) -> bool: + return hmac.compare_digest(secret or "", self.owner_secret()) + + # ---------------------------------------------------------------- graphs + + def graph_dir(self, name: str) -> Path: + # A name becomes a directory. Checked here, at the ONE place every path is made, + # so `../etc` cannot reach mkdir through any entry point. + if not ID_RE.match(name or ""): + raise GraphError(f"'{name}' is not a valid graph name (use kebab-case: my-topic)") + return self.data / "graphs" / name + + def exists(self, name: str) -> bool: + return (self.graph_dir(name) / "repo.git").is_dir() + + def repo(self, name: str) -> Path: + r = self.graph_dir(name) / "repo.git" + if not r.is_dir(): + raise GraphError(f"no graph '{name}' on this server") + return r + + def create(self, name: str, admin: str) -> str: + """A bare repo with the gate already installed. Returns the creating admin's + token: creating a graph and being able to push to it are one act.""" + d = self.graph_dir(name) + if self.exists(name): + raise GraphError(f"graph '{name}' already exists on this server") + try: + d.mkdir(parents=True, exist_ok=True) + repo = d / "repo.git" + subprocess.run(["git", "init", "-q", "--bare", str(repo)], check=True) + for key, value in (("http.receivepack", "true"), + ("receive.maxInputSize", "104857600")): # 100 MB per push + subprocess.run(["git", "-C", str(repo), "config", key, value], check=True) + install_server(repo) + return self.mint(name, admin, "admin") + except GraphError: + # A half-made repo that exists() calls valid would accept pushes with no gate, forever. + shutil.rmtree(d, ignore_errors=True) + raise + except Exception as e: + # A half-made repo that exists() calls valid would accept pushes with no gate, forever. + shutil.rmtree(d, ignore_errors=True) + raise GraphError(f"could not create graph '{name}': {e}") from e + + # ---------------------------------------------------------------- files + + def _read(self, name: str, file: str) -> dict: + p = self.graph_dir(name) / file + return json.loads(p.read_text(encoding="utf-8")) if p.exists() else {} + + def _write(self, name: str, file: str, obj: dict) -> None: + write_atomic(self.graph_dir(name) / file, json.dumps(obj, indent=1) + "\n") + + # ---------------------------------------------------------------- tokens + + def _check(self, name: str, user: str, role: str) -> None: + if role not in ROLES: + raise GraphError(f"role must be one of {', '.join(ROLES)}, not '{role}'") + if not ID_RE.match(user or ""): + raise GraphError(f"'{user}' is not a valid contributor name (use kebab-case)") + self.repo(name) + + def mint(self, name: str, user: str, role: str) -> str: + self._check(name, user, role) + token = secrets.token_urlsafe(32) + with graph_lock(self.graph_dir(name)): + tokens = self._read(name, "tokens.json") + tokens[user] = {"hash": _hash(token), "role": role} + self._write(name, "tokens.json", tokens) + return token + + def authenticate(self, name: str, user: str, token: str) -> str | None: + """The role this token grants on this graph, or None. Never raises: an unknown + graph and a wrong token look identical to the caller, so the server does not + leak which graphs exist.""" + if not ID_RE.match(name or "") or not self.exists(name): + return None + entry = self._read(name, "tokens.json").get(user or "") + # .get, not entry["hash"]: a hand-edited or truncated tokens.json then fails + # closed (no entry matches) instead of a traceback on every request for that user. + if not entry or not hmac.compare_digest(entry.get("hash", ""), _hash(token)): + return None + return entry["role"] + + def revoke(self, name: str, user: str) -> None: + self.repo(name) + with graph_lock(self.graph_dir(name)): + tokens = self._read(name, "tokens.json") + if user not in tokens: + raise GraphError(f"no contributor '{user}' on graph '{name}'") + del tokens[user] + self._write(name, "tokens.json", tokens) + + # ---------------------------------------------------------------- invites + + def invite(self, name: str, user: str, role: str, days: int = 7) -> str: + self._check(name, user, role) + code = secrets.token_hex(16) + expires = (_now() + timedelta(days=days)).isoformat() + with graph_lock(self.graph_dir(name)): + invites = self._read(name, "invites.json") + invites[_hash(code)] = {"name": user, "role": role, "expires": expires} + self._write(name, "invites.json", invites) + return code + + def redeem(self, name: str, code: str) -> tuple[str, str, str]: + """One use. Returns (user, role, token). The code is removed on first try + whether or not it was still live, so an expired code cannot be retried.""" + self.repo(name) + with graph_lock(self.graph_dir(name)): + invites = self._read(name, "invites.json") + entry = invites.pop(_hash(code), None) + if entry is None: + raise GraphError("that invite code is not valid for this graph") + self._write(name, "invites.json", invites) + if datetime.fromisoformat(entry["expires"]) < _now(): + raise GraphError("that invite has expired; ask the admin for a new one") + # Outside the lock: mint takes it again, and flock on a fresh handle would wait + # on our own lock forever. + return entry["name"], entry["role"], self.mint(name, entry["name"], entry["role"]) diff --git a/src/knoten/remote.py b/src/knoten/remote.py new file mode 100644 index 0000000..6c0cb09 --- /dev/null +++ b/src/knoten/remote.py @@ -0,0 +1,261 @@ +"""The client side of a remote graph. + +A remote is a git URL plus a token. git already knows how to push, pull and clone over +HTTP and how to ask a helper program for credentials, so this module is that helper plus +a handful of commands that wrap git and make four JSON calls. It imports nothing from the +server: a client and a server never share code paths, so a bug in one cannot hide in the +other. + +Tokens live in one file, mode 0600, one line per remote: `/ `. +The owner secret for a server is stored under the bare host with user `owner`. +""" +from __future__ import annotations + +import base64 +import getpass +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path +from urllib.parse import urlsplit + +from .core import GraphError, ID_RE + + +# ---------------------------------------------------------------- credentials + +def cred_path() -> Path: + return Path(os.environ.get("KNOTEN_CREDENTIALS") + or Path.home() / ".config" / "knoten" / "credentials") + + +def _key(url: str) -> str: + u = urlsplit(url) + return f"{u.netloc}{u.path}".rstrip("/") + + +def cred_store(url: str, user: str, secret: str) -> None: + p = cred_path() + p.parent.mkdir(parents=True, exist_ok=True) + key = _key(url) + lines = [l for l in (p.read_text(encoding="utf-8").splitlines() if p.exists() else []) + if not l.startswith(key + " ")] + lines.append(f"{key} {user} {secret}") + fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + # os.open's mode applies only when the file is created; existing files keep their bits. + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + + +def cred_lookup(url: str) -> tuple[str, str] | None: + p = cred_path() + if not p.exists(): + return None + key = _key(url) + for line in p.read_text(encoding="utf-8").splitlines(): + parts = line.split(" ", 2) + if len(parts) == 3 and parts[0] == key: + return parts[1], parts[2] + return None + + +def credential_helper(request: str) -> str: + """git's credential protocol: key=value lines in, username and password out. + + Empty output for an unknown remote, never an error: git then falls through to its + next helper, so every non-knoten remote on the machine keeps working. + """ + fields = dict(line.split("=", 1) for line in request.splitlines() if "=" in line) + url = f"{fields.get('protocol', 'https')}://{fields.get('host', '')}/{fields.get('path', '')}" + found = cred_lookup(url) + return f"username={found[0]}\npassword={found[1]}\n" if found else "" + + +# ---------------------------------------------------------------- git and http + +def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True) + + +def _toplevel(root: Path) -> Path: + r = _git(root, "rev-parse", "--show-toplevel") + if r.returncode != 0: + raise GraphError(f"{root} is not in a git repository; run `git init` there first") + return Path(r.stdout.strip()) + + +def _origin(repo: Path) -> str: + r = _git(repo, "remote", "get-url", "origin") + if r.returncode != 0: + raise GraphError("this graph has no remote; run `knoten remote create` or " + "`knoten remote add` first") + return r.stdout.strip() + + +def _wire(repo: Path, git_url: str) -> None: + """Point origin at the remote and make git ask knoten for the token.""" + has = _git(repo, "remote", "get-url", "origin").returncode == 0 + _git(repo, "remote", "set-url" if has else "add", "origin", git_url) + for key, value in (("credential.helper", "!knoten credential"), + ("credential.useHttpPath", "true"), + # Large pushes would otherwise go chunked, which the stdlib + # server does not read. 500 MB keeps them Content-Length. + ("http.postBuffer", "524288000")): + _git(repo, "config", key, value) + + +def _api(url: str, body: dict, auth: tuple[str, str] | None = None) -> dict: + req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST", + headers={"Content-Type": "application/json"}) + if auth: + cred = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode() + req.add_header("Authorization", "Basic " + cred) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read() or b"{}") + except urllib.error.HTTPError as e: + try: + message = json.loads(e.read()).get("error", e.reason) + except Exception: + message = e.reason + raise GraphError(str(message).removeprefix("knoten: ")) from None + except urllib.error.URLError as e: + raise GraphError(f"cannot reach {url}: {e.reason}") from None + + +def _explain(stderr: str) -> str: + """git prints only a status code for an HTTP refusal. Say what it means. + + A bare substring search for "401"/"403" also matched git's own URL line + (`fatal: unable to access 'http://127.0.0.1:36605/trading.git/': The requested + URL returned error: 403`), so any host, port or graph name that happened to + contain those three digits flipped the verdict — the `hub` fixture binds + port 0, and plenty of ephemeral ports contain "401". Match git's phrasing, + not the digits. + """ + # A relayed `remote:` line can itself contain "401" or "403" (a node id, a rule + # message) — match only git's own lines, or a rule violation reads as a credential + # problem. + own = "\n".join(l for l in stderr.splitlines() if not l.startswith("remote:")) + if re.search(r"returned error: 401\b|HTTP 401\b|Authentication failed|terminal prompts disabled", own): + return "credentials refused; the token may have been revoked. Ask for a new invite." + if re.search(r"returned error: 403\b|HTTP 403\b", own): + return "this token has read access, not write" + if "pre-receive hook declined" in stderr: + return "the server refused the push; fix the violations above and push again" + lines = [l for l in stderr.strip().splitlines() if l.strip()] + return lines[-1] if lines else "git failed" + + +def _relay(stderr: str) -> None: + """The gate's own output arrives as `remote:` lines. Show those, and only those.""" + for line in stderr.splitlines(): + if line.startswith("remote:"): + print(line, file=sys.stderr) + + +# ---------------------------------------------------------------- commands + +def remote_create(root: Path, name: str, on: str, admin: str | None = None, + owner_secret: str | None = None) -> str: + repo = _toplevel(root) + on = on.rstrip("/") + u = urlsplit(on) + host_url = f"{u.scheme}://{u.netloc}" + secret = owner_secret or (cred_lookup(host_url) or ("", ""))[1] + if not secret: + try: + secret = getpass.getpass(f"owner secret for {u.netloc}: ") + except EOFError: + # No terminal to prompt on (cron, CI, a pipe): a raw traceback here would + # break "no tracebacks for user error", so this is a refusal like any other. + raise GraphError(f"no owner secret for {u.netloc}; pass --owner-secret, or " + "run this where a prompt can be answered") from None + if admin is None: + admin = _git(repo, "config", "user.name").stdout.strip().lower().replace(" ", "-") + if not ID_RE.match(admin or ""): + raise GraphError(f"'{admin}' is not a valid contributor name; pass --as NAME (kebab-case)") + + token = _api(f"{on}/graphs", {"name": name, "admin": admin}, ("owner", secret))["token"] + cred_store(host_url, "owner", secret) + git_url = f"{on}/{name}.git" + cred_store(git_url, admin, token) + _wire(repo, git_url) + r = _git(repo, "push", "-u", "origin", "HEAD") + _relay(r.stderr) + if r.returncode != 0: + raise GraphError(_explain(r.stderr)) + return f"{on}/{name}" + + +def remote_add(root: Path, url: str) -> None: + _wire(_toplevel(root), url.rstrip("/") + ".git") + + +def push(root: Path) -> int: + repo = _toplevel(root) + _origin(repo) + r = _git(repo, "push", "origin", "HEAD") + _relay(r.stderr) + if r.returncode != 0: + raise GraphError(_explain(r.stderr)) + print(" ✓ pushed") + return 0 + + +def pull(root: Path) -> int: + repo = _toplevel(root) + _origin(repo) + r = _git(repo, "pull", "-q", "--ff-only", "origin") + if r.returncode != 0: + raise GraphError(_explain(r.stderr)) + print(" ✓ up to date") + return 0 + + +def _graph_api(root: Path) -> tuple[str, tuple[str, str]]: + """The graph's API base URL and the caller's credentials for it.""" + repo = _toplevel(root) + git_url = _origin(repo) + auth = cred_lookup(git_url) + if not auth: + raise GraphError("no credentials stored for this remote; are you a contributor here?") + return git_url.removesuffix(".git"), auth + + +def invite(root: Path, name: str, role: str = "write", days: int = 7) -> str: + base, auth = _graph_api(root) + return _api(f"{base}/invite", {"name": name, "role": role, "days": days}, auth)["code"] + + +def revoke(root: Path, name: str) -> None: + base, auth = _graph_api(root) + _api(f"{base}/revoke", {"name": name}, auth) + + +def join(url: str, code: str, dest: str | None = None) -> tuple[Path, str, str]: + """Redeem the code, remember the token, clone. Nothing is written to disk until the + server has accepted the code, so a wrong code leaves no half-made clone behind.""" + url = url.rstrip("/") + git_url = url + ".git" + got = _api(f"{url}/join", {"code": code}) + cred_store(git_url, got["name"], got["token"]) + target = Path(dest or url.rsplit("/", 1)[-1]) + r = subprocess.run(["git", "-c", "credential.helper=!knoten credential", + "-c", "credential.useHttpPath=true", + "clone", "-q", git_url, str(target)], capture_output=True, text=True) + if r.returncode != 0: + # The server already consumed the code in the _api call above, one line up. git's + # own error alone reads like the code is still good and worth retrying — it is + # not, so say what actually happened and how to finish without it. + raise GraphError( + f"clone failed: {_explain(r.stderr)}. The invite is spent but your credentials " + f"are saved, so finish by hand: git clone {git_url} && cd && " + f"knoten remote add {url}") + _wire(target, git_url) + return target, got["name"], got["role"] diff --git a/src/knoten/serve.py b/src/knoten/serve.py new file mode 100644 index 0000000..1e91192 --- /dev/null +++ b/src/knoten/serve.py @@ -0,0 +1,225 @@ +"""One job per request: decide whether this token may do this, then hand the request to +git. + +`git http-backend` ships with git and speaks the smart HTTP protocol as a CGI. It is what +nginx and Apache call to host git. knoten's server is the authorization layer in front +of it and nothing more: packfiles, refs, negotiation and the pre-receive gate are git's. + +git's own protocol separates reading from writing by endpoint (`git-upload-pack` serves +data out, `git-receive-pack` takes it in), so "read access" is a token that never gets +past the door for receive-pack. There is no finer reasoning about git's data model here. + +Binds localhost and speaks plain HTTP. TLS is a reverse proxy's or a tunnel's job. +""" +from __future__ import annotations + +import base64 +import json +import os +import re +import subprocess +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from .core import GraphError +from .registry import Registry + +GIT_RE = re.compile(r"^/([a-z0-9][a-z0-9_-]*)\.git(/.*)$") +API_RE = re.compile(r"^/([a-z0-9][a-z0-9_-]*)/(join|invite|revoke)$") +WRITE = "git-receive-pack" + + +def make_server(reg: Registry, host: str = "127.0.0.1", port: int = 8899) -> ThreadingHTTPServer: + class Handler(_Handler): + registry = reg + return ThreadingHTTPServer((host, port), Handler) + + +class _Handler(BaseHTTPRequestHandler): + registry: Registry + + def log_message(self, *args) -> None: # one line per request is noise on a server + pass + + # ---------------------------------------------------------------- helpers + + def _basic(self) -> tuple[str, str]: + header = self.headers.get("Authorization", "") + if not header.startswith("Basic "): + return "", "" + try: + raw = base64.b64decode(header[6:]).decode() + except Exception: + return "", "" + user, _, secret = raw.partition(":") + return user, secret + + def _body(self) -> bytes: + raw = self.headers.get("Content-Length") + try: + length = int(raw) if raw else 0 + except ValueError: + # "Content-Length: abc" raised here unguarded, escaping _route's except + # GraphError: the thread died with no response and a traceback on the + # server's stderr. GraphError instead turns into an ordinary 400. + raise GraphError("request body length is invalid") from None + if not (0 <= length <= 104857600): # matches receive.maxInputSize (100 MB) + # A negative length reaches rfile.read(-1), which reads until EOF -- on a + # socket the client never closes, that parks the thread forever: an + # unauthenticated way to exhaust the server's thread pool one request at a time. + raise GraphError("request body length is invalid") + return self.rfile.read(length) + + def _json_body(self) -> dict: + try: + result = json.loads(self._body() or b"{}") + except json.JSONDecodeError as e: + raise GraphError(f"request body is not JSON: {e}") from None + # A list like [1, 2, 3] parses fine but then crashes the thread on .get(). + if not isinstance(result, dict): + raise GraphError("request body must be a JSON object") + return result + + def _json(self, status: int, obj: dict) -> None: + data = json.dumps(obj).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + if status == 401: + self.send_header("WWW-Authenticate", 'Basic realm="knoten"') + self.end_headers() + self.wfile.write(data) + + def _refuse(self, status: int, message: str) -> None: + self._json(status, {"error": message}) + + # ---------------------------------------------------------------- routing + + def do_GET(self) -> None: + self._route() + + def do_POST(self) -> None: + self._route() + + def _route(self) -> None: + path, _, query = self.path.partition("?") + try: + if m := GIT_RE.match(path): + return self._git(m.group(1), m.group(2), query) + if path == "/graphs" and self.command == "POST": + return self._create() + if (m := API_RE.match(path)) and self.command == "POST": + return getattr(self, "_" + m.group(2))(m.group(1)) + self._refuse(404, "knoten: not found") + except GraphError as e: + self._refuse(400, f"knoten: {e}") + + # ---------------------------------------------------------------- git + + def _git(self, name: str, sub: str, query: str) -> None: + user, token = self._basic() + role = self.registry.authenticate(name, user, token) + if role is None: + # Same answer for "wrong token" and "no such graph": the server does not + # confirm which graphs exist to someone who cannot open them. + return self._refuse(401, "knoten: credentials required" if not token + else "knoten: that token is not valid here") + if role == "read" and (WRITE in query or sub.endswith("/" + WRITE)): + return self._refuse(403, f"knoten: {user} has read access to {name}, not write") + if "chunked" in self.headers.get("Transfer-Encoding", "").lower(): + # _body only ever reads Content-Length bytes, so a chunked push (git goes + # chunked above http.postBuffer, default 1 MiB) hands http-backend an empty + # stdin; http-backend dies and the client sees a bare, unexplained 500. + return self._refuse(411, "knoten: chunked uploads are not supported; set " + "http.postBuffer to at least the push size (knoten remote " + "add does this) and push again") + + env = { + **os.environ, + "GIT_PROJECT_ROOT": str(self.registry.graph_dir(name)), + "GIT_HTTP_EXPORT_ALL": "1", + "PATH_INFO": "/repo.git" + sub, # the URL says .git; disk says repo.git + "QUERY_STRING": query, + "REQUEST_METHOD": self.command, + "REMOTE_USER": user, + "REMOTE_ADDR": self.client_address[0], + "CONTENT_TYPE": self.headers.get("Content-Type", ""), + "CONTENT_LENGTH": self.headers.get("Content-Length", ""), + "HTTP_CONTENT_ENCODING": self.headers.get("Content-Encoding", ""), + } + r = subprocess.run(["git", "http-backend"], input=self._body(), + capture_output=True, env=env) + head, _, out = r.stdout.partition(b"\r\n\r\n") + status, headers, saw_status = 200, [], False + for line in head.decode(errors="replace").splitlines(): + key, _, value = line.partition(":") + if key.strip().lower() == "status": + status, saw_status = int(value.strip().split()[0]), True + elif key.strip().lower() == "content-length": + pass # knoten sends its own Content-Length below; relaying http-backend's too would duplicate the header + elif key.strip(): + headers.append((key.strip(), value.strip())) + if r.returncode != 0: + print(f"knoten serve: git http-backend: {r.stderr.decode(errors='replace').strip()}", + file=sys.stderr) + if not saw_status: + # A pre-receive refusal travels the sideband with exit 0, so a non-zero + # exit with no Status line can only be the backend itself dying — a + # crashed backend used to relay to the client as a silent 200. + return self._refuse(500, "knoten: git http-backend failed on the server; see its log") + self.send_response(status) + for key, value in headers: + self.send_header(key, value) + self.send_header("Content-Length", str(len(out))) + self.end_headers() + self.wfile.write(out) + + # ---------------------------------------------------------------- api + + def _create(self) -> None: + user, secret = self._basic() + # Distinguishing "wrong username" from "wrong secret" would tell an attacker which half they got right. + if user != "owner" or not self.registry.check_owner(secret): + return self._refuse(401, "knoten: the owner secret is required to create a graph") + body = self._json_body() + token = self.registry.create(body.get("name", ""), body.get("admin", "")) + self._json(201, {"token": token}) + + def _join(self, name: str) -> None: + body = self._json_body() + if not self.registry.exists(name): + # /join needs no credentials, so it must not become a name oracle: an + # unknown graph gets the same 400 a wrong code gets, not "no graph 'x'". + return self._refuse(400, "knoten: that invite code is not valid for this graph") + user, role, token = self.registry.redeem(name, body.get("code", "")) + self._json(200, {"name": user, "role": role, "token": token}) + + def _admin(self, name: str) -> str | None: + """The calling admin's name, or None after having refused the request. + + If auth fails, _admin writes the 403 response itself. Callers check the return + value and must return without writing anything, or the client gets two responses + on one connection.""" + user, token = self._basic() + if self.registry.authenticate(name, user, token) != "admin": + self._refuse(403, f"knoten: only an admin of {name} can do that") + return None + return user + + def _invite(self, name: str) -> None: + if not self._admin(name): + return + body = self._json_body() + try: + days = int(body.get("days", 7)) + except (TypeError, ValueError): + raise GraphError("days must be a whole number") from None + code = self.registry.invite(name, body.get("name", ""), body.get("role", "write"), days) + self._json(200, {"code": code}) + + def _revoke(self, name: str) -> None: + if not self._admin(name): + return + body = self._json_body() + self.registry.revoke(name, body.get("name", "")) + self._json(200, {"revoked": body.get("name", "")}) diff --git a/tests/conftest.py b/tests/conftest.py index 7040c79..0022035 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import os import sys from pathlib import Path @@ -50,3 +51,63 @@ def read(self, nid): (tmp_path / "nodes").mkdir() return Graph().rules() + + +GIT_ISOLATION = { + # The developer's own git config must not reach these tests: a global credential + # helper would answer the server's 401 with cached credentials and pass a test that + # should fail, and a global signing setup would make commits in fixtures fail. + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_TERMINAL_PROMPT": "0", +} + + +@pytest.fixture +def hub(tmp_path, monkeypatch): + """A real knoten server on a random localhost port, and an isolated credential store. + + Real because the failures worth catching live in the seam between git's client, the + HTTP layer and git-http-backend, and none of them are visible to a mock. + """ + import threading + from types import SimpleNamespace + + from knoten.registry import Registry + from knoten.serve import make_server + + for k, v in GIT_ISOLATION.items(): + monkeypatch.setenv(k, v) + monkeypatch.setenv("KNOTEN_CREDENTIALS", str(tmp_path / "credentials")) + + reg = Registry(tmp_path / "data") + srv = make_server(reg, "127.0.0.1", 0) + threading.Thread(target=srv.serve_forever, daemon=True).start() + host, port = srv.server_address + yield SimpleNamespace(url=f"http://{host}:{port}", registry=reg, + secret=reg.owner_secret(), data=tmp_path / "data") + srv.shutdown() + srv.server_close() + + +@pytest.fixture +def local_graph(tmp_path, rules_yaml): + """A git repo whose root is a graph with one committed node: what a user has on disk + the moment they decide to share it. + + Nested under "admin/", not directly in tmp_path: a friend joining a graph named + "trading" clones to tmp_path/"trading" by default, and that must never collide with + the admin's own on-disk checkout of the same graph, which happens to share this + tmp_path in tests.""" + import subprocess + + root = tmp_path / "admin" / "trading" + (root / "nodes").mkdir(parents=True) + (root / "graph.yaml").write_text(rules_yaml, encoding="utf-8") + (root / "nodes" / "hyp-ok.md").write_text( + "---\nid: hyp-ok\ntype: hypothesis\nstatus: open\n---\n\n# a claim\n", encoding="utf-8") + env = {**os.environ, **GIT_ISOLATION} + for cmd in (["init", "-q", "-b", "master"], ["config", "user.email", "t@t.t"], + ["config", "user.name", "t"], ["add", "-A"], ["commit", "-qm", "seed"]): + subprocess.run(["git", *cmd], cwd=root, check=True, env=env, capture_output=True) + return root diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..f14afea --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,215 @@ +"""What a server knows that the graph does not: who may connect, who is invited, who +owns the box. Files, hashed, locked. Losing this directory loses availability, never the +meaning of a graph.""" +import os +import stat + +import pytest + +from knoten.core import GraphError +from knoten.registry import ROLES, Registry + + +@pytest.fixture +def reg(tmp_path): + return Registry(tmp_path / "data") + + +def test_the_owner_secret_is_created_once_and_kept_private(reg): + """Printed once by `knoten serve`. If it changed between calls, the owner would be + locked out of their own server on restart.""" + first = reg.owner_secret() + + assert first == reg.owner_secret() + assert len(first) >= 32 + mode = stat.S_IMODE((reg.data / "owner").stat().st_mode) + assert mode == 0o600, f"owner secret is world-readable: {oct(mode)}" + + +def test_check_owner_accepts_only_the_secret(reg): + assert reg.check_owner(reg.owner_secret()) + assert not reg.check_owner("nope") + assert not reg.check_owner("") + + +def test_create_makes_a_gated_bare_repo(reg): + """The gate is installed at creation, not later. A remote graph that exists for even + one push without it has accepted whatever that push contained.""" + reg.create("trading", admin="seb") + + repo = reg.repo("trading") + assert (repo / "HEAD").exists(), "not a git repo" + assert not (repo / ".git").exists(), "not bare" + hook = repo / "hooks" / "pre-receive" + assert hook.exists() and os.access(hook, os.X_OK) + assert "knoten" in hook.read_text(encoding="utf-8") + + +def test_create_refuses_a_second_graph_of_the_same_name(reg): + reg.create("trading", admin="seb") + + with pytest.raises(GraphError, match="already exists"): + reg.create("trading", admin="seb") + + +@pytest.mark.parametrize("bad", ["../etc", "Trading", "a b", "", "-x", "x/y"]) +def test_graph_names_outside_id_re_are_refused_before_touching_disk(reg, bad): + """A graph name becomes a directory. `../etc` would be created outside the data + dir; the check has to run before mkdir, not after.""" + with pytest.raises(GraphError, match="not a valid graph name"): + reg.create(bad, admin="seb") + + assert sorted(p.name for p in (reg.data / "graphs").iterdir()) == [] + + +def test_repo_of_an_unknown_graph_is_an_error_not_a_path(reg): + with pytest.raises(GraphError, match="no graph 'nope'"): + reg.repo("nope") + assert not reg.exists("nope") + + +def test_create_rolls_back_on_partial_failure(reg, monkeypatch): + """A half-made repo that exists() calls valid would accept pushes with no gate, forever. + Partial creation must be rolled back atomically so a retry can succeed.""" + import knoten.registry + def failing_install(repo): + raise OSError("disk full") + monkeypatch.setattr(knoten.registry, "install_server", failing_install) + + with pytest.raises(GraphError, match="could not create"): + reg.create("trading", admin="seb") + + assert not reg.exists("trading") + assert sorted(p.name for p in (reg.data / "graphs").iterdir()) == [] + + # Restore monkeypatch and retry succeeds + monkeypatch.undo() + reg.create("trading", admin="seb") + assert reg.exists("trading") + + +# ---------------------------------------------------------------- tokens + +def test_a_minted_token_authenticates_with_its_role(reg): + reg.create("trading", admin="seb") + tok = reg.mint("trading", "maria", "write") + + assert reg.authenticate("trading", "maria", tok) == "write" + + +def test_the_wrong_token_the_wrong_user_and_the_wrong_graph_all_fail_closed(reg): + reg.create("trading", admin="seb") + tok = reg.mint("trading", "maria", "write") + + assert reg.authenticate("trading", "maria", "not-it") is None + assert reg.authenticate("trading", "seb", tok) is None # someone else's token + assert reg.authenticate("biology", "maria", tok) is None # no such graph + assert reg.authenticate("trading", "maria", "") is None + assert reg.authenticate("trading", "", tok) is None + + +def test_tokens_are_stored_hashed(reg): + """A leaked tokens.json must be worthless.""" + reg.create("trading", admin="seb") + tok = reg.mint("trading", "maria", "write") + + on_disk = (reg.graph_dir("trading") / "tokens.json").read_text(encoding="utf-8") + assert tok not in on_disk + assert "maria" in on_disk + + +def test_create_returns_a_working_admin_token(reg): + tok = reg.create("trading", admin="seb") + + assert reg.authenticate("trading", "seb", tok) == "admin" + + +@pytest.mark.parametrize("role", ["owner", "Admin", "", "rw"]) +def test_a_role_outside_the_three_is_refused(reg, role): + reg.create("trading", admin="seb") + with pytest.raises(GraphError, match="role must be one of"): + reg.mint("trading", "maria", role) + + +def test_contributor_names_follow_id_re(reg): + """The name ends up as a JSON key and, in phase 2, as a filename in graph.yaml.""" + reg.create("trading", admin="seb") + with pytest.raises(GraphError, match="not a valid contributor name"): + reg.mint("trading", "Maria Lopez", "write") + + +def test_revoke_ends_access_and_nothing_else(reg): + reg.create("trading", admin="seb") + tok = reg.mint("trading", "maria", "write") + + reg.revoke("trading", "maria") + + assert reg.authenticate("trading", "maria", tok) is None + assert reg.exists("trading") + + +def test_revoking_a_stranger_is_an_error(reg): + reg.create("trading", admin="seb") + with pytest.raises(GraphError, match="no contributor 'ghost'"): + reg.revoke("trading", "ghost") + + +def test_revoking_on_an_unknown_graph_is_a_graph_error(reg): + """graph_lock opens a file inside the graph directory. Without an existence check + first, a well-formed name for a graph that does not exist raised a raw + FileNotFoundError instead of the one-line refusal every other method gives.""" + with pytest.raises(GraphError, match="no graph 'biology'"): + reg.revoke("biology", "maria") + + +# ---------------------------------------------------------------- invites + +def test_an_invite_redeems_once_into_a_working_token(reg): + reg.create("trading", admin="seb") + code = reg.invite("trading", "maria", "write") + + user, role, tok = reg.redeem("trading", code) + + assert (user, role) == ("maria", "write") + assert reg.authenticate("trading", "maria", tok) == "write" + with pytest.raises(GraphError, match="not valid"): + reg.redeem("trading", code) # spent + + +def test_an_expired_invite_is_refused_and_spent(reg): + """Expired codes are removed when tried, so a stale invites.json does not grow + forever and a late guess cannot be retried after the clock is fixed.""" + reg.create("trading", admin="seb") + code = reg.invite("trading", "maria", "write", days=-1) + + with pytest.raises(GraphError, match="expired"): + reg.redeem("trading", code) + with pytest.raises(GraphError, match="not valid"): + reg.redeem("trading", code) + + +def test_invite_codes_are_stored_hashed(reg): + reg.create("trading", admin="seb") + code = reg.invite("trading", "maria", "write") + + assert code not in (reg.graph_dir("trading") / "invites.json").read_text(encoding="utf-8") + + +def test_a_wrong_code_and_a_wrong_graph_both_fail(reg): + reg.create("trading", admin="seb") + reg.invite("trading", "maria", "write") + + with pytest.raises(GraphError, match="not valid"): + reg.redeem("trading", "guess") + with pytest.raises(GraphError, match="no graph 'biology'"): + reg.redeem("biology", "anything") + + +def test_secrets_that_travel_on_a_command_line_never_start_with_a_dash(reg): + """`--owner-secret VALUE` and `--invite CODE` are argv. A value beginning with `-` + is a flag to argparse, and token_urlsafe produced one about one run in five.""" + reg.create("trading", admin="seb") + for _ in range(64): + assert not reg.invite("trading", "maria", "write").startswith("-") + assert not reg.owner_secret().startswith("-") + assert all(c in "0123456789abcdef" for c in reg.owner_secret()) diff --git a/tests/test_remote.py b/tests/test_remote.py new file mode 100644 index 0000000..e51d0f7 --- /dev/null +++ b/tests/test_remote.py @@ -0,0 +1,364 @@ +"""The client side of a remote graph: a credential store git calls through its own +credential-helper protocol, and commands that wrap git plus four JSON calls.""" +import os +import stat +import subprocess + +import pytest + +from knoten.cli import main +from knoten.core import GraphError +from knoten.remote import _explain, cred_lookup, cred_path, cred_store, credential_helper + + +def git(*args, cwd, env=None): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, + env={**os.environ, **(env or {})}) + + +@pytest.fixture(autouse=True) +def isolated_credentials(tmp_path, monkeypatch): + monkeypatch.setenv("KNOTEN_CREDENTIALS", str(tmp_path / "creds")) + + +# ---------------------------------------------------------------- the store + +def test_store_and_lookup_round_trip_per_remote(): + cred_store("https://h.example/trading.git", "seb", "tok-1") + cred_store("https://h.example/biology.git", "seb", "tok-2") + + assert cred_lookup("https://h.example/trading.git") == ("seb", "tok-1") + assert cred_lookup("https://h.example/biology.git") == ("seb", "tok-2") + assert cred_lookup("https://h.example/nope.git") is None + + +def test_storing_the_same_remote_again_replaces_it(): + cred_store("https://h.example/trading.git", "seb", "old") + cred_store("https://h.example/trading.git", "seb", "new") + + assert cred_lookup("https://h.example/trading.git") == ("seb", "new") + assert cred_path().read_text().count("trading.git") == 1 + + +def test_the_store_is_private_to_the_user(): + cred_store("https://h.example/trading.git", "seb", "tok") + + assert stat.S_IMODE(cred_path().stat().st_mode) == 0o600 + + +def test_the_store_is_made_private_again_if_it_was_not(): + """os.open's mode applies only when the file is created. A credentials file that + already existed with looser bits (a manual copy, a bad umask) kept them on every + later write, leaking every token to other local users.""" + p = cred_path() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("h.example/old.git seb tok\n", encoding="utf-8") + os.chmod(p, 0o644) + + cred_store("https://h.example/trading.git", "seb", "tok") + + assert stat.S_IMODE(p.stat().st_mode) == 0o600 + + +def test_lookup_with_no_store_is_none_not_an_error(): + assert cred_lookup("https://h.example/trading.git") is None + + +# ---------------------------------------------------------------- git's protocol + +def test_the_helper_answers_gits_request_for_a_known_remote(): + """git writes key=value lines and reads username/password back. `path` is only + sent when credential.useHttpPath is on, which the client sets, so one host can + hold many graphs with different tokens.""" + cred_store("https://h.example/trading.git", "maria", "tok") + + out = credential_helper("protocol=https\nhost=h.example\npath=trading.git\n") + + assert out == "username=maria\npassword=tok\n" + + +def test_the_helper_says_nothing_for_an_unknown_remote(): + """Empty output tells git to fall through to its next helper or to prompt. Anything + else, including an error, would break every non-knoten remote on the machine.""" + assert credential_helper("protocol=https\nhost=h.example\npath=other.git\n") == "" + + +def test_the_cli_exposes_the_helper_on_stdin(monkeypatch, capsys): + import io + cred_store("https://h.example/trading.git", "maria", "tok") + monkeypatch.setattr("sys.stdin", io.StringIO("protocol=https\nhost=h.example\npath=trading.git\n")) + + assert main(["credential", "get"]) == 0 + assert capsys.readouterr().out == "username=maria\npassword=tok\n" + + +# ---------------------------------------------------------------- create, push, pull + +def commit_node(work, name, text): + (work / "nodes" / name).write_text(text, encoding="utf-8") + git("add", "-A", cwd=work) + git("commit", "-qm", name, cwd=work) + + +@pytest.fixture +def shared(hub, local_graph, monkeypatch): + """`local_graph` created on `hub` by its admin through the CLI, cwd inside it.""" + monkeypatch.chdir(local_graph) + assert main(["remote", "create", "trading", "--on", hub.url, "--as", "seb", + "--owner-secret", hub.secret]) == 0 + return local_graph + + +def test_remote_create_puts_the_graph_on_the_server_and_wires_the_clone(capsys, hub, shared): + """One command: the graph exists on the server, the admin's token is stored, origin + points at it, and the seed commit is already there.""" + assert hub.registry.exists("trading") + assert "seed" in git("log", "--oneline", cwd=hub.registry.repo("trading")).stdout + assert cred_lookup(f"{hub.url}/trading.git")[0] == "seb" + assert cred_lookup(hub.url) == ("owner", hub.secret) + assert git("remote", "get-url", "origin", cwd=shared).stdout.strip() == f"{hub.url}/trading.git" + assert git("config", "credential.helper", cwd=shared).stdout.strip() == "!knoten credential" + assert f"{hub.url}/trading" in capsys.readouterr().out + + +def test_remote_create_remembers_the_owner_secret_for_the_next_graph(hub, local_graph, tmp_path, monkeypatch): + monkeypatch.chdir(local_graph) + main(["remote", "create", "trading", "--on", hub.url, "--as", "seb", "--owner-secret", hub.secret]) + + second = tmp_path / "biology" + (second / "nodes").mkdir(parents=True) + (second / "graph.yaml").write_text("name: biology\n", encoding="utf-8") + for c in (["init", "-q", "-b", "master"], ["config", "user.email", "t@t.t"], + ["config", "user.name", "t"], ["add", "-A"], ["commit", "-qm", "seed"]): + git(*c, cwd=second) + monkeypatch.chdir(second) + + assert main(["remote", "create", "biology", "--on", hub.url, "--as", "seb"]) == 0 + assert hub.registry.exists("biology") + + +def test_remote_create_outside_a_git_repo_says_git_init(hub, tmp_path, monkeypatch, capsys): + g = tmp_path / "g" + (g / "nodes").mkdir(parents=True) + (g / "graph.yaml").write_text("name: g\n", encoding="utf-8") + monkeypatch.chdir(g) + + assert main(["remote", "create", "g", "--on", hub.url, "--as", "seb", "--owner-secret", hub.secret]) == 1 + assert "git init" in capsys.readouterr().err + + +def test_remote_create_with_the_wrong_owner_secret_is_one_line(hub, local_graph, monkeypatch, capsys): + monkeypatch.chdir(local_graph) + + assert main(["remote", "create", "trading", "--on", hub.url, "--as", "seb", "--owner-secret", "no"]) == 1 + err = capsys.readouterr().err + assert "owner secret" in err and "Traceback" not in err + + +def test_remote_create_with_no_secret_and_no_terminal_is_one_line(hub, local_graph, monkeypatch, capsys): + """getpass raises EOFError when stdin is not a terminal. From a script or CI that + was a traceback instead of the one line every other refusal gives. The prompt itself + is stubbed rather than pointing stdin at a StringIO, which makes getpass warn about + not being able to control echo on the terminal.""" + def no_terminal(*_a, **_k): + raise EOFError + monkeypatch.chdir(local_graph) + monkeypatch.setattr("knoten.remote.getpass.getpass", no_terminal) + + assert main(["remote", "create", "trading", "--on", hub.url, "--as", "seb"]) == 1 + err = capsys.readouterr().err + assert "owner secret" in err and "Traceback" not in err + + +def test_push_goes_through_the_gate(hub, shared, capsys): + commit_node(shared, "hyp-x.md", "---\nid: hyp-x\ntype: hypothesis\nstatus: alive\n---\n\n# x\n") + + assert main(["push"]) == 1 + err = capsys.readouterr().err + assert "live-claims-must-cite-their-gates" in err + assert "hyp-x" not in git("log", "--oneline", cwd=hub.registry.repo("trading")).stdout + + git("reset", "-q", "--hard", "HEAD~1", cwd=shared) + commit_node(shared, "hyp-y.md", "---\nid: hyp-y\ntype: hypothesis\nstatus: open\n---\n\n# y\n") + + assert main(["push"]) == 0 + assert "hyp-y" in git("log", "--oneline", cwd=hub.registry.repo("trading")).stdout + + +def test_pull_brings_a_collaborators_node_down(hub, shared, tmp_path, monkeypatch): + tok = hub.registry.mint("trading", "maria", "write") + other = tmp_path / "maria" + url = f"http://maria:{tok}@{hub.url.removeprefix('http://')}/trading.git" + git("clone", "-q", url, str(other), cwd=tmp_path) + git("config", "user.email", "m@m.m", cwd=other); git("config", "user.name", "maria", cwd=other) + commit_node(other, "hyp-m.md", "---\nid: hyp-m\ntype: hypothesis\nstatus: open\n---\n\n# m\n") + assert git("push", "-q", "origin", "master", cwd=other).returncode == 0 + + monkeypatch.chdir(shared) + assert main(["pull"]) == 0 + assert (shared / "nodes" / "hyp-m.md").exists() + + +def test_push_with_a_read_token_is_explained_not_dumped(hub, shared, tmp_path, monkeypatch, capsys): + """git says `The requested URL returned error: 403`. The user should read `read + access, not write`.""" + tok = hub.registry.mint("trading", "reader", "read") + cred_store(f"{hub.url}/trading.git", "reader", tok) + commit_node(shared, "hyp-z.md", "---\nid: hyp-z\ntype: hypothesis\nstatus: open\n---\n\n# z\n") + + assert main(["push"]) == 1 + assert "read access, not write" in capsys.readouterr().err + + +def test_a_rule_message_containing_401_is_not_mistaken_for_a_credential_problem(hub, shared, capsys): + """The gate's remote: lines share stderr with git's own. A node id with 401 in it + used to make the summary say "credentials refused" for a plain rule violation.""" + commit_node(shared, "hyp-401-alive.md", + "---\nid: hyp-401-alive\ntype: hypothesis\nstatus: alive\n---\n\n# x\n") + + assert main(["push"]) == 1 + err = capsys.readouterr().err + assert "live-claims-must-cite-their-gates" in err + assert "credentials refused" not in err + assert "refused the push" in err + + +def test_explain_is_not_fooled_by_digits_in_gits_own_url_line(): + """`_explain` used to bare-substring-search for "401"/"403", which also matched + git's own `fatal: unable to access '...'` line whenever the port or graph name in + the URL happened to contain those three digits. A port of 34012 turned a real 403 + into "credentials refused"; a port of 35403 turned a real 401 into "read access, + not write". Neither port carries the phrase git actually uses for the error.""" + read_only = ( + "fatal: unable to access 'http://127.0.0.1:34012/trading.git/': " + "The requested URL returned error: 403" + ) + assert "read access, not write" in _explain(read_only) + + bad_token = ( + "fatal: unable to access 'http://127.0.0.1:35403/trading.git/': " + "The requested URL returned error: 401" + ) + assert "credentials refused" in _explain(bad_token) + + +def test_push_without_a_remote_says_so(local_graph, monkeypatch, capsys): + monkeypatch.chdir(local_graph) + assert main(["push"]) == 1 + assert "remote create" in capsys.readouterr().err + + +def test_remote_add_points_an_existing_clone_at_a_remote(hub, local_graph, monkeypatch): + monkeypatch.chdir(local_graph) + assert main(["remote", "add", f"{hub.url}/trading"]) == 0 + + assert git("remote", "get-url", "origin", cwd=local_graph).stdout.strip() == f"{hub.url}/trading.git" + assert git("config", "credential.useHttpPath", cwd=local_graph).stdout.strip() == "true" + + +# ---------------------------------------------------------------- the friend's journey + +def test_the_whole_journey(hub, shared, tmp_path, monkeypatch, capsys): + """You create a remote and invite Maria. Maria joins, adds a node, pushes. You pull + and it is there. She pushes a broken one and it is not. Every step through the CLI.""" + assert main(["invite", "maria", "--role", "write"]) == 0 + code = capsys.readouterr().out.strip().split()[-1] + + monkeypatch.chdir(tmp_path) + assert main(["join", f"{hub.url}/trading", "--invite", code]) == 0 + clone = tmp_path / "trading" + assert (clone / "nodes" / "hyp-ok.md").exists() + assert "maria" in capsys.readouterr().out + + git("config", "user.email", "m@m.m", cwd=clone); git("config", "user.name", "maria", cwd=clone) + commit_node(clone, "hyp-m.md", "---\nid: hyp-m\ntype: hypothesis\nstatus: open\n---\n\n# m\n") + monkeypatch.chdir(clone) + assert main(["push"]) == 0 + + monkeypatch.chdir(shared) + assert main(["pull"]) == 0 + assert (shared / "nodes" / "hyp-m.md").exists() + + commit_node(clone, "hyp-bad.md", "---\nid: hyp-bad\ntype: hypothesis\nstatus: alive\n---\n\n# b\n") + monkeypatch.chdir(clone) + assert main(["push"]) == 1 + assert "live-claims-must-cite-their-gates" in capsys.readouterr().err + + +def test_join_with_a_read_invite_can_pull_but_not_push(hub, shared, tmp_path, monkeypatch, capsys): + main(["invite", "reader", "--role", "read"]) + code = capsys.readouterr().out.strip().split()[-1] + monkeypatch.chdir(tmp_path) + main(["join", f"{hub.url}/trading", "--invite", code, "--dest", "r"]) + clone = tmp_path / "r" + git("config", "user.email", "r@r.r", cwd=clone); git("config", "user.name", "r", cwd=clone) + commit_node(clone, "hyp-r.md", "---\nid: hyp-r\ntype: hypothesis\nstatus: open\n---\n\n# r\n") + monkeypatch.chdir(clone) + + assert main(["push"]) == 1 + assert "read access, not write" in capsys.readouterr().err + assert main(["pull"]) == 0 + + +def test_revoke_locks_a_contributor_out_on_their_next_push(hub, shared, tmp_path, monkeypatch, capsys): + main(["invite", "maria"]) + code = capsys.readouterr().out.strip().split()[-1] + admin = cred_lookup(f"{hub.url}/trading.git") # admin's own token, before maria's join overwrites it + + monkeypatch.chdir(tmp_path) + main(["join", f"{hub.url}/trading", "--invite", code]) + clone = tmp_path / "trading" + git("config", "user.email", "m@m.m", cwd=clone); git("config", "user.name", "maria", cwd=clone) + maria = cred_lookup(f"{hub.url}/trading.git") + + # The credential store is one machine's, keyed by remote URL: admin and maria are on + # separate machines in reality, each with their own store for this same URL. Restore + # each in turn to simulate that, since the test runs both in one shared file. + cred_store(f"{hub.url}/trading.git", *admin) + monkeypatch.chdir(shared) + assert main(["revoke", "maria"]) == 0 + + cred_store(f"{hub.url}/trading.git", *maria) + commit_node(clone, "hyp-m.md", "---\nid: hyp-m\ntype: hypothesis\nstatus: open\n---\n\n# m\n") + monkeypatch.chdir(clone) + assert main(["push"]) == 1 + assert "credentials refused" in capsys.readouterr().err + + +def test_only_an_admin_can_invite(hub, shared, tmp_path, monkeypatch, capsys): + main(["invite", "maria"]) + code = capsys.readouterr().out.strip().split()[-1] + monkeypatch.chdir(tmp_path) + main(["join", f"{hub.url}/trading", "--invite", code]) + monkeypatch.chdir(tmp_path / "trading") + + assert main(["invite", "friend-of-maria"]) == 1 + assert "only an admin" in capsys.readouterr().err + + +def test_a_spent_or_wrong_code_is_one_readable_line(hub, shared, tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + + assert main(["join", f"{hub.url}/trading", "--invite", "nope"]) == 1 + err = capsys.readouterr().err + assert "not valid" in err and "Traceback" not in err + assert not (tmp_path / "trading").exists() + + +def test_a_clone_failure_after_redemption_says_the_invite_is_spent(hub, shared, tmp_path, monkeypatch, capsys): + """The server consumes the code before git clone runs. When the clone then failed, + the user saw only git's error, retried the same code, and was refused for a reason + that looked unrelated. Now the message says the credentials are saved and how to + finish by hand.""" + main(["invite", "maria"]) + code = capsys.readouterr().out.strip().split()[-1] + blocker = tmp_path / "taken" + (blocker / "not-empty").mkdir(parents=True) # git refuses a non-empty dest + monkeypatch.chdir(tmp_path) + + assert main(["join", f"{hub.url}/trading", "--invite", code, "--dest", "taken"]) == 1 + err = capsys.readouterr().err + assert "invite is spent" in err and "credentials are saved" in err + assert "Traceback" not in err + assert cred_lookup(f"{hub.url}/trading.git")[0] == "maria" + diff --git a/tests/test_serve.py b/tests/test_serve.py new file mode 100644 index 0000000..ba15bd8 --- /dev/null +++ b/tests/test_serve.py @@ -0,0 +1,418 @@ +"""knoten's server does one thing per request: decide whether this token may do this, +then hand the request to `git http-backend`, which ships with git and speaks the smart +HTTP protocol. Everything hard (packfiles, refs, negotiation, hooks) is git's. These +tests therefore drive real git clients at a real server. +""" +import base64 +import http.client +import json +import os +import subprocess +import urllib.error +import urllib.request + +import pytest + +ALIVE_NO_GATE = "---\nid: hyp-x\ntype: hypothesis\nstatus: alive\n---\n\n# x\n" + + +def git(*args, cwd, env=None): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, + env={**os.environ, **(env or {})}) + + +def clone_url(hub, graph, user, token): + return f"http://{user}:{token}@{hub.url.removeprefix('http://')}/{graph}.git" + + +def commit_node(work, name, text): + (work / "nodes" / name).write_text(text, encoding="utf-8") + git("add", "-A", cwd=work) + git("commit", "-qm", name, cwd=work) + + +def auth_refused(r): + """git phrases a 401 three ways: `401` when it cannot retry, `Authentication failed` + when the credentials it sent were rejected, `could not read Username` when it had + none and prompts are off. All three are the server saying no.""" + return r.returncode != 0 and any( + s in r.stderr for s in ("401", "Authentication failed", "Username")) + + +@pytest.fixture +def trading(hub, local_graph): + """A graph on the server with its admin's token, pushed once from `local_graph`.""" + admin = hub.registry.create("trading", admin="seb") + git("remote", "add", "origin", clone_url(hub, "trading", "seb", admin), cwd=local_graph) + r = git("push", "-q", "origin", "master", cwd=local_graph) + assert r.returncode == 0, r.stderr + return {"admin": admin, "work": local_graph} + + +# ---------------------------------------------------------------- reading and writing + +def test_an_admin_can_push_and_the_server_holds_it(hub, trading): + repo = hub.registry.repo("trading") + assert "seed" in git("log", "--oneline", cwd=repo).stdout + + +def test_a_read_token_can_clone_but_not_push(hub, trading, tmp_path): + """git's own protocol separates reading from writing by endpoint. Read access is + never getting past the door for git-receive-pack, nothing subtler.""" + tok = hub.registry.mint("trading", "reader", "read") + dest = tmp_path / "reader-clone" + + r = git("clone", "-q", clone_url(hub, "trading", "reader", tok), str(dest), cwd=tmp_path) + assert r.returncode == 0, r.stderr + + git("config", "user.email", "r@r.r", cwd=dest); git("config", "user.name", "r", cwd=dest) + commit_node(dest, "hyp-r.md", "---\nid: hyp-r\ntype: hypothesis\nstatus: open\n---\n\n# r\n") + r = git("push", "origin", "master", cwd=dest) + + assert r.returncode != 0 + assert "403" in r.stderr + assert "hyp-r" not in git("log", "--oneline", cwd=hub.registry.repo("trading")).stdout + + +def test_no_token_is_a_401_challenge(hub, trading, tmp_path): + r = git("clone", "-q", f"{hub.url}/trading.git", str(tmp_path / "x"), cwd=tmp_path) + + assert auth_refused(r), r.stderr + + +def test_a_wrong_token_is_refused(hub, trading, tmp_path): + r = git("clone", "-q", clone_url(hub, "trading", "seb", "wrong"), str(tmp_path / "x"), + cwd=tmp_path) + + assert auth_refused(r), r.stderr + + +def test_an_unknown_graph_looks_like_a_wrong_token(hub, trading, tmp_path): + """The server must not confirm which graphs exist to someone without a token.""" + r = git("clone", "-q", clone_url(hub, "biology", "seb", trading["admin"]), + str(tmp_path / "x"), cwd=tmp_path) + + assert auth_refused(r), r.stderr + + +# ---------------------------------------------------------------- the gate, over HTTP + +def test_the_rule_gate_refuses_a_broken_push_over_http(hub, trading): + """PR #32's pre-receive hook, unchanged, reached through git-http-backend. The + rule's own message must reach the pusher.""" + work = trading["work"] + commit_node(work, "hyp-x.md", ALIVE_NO_GATE) + + r = git("push", "origin", "master", cwd=work) + + assert r.returncode != 0 + assert "live-claims-must-cite-their-gates" in r.stderr + assert "hyp-x" not in git("log", "--oneline", cwd=hub.registry.repo("trading")).stdout + + +def test_a_clean_push_after_a_refused_one_lands(hub, trading): + work = trading["work"] + commit_node(work, "hyp-x.md", ALIVE_NO_GATE) + git("push", "origin", "master", cwd=work) + git("reset", "-q", "--hard", "HEAD~1", cwd=work) + commit_node(work, "hyp-y.md", "---\nid: hyp-y\ntype: hypothesis\nstatus: open\n---\n\n# y\n") + + r = git("push", "origin", "master", cwd=work) + + assert r.returncode == 0, r.stderr + assert "hyp-y" in git("log", "--oneline", cwd=hub.registry.repo("trading")).stdout + + +def test_a_second_clone_sees_what_the_first_pushed(hub, trading, tmp_path): + tok = hub.registry.mint("trading", "maria", "write") + dest = tmp_path / "maria" + git("clone", "-q", clone_url(hub, "trading", "maria", tok), str(dest), cwd=tmp_path) + + assert (dest / "nodes" / "hyp-ok.md").exists() + + +def test_a_crashed_backend_is_a_500_not_a_silent_200(hub, trading, monkeypatch): + """A refusal by the gate arrives in the sideband with exit 0, so a non-zero exit + with no Status line can only be the backend dying. That used to relay as 200 with + an empty body. Real git cannot be made to crash headerless on demand, which is why + this one test fakes the subprocess: it is testing the relay, not git.""" + import subprocess as sp + from knoten import serve as serve_mod + + def dead(*args, **kwargs): + return sp.CompletedProcess(args, 128, stdout=b"", stderr=b"fatal: boom") + monkeypatch.setattr(serve_mod.subprocess, "run", dead) + + req = urllib.request.Request( + f"{hub.url}/trading.git/info/refs?service=git-upload-pack", + headers={"Authorization": "Basic " + base64.b64encode( + f"seb:{trading['admin']}".encode()).decode()}) + with pytest.raises(urllib.error.HTTPError) as e: + urllib.request.urlopen(req) + + assert e.value.code == 500 + assert "http-backend failed" in json.loads(e.value.read())["error"] + + +def test_a_chunked_push_is_refused_not_dumped_as_a_bare_500(hub, trading): + """_body only ever reads Content-Length bytes. A chunked push (git goes chunked + above http.postBuffer, default 1 MiB) used to hand http-backend an empty stdin, + which died, leaving the client with an inscrutable 500 and no idea why.""" + host, port = hub.url.removeprefix("http://").rsplit(":", 1) + cred = base64.b64encode(f"seb:{trading['admin']}".encode()).decode() + conn = http.client.HTTPConnection(host, int(port), timeout=5) + conn.putrequest("POST", "/trading.git/git-receive-pack") + conn.putheader("Content-Type", "application/x-git-receive-pack-request") + conn.putheader("Authorization", "Basic " + cred) + conn.putheader("Transfer-Encoding", "chunked") + conn.endheaders() + conn.send(b"0\r\n\r\n") + r = conn.getresponse() + body = json.loads(r.read()) + conn.close() + + assert r.status == 411, body + assert "http.postBuffer" in body["error"] + + +# ---------------------------------------------------------------- the api + +def api(hub, path, body, auth=None): + """Raw urllib, deliberately not the knoten client: the server is being tested.""" + req = urllib.request.Request(hub.url + path, data=json.dumps(body).encode(), + method="POST", headers={"Content-Type": "application/json"}) + if auth: + cred = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode() + req.add_header("Authorization", "Basic " + cred) + try: + with urllib.request.urlopen(req) as r: + return r.status, json.loads(r.read()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read()) + + +def test_the_owner_secret_creates_a_graph_and_gets_the_admin_token(hub): + status, body = api(hub, "/graphs", {"name": "biology", "admin": "seb"}, ("owner", hub.secret)) + + assert status == 201 + assert hub.registry.authenticate("biology", "seb", body["token"]) == "admin" + assert (hub.registry.repo("biology") / "hooks" / "pre-receive").exists() + + +def test_creating_a_graph_without_the_owner_secret_is_refused(hub): + status, body = api(hub, "/graphs", {"name": "biology", "admin": "seb"}, ("owner", "nope")) + assert status == 401 + assert not hub.registry.exists("biology") + + status, _ = api(hub, "/graphs", {"name": "biology", "admin": "seb"}) + assert status == 401 + + +def test_a_bad_graph_name_is_a_400_and_creates_nothing(hub): + status, body = api(hub, "/graphs", {"name": "../etc", "admin": "seb"}, ("owner", hub.secret)) + + assert status == 400 + assert "not a valid graph name" in body["error"] + assert list((hub.data / "graphs").iterdir()) == [] + + +def test_an_admin_can_invite_and_the_invitee_can_join(hub, trading): + status, body = api(hub, "/trading/invite", {"name": "maria", "role": "write", "days": 7}, + ("seb", trading["admin"])) + assert status == 200, body + + status, joined = api(hub, "/trading/join", {"code": body["code"]}) + + assert status == 200, joined + assert joined["name"] == "maria" and joined["role"] == "write" + assert hub.registry.authenticate("trading", "maria", joined["token"]) == "write" + + +def test_a_write_token_cannot_invite(hub, trading): + tok = hub.registry.mint("trading", "maria", "write") + + status, body = api(hub, "/trading/invite", {"name": "x", "role": "write"}, ("maria", tok)) + + assert status == 403 + assert "only an admin" in body["error"] + + +def test_joining_twice_with_one_code_fails_the_second_time(hub, trading): + _, inv = api(hub, "/trading/invite", {"name": "maria", "role": "read"}, ("seb", trading["admin"])) + api(hub, "/trading/join", {"code": inv["code"]}) + + status, body = api(hub, "/trading/join", {"code": inv["code"]}) + + assert status == 400 + assert "not valid" in body["error"] + + +def test_join_on_an_unknown_graph_looks_like_a_wrong_code(hub, trading): + """/join needs no credentials, so it must not confirm which graphs exist: a + nonexistent graph and a wrong code get byte-identical 400 bodies.""" + status_unknown, body_unknown = api(hub, "/biology/join", {"code": "x"}, None) + status_known, body_known = api(hub, "/trading/join", {"code": "x"}, None) + + assert status_unknown == 400 and status_known == 400 + assert body_unknown == body_known + + +def test_revoke_ends_a_contributors_access(hub, trading, tmp_path): + tok = hub.registry.mint("trading", "maria", "write") + + status, _ = api(hub, "/trading/revoke", {"name": "maria"}, ("seb", trading["admin"])) + assert status == 200 + + r = git("clone", "-q", clone_url(hub, "trading", "maria", tok), str(tmp_path / "x"), cwd=tmp_path) + assert auth_refused(r), r.stderr + + +def test_malformed_json_is_a_400_not_a_traceback(hub, trading): + req = urllib.request.Request(hub.url + "/trading/join", data=b"{not json", + method="POST", headers={"Content-Type": "application/json"}) + with pytest.raises(urllib.error.HTTPError) as e: + urllib.request.urlopen(req) + + assert e.value.code == 400 + assert "not JSON" in json.loads(e.value.read())["error"] + + +def test_a_json_body_that_is_not_an_object_is_a_400(hub, trading): + """`[1, 2, 3]` parses as JSON and then crashed the thread on `.get`. The client saw + a closed connection, not a refusal.""" + status, body = api(hub, "/trading/invite", [1, 2, 3], ("seb", trading["admin"])) + assert status == 400 + assert "JSON object" in body["error"] + + +def test_a_malformed_or_negative_content_length_is_a_400_not_a_hang(hub, trading): + """"Content-Length: abc" used to raise ValueError inside _body, unguarded, killing + the thread with no response. "Content-Length: -1" used to reach rfile.read(-1), + which reads until the socket closes -- on a connection the client never closes, + a thread parked forever with no credentials required to trigger it.""" + host, port = hub.url.removeprefix("http://").rsplit(":", 1) + for bad in ("abc", "-1"): + conn = http.client.HTTPConnection(host, int(port), timeout=5) + conn.putrequest("POST", "/trading/join") + conn.putheader("Content-Type", "application/json") + conn.putheader("Content-Length", bad) + conn.endheaders() + r = conn.getresponse() + body = json.loads(r.read()) + conn.close() + + assert r.status == 400, (bad, body) + assert "invalid" in body["error"] + + +def test_a_non_numeric_days_is_a_400(hub, trading): + status, body = api(hub, "/trading/invite", + {"name": "maria", "role": "write", "days": "banana"}, ("seb", trading["admin"])) + assert status == 400 + assert "whole number" in body["error"] + + +def test_unknown_paths_are_404(hub): + status, _ = api(hub, "/trading/steal", {}) + assert status == 404 + + +# ---------------------------------------------------------------- the cli + +def test_serve_prints_the_owner_secret_exactly_once(tmp_path, monkeypatch, capsys): + """Shown on first run, when the directory is created, and never again: the secret + on a terminal scrollback is one thing, in every restart's log is another.""" + from http.server import ThreadingHTTPServer + from knoten.cli import main + + monkeypatch.setattr(ThreadingHTTPServer, "serve_forever", lambda self: None) + + assert main(["serve", "--data", str(tmp_path / "d"), "--bind", "127.0.0.1:0"]) == 0 + first = capsys.readouterr().out + assert main(["serve", "--data", str(tmp_path / "d"), "--bind", "127.0.0.1:0"]) == 0 + second = capsys.readouterr().out + + secret = (tmp_path / "d" / "owner").read_text().strip() + assert secret in first + assert secret not in second + assert "serving" in second + + +def test_serve_warns_when_bound_to_a_non_local_address(tmp_path, monkeypatch, capsys): + from http.server import ThreadingHTTPServer + from knoten.cli import main + + monkeypatch.setattr(ThreadingHTTPServer, "serve_forever", lambda self: None) + main(["serve", "--data", str(tmp_path / "d"), "--bind", "0.0.0.0:0"]) + + assert "plain HTTP" in capsys.readouterr().err + + +def test_serve_refuses_a_non_numeric_port_before_creating_the_owner_secret(tmp_path, capsys): + """The secret used to be written before the bind was parsed, so a typo in --bind + created it, crashed, and never showed it; every later run then saw an existing + file and stayed silent. Fail before writing anything.""" + from knoten.cli import main + + assert main(["serve", "--data", str(tmp_path / "d"), "--bind", "127.0.0.1:abc"]) == 1 + err = capsys.readouterr().err + assert "numeric port" in err and "Traceback" not in err + assert not (tmp_path / "d" / "owner").exists() + + +# ---------------------------------------------------------------- isolation and races + +def test_two_graphs_on_one_server_do_not_share_tokens(hub, trading, tmp_path): + """A token for `trading` opens nothing on `biology`, including reading. One server, + many graphs, no cross-talk, or the invite model means nothing.""" + hub.registry.create("biology", admin="seb") + + r = git("clone", "-q", clone_url(hub, "biology", "seb", trading["admin"]), + str(tmp_path / "x"), cwd=tmp_path) + + assert auth_refused(r), r.stderr + + +def test_concurrent_joins_do_not_lose_each_other(hub, trading): + """Eight invites redeemed at the same moment. A read-modify-write on tokens.json + without the lock drops some of them, silently, and the file still parses.""" + import concurrent.futures + + codes = [hub.registry.invite("trading", f"user-{i}", "write") for i in range(8)] + + def redeem(code): + _, body = api(hub, "/trading/join", {"code": code}) + return body["name"], body["token"] + + with concurrent.futures.ThreadPoolExecutor(8) as pool: + results = list(pool.map(redeem, codes)) + + for name, token in results: + assert hub.registry.authenticate("trading", name, token) == "write", f"{name} lost" + on_disk = json.loads((hub.registry.graph_dir("trading") / "tokens.json").read_text()) + assert len(on_disk) == 9 # seb + eight + + +def test_a_traversal_in_the_url_is_404_not_a_file(hub, trading): + """`/../../etc.git` must never reach GIT_PROJECT_ROOT. The regex refuses it before + the registry sees it; this pins that the regex stays strict.""" + req = urllib.request.Request(hub.url + "/../../etc.git/info/refs?service=git-upload-pack") + with pytest.raises(urllib.error.HTTPError) as e: + urllib.request.urlopen(req) + + assert e.value.code in (401, 404) + e.value.close() # unread, it leaves the socket for the GC to warn about later + + +def test_serve_closes_its_socket_when_it_stops(tmp_path, monkeypatch): + """`serve_forever` returning is not the socket closing. Left open, the port stays + bound until the interpreter exits.""" + import warnings + from http.server import ThreadingHTTPServer + from knoten.cli import main + + monkeypatch.setattr(ThreadingHTTPServer, "serve_forever", lambda self: None) + with warnings.catch_warnings(): + warnings.simplefilter("error", ResourceWarning) + assert main(["serve", "--data", str(tmp_path / "d"), "--bind", "127.0.0.1:0"]) == 0 + import gc; gc.collect()