#!/usr/bin/env python3 """Refuse a stdout line that prints a raw DID at a person. A DID is an identifier and not a name. It is the right thing to send a service and the wrong thing to show somebody: two cannot be told apart at a glance, neither can be typed back into any command, and a reader who has never been told that an account *has* a DID meets the concept for the first time in a line of output. atgc has been fixing these one sweep at a time and keeps finding more, always the same way — somebody reads the output during unrelated work and notices. The listings were fixed by linking the shared label code; the write verbs were missed by that sweep precisely because they never built a label, they formatted the DID straight into the line. This is that sweep made mechanical. **Not a type check, and it does not pretend to be one.** It matches on the *name* of the interpolated expression, so it is defeated by an alias (`let a = did; println!("{a}")`) and it cannot see through a function that returns a DID. What it does catch is the form every one of these bugs has actually taken. The type-level version is a newtype with no `Display`; see plan/output.md for why that is a larger change than it looks. `--json` output is exempt by construction: this reads `println!` and `print!`, and JSON leaves through `jsonout::emit`. A DID in a `--json` field is the right answer. """ import re import sys from pathlib import Path # What counts as a raw identifier, and the distinction the whole check turns # on: **a struct field holds what the record holds; a local holds what the # code computed.** `issue.author` is a DID off a record and always was; # `author` is nearly always a label somebody already resolved, and flagging # it produced five false positives for every true one. So a field access # ending in an identifier name is suspect, and a bare local is suspect only # when its name says outright that it is a DID. SUSPECT = re.compile(r"\.(did|author|owner|actor|acting|[a-z_]*_did)$|^([a-z_]*_)?did$") # Anything that turns one into something a person can read. NAMED = re.compile( r"hyperlink::|account\(|linked\(\)|\.label\(\)|named\(|_label\b|\.display\(\)|handle\(" ) MACROS = re.compile(r"\b(println|print)!\s*\(") def calls(text): """Every `println!`/`print!` invocation, as (offset, body).""" for m in MACROS.search(text) and MACROS.finditer(text) or []: start = m.end() depth, i = 1, start while i < len(text) and depth: if text[i] == '(': depth += 1 elif text[i] == ')': depth -= 1 i += 1 yield m.start(), text[start : i - 1] def interpolations(body): """The expressions this call interpolates: inline `{name}` captures and the arguments after the format string.""" fmt = re.match(r'\s*"((?:[^"\\]|\\.)*)"', body) if not fmt: return [] names = [n for n in re.findall(r"\{([a-zA-Z_][\w.]*)[^}]*\}", fmt.group(1))] rest = body[fmt.end() :].lstrip().lstrip(",") # Arguments split on top-level commas. args, depth, cur = [], 0, "" for ch in rest: if ch in "([{": depth += 1 elif ch in ")]}": depth -= 1 if ch == "," and depth == 0: args.append(cur) cur = "" else: cur += ch args.append(cur) return names + [a.strip() for a in args if a.strip()] # A line may say out loud that its DID is deliberate. Written next to the # code rather than kept in a list here, so the reason is in front of whoever # is reading the line and so deleting the line deletes its exception. ALLOW = re.compile(r"raw-did-ok:") def allowed_above(text, offset): """Whether the contiguous comment block above `offset` carries the marker.""" for line in reversed(text[:offset].split("\n")[:-1]): stripped = line.strip() if not stripped: continue if not stripped.startswith("//"): return False if ALLOW.search(stripped): return True return False def main(): root = Path(__file__).resolve().parent.parent bad = [] for path in sorted((root / "src" / "cmd").rglob("*.rs")): text = path.read_text(encoding="utf-8") for offset, body in calls(text): line = text.count("\n", 0, offset) + 1 # The marker may sit on the call, or anywhere in the comment # block immediately above it — walked rather than windowed, # because an exception worth writing is usually worth several # lines and a fixed window silently stops honouring the long ones. if ALLOW.search(body) or allowed_above(text, offset): continue for expr in interpolations(body): if NAMED.search(expr): continue if SUSPECT.search(expr.strip().lstrip("&")): bad.append((path.relative_to(root), line, expr.strip())) for path, line, expr in bad: print(f"{path}:{line}: prints `{expr}` at a person, which is a raw DID") if bad: print() print( "A DID is for a service; a person gets `@handle`. Resolve it with\n" "`crate::clients::atproto::handles::handle(did).await` and name it with\n" "`crate::term::hyperlink::account(handle, did)`, which links the handle\n" "and falls back to the DID only when none resolves." ) return 1 if bad else 0 if __name__ == "__main__": sys.exit(main())