Skip to content

Commit ae9cda0

Browse files
HarperZ9claude
andauthored
Draw what a shared map carries, and close two redaction gaps it found (#37)
The README says a map is private by default and then leaves a reader to take that on trust. This adds the drawing that says it instead: seven things git or the filesystem hands index, and what each one becomes in the written file. One row is marked, and it is the setting that puts real absolute paths in the map. Writing it meant reading sanitize_credentials closely, and two origins went through it untouched. A password in an ssh URL survived, because the userinfo rule was bound to http and https. A parameter named access_token survived, because a word boundary cannot match between an underscore and a letter, so a pattern anchored on one sees token and misses every prefixed spelling of it. Both sit under a module docstring that calls the redaction always-on. Drawing the gap would have advertised it, so the code is fixed and ten new cases hold it: three password-carrying schemes, ten parameter names bare and prefixed, one ssh username that stays because a username is not a secret, and six ordinary remotes that must come back byte for byte. Reverting the module turns six of them red. The value class after a credential name still runs to the end of the query. Stopping it at an ampersand would uncover the next parameter, which may be a secret whose name this pattern does not know, so the greedy read is the safe one and the reason is now in the code. The card gate measures every row against its column with the renderer's own width table, and it checks two failures rather than one. A note that loses its ending is the obvious case. A single token longer than the column is the quiet one: greedy wrapping leaves it alone on its line, the joined text still equals the source, and the drawing runs off the page with every check green. Both are proved failable by a control card. Measured against the real Hanken Grotesk face as well as the renderer's table: 37 text elements, widest right edge 905.8 real and 904.2 by table, against a rule at 916. Co-authored-by: Zain Dana Harper <17142659+HarperZ9@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent a3babae commit ae9cda0

8 files changed

Lines changed: 392 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ Everything works with zero configuration. An optional `.index.toml` at the works
161161
- Self-contained and safe with untrusted docs: one HTML file, no external URLs, markdown escaped as it renders, with hostile-content fixtures in the tests.
162162
- Private by default: paths are root-relative, the local root reduces to a short hash, and credential-shaped fragments in remote URLs are redacted.
163163

164+
<p align="center"><img src="docs/art/what-leaves.svg" alt="Seven things git or the filesystem hands index, and what each one becomes in the written map. A password in a remote URL is replaced whole, a credential-shaped query parameter keeps its name and loses its value, and an ssh username is left alone because it is not a secret. Paths are measured from the scan root, a repository outside that root reduces to its own directory name, and the root itself reduces to sixteen hex characters. The marked row is the one setting that puts real absolute paths in the file, which the map then records against itself." width="100%"></p>
165+
164166
## Status
165167

166168
`index-graph` 2.9.0 on PyPI, command `index`, Python 3.11+, Development Status Beta. It is used as the workspace map layer of [Project Telos](https://harperz9.github.io), alongside [gather](https://github.com/HarperZ9/gather), [crucible](https://github.com/HarperZ9/crucible), [forum](https://github.com/HarperZ9/forum), and [telos](https://github.com/HarperZ9/telos).

docs/art/index.art.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,25 @@
3232
{"label": "UNVERIFIABLE", "note": "an endpoint sits outside the scanned workspace", "tone": "none"}
3333
]
3434
}
35+
],
36+
"cards": [
37+
{
38+
"file": "what-leaves.svg",
39+
"kicker": "every origin and every path goes through this",
40+
"title": "What a shared map carries about the machine that made it",
41+
"source": "python -c \"import index_graph.gitmeta as g; print(g.sanitize_credentials.__doc__)\"",
42+
"alt": "Seven things git or the filesystem hands index, and what each one becomes in the written map. A password in a remote URL is replaced whole, a credential-shaped query parameter keeps its name and loses its value, and an ssh username is left alone because it is not a secret. Paths are measured from the scan root, a repository outside that root reduces to its own directory name, and the root itself reduces to sixteen hex characters. The marked row is the one setting that puts real absolute paths in the file, which the map then records against itself.",
43+
"footnote": "A map is written to be read somewhere else, so an origin is rewritten before it reaches a row and a path is measured from the scan root. Redaction works by pattern, which covers the parameter names listed here and cannot cover one it has never seen. The marked row is the setting that puts real paths in the file, and a map made that way says so in absolute_paths_included.",
44+
"heads": ["what index is handed", "what the map carries", "why it lands that way"],
45+
"fields": [
46+
{"key": "a password in a remote", "value": "scheme, host, path", "note": "The userinfo goes as a unit, under any scheme, once a colon says a password came with the name."},
47+
{"key": "a credential parameter", "value": "name kept, value gone", "note": "token, password, secret and api_key, bare or prefixed. The rest of the query goes with it."},
48+
{"key": "ssh://git@host", "value": "left as it is", "note": "A username is not a secret, and blanking it costs a reader how the clone was set up."},
49+
{"key": "a repo under the root", "value": "path relative to it", "note": "Forward slashes on every platform, and the root itself reads as a single dot."},
50+
{"key": "a repo outside it", "value": "its own name only", "note": "relative_to raises and the fallback is the directory name, so no absolute path arrives by that route."},
51+
{"key": "the scan root", "value": "16 hex characters", "note": "A sha256 prefix. Two maps under one root agree without either naming it."},
52+
{"key": "portable turned off", "value": "absolute paths, flagged", "tone": "drift", "note": "The one setting that writes real paths, recorded in the map as absolute_paths_included."}
53+
]
54+
}
3555
]
3656
}

