nandi/frqpublic Fork 0
32dfa6e
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

A handle is scanned, not matched

`std/re` is PCRE, and PCRE is a shared library the machine running the
tests may not have. It did not: the profile commit went green here and
red in CI, on `could not load: libpcre.so(.3|.1|)` — a runtime failure,
so nothing caught it at compile time.

`isHandle` now scans the domain shape directly, which is what
`ircparse` and `clock` already do and for the same reason. The grammar
it accepts is unchanged; the tests say so more thoroughly than before,
since hyphens and empty labels were the regex's business and are now
ours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T08:17:23-07:00 Browse files
32dfa6e parent: 9c1dfe9
modified nim/src/frq/profile.nim +33 -5
@@ -18,7 +18,7 @@
1818 ## The Clojure has a fetch seam here because the two compilers disagreed about
1919 ## HTTP. Nim has one client, so the seam is gone and `fetch` simply asks.
2020
21-import std/[json, re, strutils, tables]
21+import std/[json, strutils, tables]
2222 from std/unicode import runeLen, runeSubStr
2323 import frq/[atproto, trace]
2424
@@ -33,11 +33,39 @@ type
3333
3434 var cache: Table[string, Profile]
3535
36-let handlePattern = re"^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9][A-Za-z0-9-]*)*\.[A-Za-z]{2,}$"
37-
38-proc isHandle*(nick: string): bool =
36+func isHandle*(nick: string): bool =
3937 ## Whether this nick is an AT Protocol handle, and so worth a lookup.
40- nick.len > 0 and nick.match(handlePattern)
38+ ##
39+ ## Domain shape, per the handle grammar: dot-separated labels that begin
40+ ## with a letter or digit and may then carry hyphens, and a final label of
41+ ## letters only. Scanned rather than matched: `std/re` is PCRE, which means
42+ ## a `libpcre.so` the machine running this may not have — and it did not,
43+ ## the first time this shipped. `ircparse` and `clock` scan for the same
44+ ## reason.
45+ if nick.len == 0: return false
46+ var
47+ labels = 0
48+ labelLen = 0
49+ lastAllAlpha = true
50+ for i, c in nick:
51+ if c == '.':
52+ if labelLen == 0: return false # empty label: leading, doubled or trailing dot
53+ labels.inc
54+ labelLen = 0
55+ lastAllAlpha = true
56+ elif c in {'A'..'Z', 'a'..'z'}:
57+ labelLen.inc
58+ elif c in {'0'..'9'}:
59+ labelLen.inc
60+ lastAllAlpha = false
61+ elif c == '-':
62+ if labelLen == 0: return false # a label may not open with a hyphen
63+ labelLen.inc
64+ lastAllAlpha = false
65+ else:
66+ return false
67+ # The last label is the TLD: at least two characters, and all letters.
68+ labels > 0 and labelLen >= 2 and lastAllAlpha
4169
4270 proc actorFor*(did, nick: string): string =
4371 ## The identity to look a profile up by, or "" when there is none.
@@ -18,7 +18,7 @@
18 ## The Clojure has a fetch seam here because the two compilers disagreed about18 ## The Clojure has a fetch seam here because the two compilers disagreed about
19 ## HTTP. Nim has one client, so the seam is gone and `fetch` simply asks.19 ## HTTP. Nim has one client, so the seam is gone and `fetch` simply asks.
20 20
21-import std/[json, re, strutils, tables]21+import std/[json, strutils, tables]
22 from std/unicode import runeLen, runeSubStr22 from std/unicode import runeLen, runeSubStr
23 import frq/[atproto, trace]23 import frq/[atproto, trace]
24 24
@@ -33,11 +33,39 @@ type
33 33
34 var cache: Table[string, Profile]34 var cache: Table[string, Profile]
35 35
36-let handlePattern = re"^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9][A-Za-z0-9-]*)*\.[A-Za-z]{2,}$"36+func isHandle*(nick: string): bool =
37-
38-proc isHandle*(nick: string): bool =
39 ## Whether this nick is an AT Protocol handle, and so worth a lookup.37 ## Whether this nick is an AT Protocol handle, and so worth a lookup.
40- nick.len > 0 and nick.match(handlePattern)38+ ##
39+ ## Domain shape, per the handle grammar: dot-separated labels that begin
40+ ## with a letter or digit and may then carry hyphens, and a final label of
41+ ## letters only. Scanned rather than matched: `std/re` is PCRE, which means
42+ ## a `libpcre.so` the machine running this may not have — and it did not,
43+ ## the first time this shipped. `ircparse` and `clock` scan for the same
44+ ## reason.
45+ if nick.len == 0: return false
46+ var
47+ labels = 0
48+ labelLen = 0
49+ lastAllAlpha = true
50+ for i, c in nick:
51+ if c == '.':
52+ if labelLen == 0: return false # empty label: leading, doubled or trailing dot
53+ labels.inc
54+ labelLen = 0
55+ lastAllAlpha = true
56+ elif c in {'A'..'Z', 'a'..'z'}:
57+ labelLen.inc
58+ elif c in {'0'..'9'}:
59+ labelLen.inc
60+ lastAllAlpha = false
61+ elif c == '-':
62+ if labelLen == 0: return false # a label may not open with a hyphen
63+ labelLen.inc
64+ lastAllAlpha = false
65+ else:
66+ return false
67+ # The last label is the TLD: at least two characters, and all letters.
68+ labels > 0 and labelLen >= 2 and lastAllAlpha
41 69
42 proc actorFor*(did, nick: string): string =70 proc actorFor*(did, nick: string): string =
43 ## The identity to look a profile up by, or "" when there is none.71 ## The identity to look a profile up by, or "" when there is none.
modified nim/tests/tprofile.nim +14 -0
@@ -17,6 +17,20 @@ suite "isHandle":
1717 test "nor is something with no TLD":
1818 check not isHandle("alice.")
1919 check not isHandle("")
20+ test "a hyphen is fine inside a label, not at its start":
21+ check isHandle("my-host.example.com")
22+ check not isHandle("-host.example.com")
23+ check not isHandle("host.-example.com")
24+ test "the TLD is two or more letters, and only letters":
25+ check not isHandle("alice.c")
26+ check not isHandle("alice.c0m")
27+ check not isHandle("alice.com-")
28+ test "no empty labels":
29+ check not isHandle(".alice.com")
30+ check not isHandle("alice..com")
31+ test "nor anything a domain cannot hold":
32+ check not isHandle("alice bob.com")
33+ check not isHandle("alice@bsky.social")
2034
2135 suite "actorFor":
2236 test "a DID wins, because it is the identity itself":
@@ -17,6 +17,20 @@ suite "isHandle":
17 test "nor is something with no TLD":17 test "nor is something with no TLD":
18 check not isHandle("alice.")18 check not isHandle("alice.")
19 check not isHandle("")19 check not isHandle("")
20+ test "a hyphen is fine inside a label, not at its start":
21+ check isHandle("my-host.example.com")
22+ check not isHandle("-host.example.com")
23+ check not isHandle("host.-example.com")
24+ test "the TLD is two or more letters, and only letters":
25+ check not isHandle("alice.c")
26+ check not isHandle("alice.c0m")
27+ check not isHandle("alice.com-")
28+ test "no empty labels":
29+ check not isHandle(".alice.com")
30+ check not isHandle("alice..com")
31+ test "nor anything a domain cannot hold":
32+ check not isHandle("alice bob.com")
33+ check not isHandle("alice@bsky.social")
20 34
21 suite "actorFor":35 suite "actorFor":
22 test "a DID wins, because it is the identity itself":36 test "a DID wins, because it is the identity itself":