docs/art/what-leaves.svg

Lines changed: 17 additions & 0 deletions
Loading

scripts/render_repo_art.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from repo_art import header_svg # noqa: E402
2222
from repo_flow import flow_svg # noqa: E402
23+
from repo_card import card_svg # noqa: E402
2324

2425
ART = Path(__file__).resolve().parents[1] / "docs" / "art"
2526

@@ -31,6 +32,8 @@ def rendered(spec_path: Path) -> dict[Path, str]:
3132
out = {spec_path.parent / f"{stem}-header.svg": header_svg(spec["header"])}
3233
for flow in spec.get("flows", []):
3334
out[spec_path.parent / flow["file"]] = flow_svg(flow)
35+
for card in spec.get("cards", []):
36+
out[spec_path.parent / card["file"]] = card_svg(card)
3437
return out
3538

3639

scripts/repo_card.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""repo_card.py -- the artifact a tool hands back, drawn field by field.
2+
3+
Every command here writes a receipt, and until now the README said so in a
4+
sentence. A reader deciding whether to trust the tool wants to see the thing:
5+
what fields come back, which one carries the verdict, and how they would check
6+
each field themselves. This draws that from a spec, so the picture is data in
7+
the repository and a gate can hold it against a receipt the tool actually
8+
emits.
9+
10+
Color still says one thing. Exactly one row carries the verdict and takes the
11+
verified green; a row that reports drift takes the drift iris. Every other row
12+
is ink and a hairline, because a field is structure and structure is not news.
13+
14+
The value column shows a literal only where the literal is stable. A hash or a
15+
byte count changes with the checkout, so those rows carry the shape of the
16+
value instead: how many entries, how many keys. A picture that shows a hash is
17+
a picture that is wrong by the next commit.
18+
"""
19+
from __future__ import annotations
20+
21+
from repo_art import GROTESK, MONO, _esc, _num
22+
23+
W = 960
24+
PAD = 44
25+
ROW_H = 46
26+
TOP = 142
27+
KEY_W = 186
28+
VAL_W = 258
29+
GUTTER = 26
30+
NOTE_X = PAD + KEY_W + GUTTER + VAL_W + GUTTER
31+
NOTE_W = W - PAD - NOTE_X
32+
33+
# The same two palettes the schematics use, so the whole set reads as one hand.
34+
STYLE = """
35+
:root{ --void:#f4f3ef; --bone:#0b0c0e; --muted:#43474e;
36+
--hairline:rgba(11,12,14,.16); --card:rgba(255,255,255,.66);
37+
--verified:#1f7a52; --drift:#3a2bd6; }
38+
@media (prefers-color-scheme: dark){
39+
:root{ --void:#0b0e0f; --bone:#eef1ee; --muted:#9aa39c;
40+
--hairline:rgba(238,241,238,.18); --card:rgba(255,255,255,.05);
41+
--verified:#5fae93; --drift:#a99cf5; } }
42+
.bg{ fill:var(--void); }
43+
.row{ fill:var(--card); stroke:var(--hairline); stroke-width:1.2; }
44+
.key{ fill:var(--bone); font-size:13px; font-weight:650; }
45+
.val{ fill:var(--muted); font-size:12px; }
46+
.s{ fill:var(--muted); font-size:11.5px; }
47+
.k{ fill:var(--muted); font-size:11px; letter-spacing:.16em; }
48+
.h{ fill:var(--bone); font-size:21px; font-weight:700; }
49+
.thin{ stroke:var(--hairline); stroke-width:1.2; fill:none; }
50+
"""
51+
52+
TONE = {"verified": "var(--verified)", "drift": "var(--drift)",
53+
"none": "var(--hairline)"}
54+
55+
# The column heads say what a row of this drawing is. A receipt reads as a
56+
# field and what comes back in it; something else in the repository reads as
57+
# something else, so a spec may name its own three and these are the default.
58+
HEADS = ("field", "what comes back", "how you check it")
59+
60+
# What one character draws in the note and footnote columns, in pixels at
61+
# 11.5px. These are measured off a rendered probe rather than assumed, because
62+
# a budget counted in characters cannot tell an uppercase line from a
63+
# lowercase one: capitals run about a quarter wider, so a row of verdict
64+
# tokens fits a character count and still draws off the edge of the page.
65+
#
66+
# The weights round up. The drawing ships to readers whose machine resolves a
67+
# different face than the one measured, so a line that stops a little short is
68+
# a smaller defect than one that runs past the rule.
69+
UPPER, LOWER, DIGIT, SPACE, NARROW = 7.1, 5.75, 6.3, 3.2, 2.7
70+
_NARROW = frozenset(".,;:'!|")
71+
72+
73+
def _advance(char: str) -> float:
74+
if char == " ":
75+
return SPACE
76+
if char.isupper():
77+
return UPPER
78+
if char.islower():
79+
return LOWER
80+
if char.isdigit():
81+
return DIGIT
82+
return NARROW if char in _NARROW else LOWER
83+
84+
85+
def text_width(text: str) -> float:
86+
"""What a line of note or footnote prose draws, in pixels."""
87+
return sum(_advance(char) for char in text)
88+
89+
90+
# One line of the note column, in pixels. Two lines fit the row.
91+
NOTE_BUDGET = NOTE_W
92+
NOTE_LINES = 2
93+
94+
# The footnote runs the width of the page at the same size, so it holds more.
95+
FOOT_BUDGET = W - PAD * 2
96+
FOOT_LINES = 3
97+
98+
99+
def _wrap(text: str, width: float = NOTE_BUDGET,
100+
limit: int = NOTE_LINES) -> list[str]:
101+
"""Greedy wrap by drawn width, cut to the lines the caller has room for."""
102+
lines: list[str] = []
103+
line = ""
104+
for word in text.split():
105+
candidate = f"{line} {word}".strip()
106+
if text_width(candidate) > width and line:
107+
lines.append(line)
108+
line = word
109+
else:
110+
line = candidate
111+
if line:
112+
lines.append(line)
113+
return lines[:limit]
114+
115+
116+
def _row_y(index: int) -> float:
117+
return TOP + index * ROW_H
118+
119+
120+
def _row(index: int, field: dict) -> str:
121+
"""One field: its name, what comes back in it, and how to check it."""
122+
y = _row_y(index)
123+
tone = TONE[field.get("tone", "none")]
124+
accent = field.get("tone", "none") != "none"
125+
notes = "".join(
126+
f'<text class="s" x="{_num(NOTE_X)}" y="{_num(y + 20 + i * 15)}">'
127+
f"{_esc(line)}</text>"
128+
for i, line in enumerate(_wrap(field["note"])))
129+
rule = (f'<rect x="{_num(PAD)}" y="{_num(y)}" width="3" '
130+
f'height="{ROW_H - 8}" fill="{tone}"/>') if accent else ""
131+
return (f'<g><rect class="row" x="{_num(PAD)}" y="{_num(y)}" '
132+
f'width="{W - PAD * 2}" height="{ROW_H - 8}" rx="3"/>{rule}'
133+
f'<text class="key" x="{_num(PAD + 16)}" y="{_num(y + 24)}" '
134+
f'font-family="{MONO}">{_esc(field["key"])}</text>'
135+
f'<text class="val" x="{_num(PAD + KEY_W + GUTTER)}" '
136+
f'y="{_num(y + 24)}" font-family="{MONO}"'
137+
f'{f" style={chr(34)}fill:{tone}{chr(34)}" if accent else ""}>'
138+
f'{_esc(field["value"])}</text>{notes}</g>')
139+
140+
141+
def _column_heads(labels: tuple[str, str, str] = HEADS) -> str:
142+
columns = (PAD + 16, PAD + KEY_W + GUTTER, NOTE_X)
143+
return "".join(
144+
f'<text class="k" x="{_num(x)}" y="{_num(TOP - 14)}" '
145+
f'font-family="{MONO}">{_esc(label.upper())}</text>'
146+
for label, x in zip(labels, columns))
147+
148+
149+
def _footnote(text: str, top: float) -> str:
150+
return "".join(
151+
f'<text class="s" x="{PAD}" y="{_num(top + i * 16)}">{_esc(line)}</text>'
152+
for i, line in enumerate(_wrap(text, FOOT_BUDGET, FOOT_LINES)))
153+
154+
155+
def card_svg(spec: dict) -> str:
156+
"""A receipt drawn field by field, with the source that produced it."""
157+
fields = spec["fields"]
158+
foot = _wrap(spec["footnote"], FOOT_BUDGET, FOOT_LINES)
159+
rule = _row_y(len(fields)) + 12
160+
height = rule + 22 + len(foot) * 16
161+
rows = "".join(_row(i, f) for i, f in enumerate(fields))
162+
return (
163+
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {_num(height)}" '
164+
f'width="{W}" height="{_num(height)}" font-family="{GROTESK}" role="img" '
165+
f'aria-label="{_esc(spec["alt"])}">'
166+
f"<style>{STYLE}</style>"
167+
f'<rect class="bg" width="{W}" height="{_num(height)}"/>'
168+
f'<text class="k" x="{PAD}" y="40" font-family="{MONO}">'
169+
f'{_esc(spec["kicker"].upper())}</text>'
170+
f'<text class="h" x="{PAD}" y="72">{_esc(spec["title"])}</text>'
171+
f'<text class="s" x="{PAD}" y="94" font-family="{MONO}" '
172+
f'font-size="11.5">$ {_esc(spec["source"])}</text>'
173+
f'{_column_heads(tuple(spec.get("heads", HEADS)))}{rows}'
174+
f'<path class="thin" d="M{PAD} {_num(rule)}H{W - PAD}"/>'
175+
f'{_footnote(spec["footnote"], rule + 22)}'
176+
"</svg>")

src/index_graph/gitmeta.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,34 @@
77
from pathlib import Path
88
from typing import Any
99

10+
# A web remote carries its whole userinfo as a secret. The token often
11+
# sits in the user slot with no password beside it, so the slot goes as a
12+
# unit.
1013
_USERINFO = re.compile(r"(?i)(https?://)[^/@]+@")
11-
_SECRET_QUERY = re.compile(r"(?i)\b(token|password|secret|api[_-]?key)=([^@\s]+)")
14+
# Every other scheme keeps its username, because ssh://git@host names a
15+
# user and nothing more. A colon means a password came with it, and that
16+
# is a secret whatever the scheme says.
17+
_PASSWORD_USERINFO = re.compile(
18+
r"(?i)([a-z][a-z0-9+.-]*://)[^/@\s]*:[^/@\s]*@")
19+
# Parameter names arrive prefixed as often as bare, so access_token has to
20+
# match as readily as token. The value class runs to the end of the query
21+
# on purpose: swallowing a trailing parameter redacts more than was asked
22+
# for, and the alternative is publishing a second secret whose name this
23+
# pattern does not know.
24+
_SECRET_QUERY = re.compile(
25+
r"(?i)(?<![\w-])([\w-]*(?:token|password|secret|api[_-]?key))=([^@\s]+)")
1226

1327

1428
def sanitize_credentials(origin: str) -> str:
29+
"""Strip the parts of a remote URL a reader of the map must not get.
30+
31+
What survives is the scheme, the host and the path, which is what
32+
makes a remote recognisable. Every origin passes through here before
33+
it reaches a row, so a map cannot carry a credential even when the
34+
clone URL held one.
35+
"""
1536
clean = _USERINFO.sub(r"\1<redacted>@", origin)
37+
clean = _PASSWORD_USERINFO.sub(r"\1<redacted>@", clean)
1638
return _SECRET_QUERY.sub(r"\1=<redacted>", clean)
1739

1840

0 commit comments

Comments
 (0)