zones.gno
40.32 Kb · 1049 lines
1// Package zones is the content model of a curated registry of gno.land
2// networks: what a zone is, what an endpoint on one is, which strings each
3// field accepts, and the curation states both move through.
4//
5// A zone is one network a person can point a node or a wallet at: mainnet, a
6// testnet, a staging chain, somebody's gnodev. An endpoint is one way in: an
7// RPC, a gnoweb, a seed or persistent peer, an indexer, a faucet, an explorer.
8// Anybody may propose a zone, and register endpoints as the holding realm
9// allows; a curator decides which
10// zones are official and which endpoints are verified. Every decision is
11// recorded with who made it and when; a rejection, a retirement, a flag and an
12// edit to an approved zone also require a reason, which the public reads.
13//
14// The package decides nothing about WHO may act. Every write that records a
15// decision takes the acting address and the height as arguments (the two
16// removals take neither), and the realm holding the [Registry]
17// decides whether that address is a curator, the proposer, or nobody. That is
18// the split that lets the same model move under a different authority later (a
19// DAO, a system realm) without a line changing here.
20//
21// Live registry: r/moul/zones.
22package zones
23
24import (
25 "errors"
26 "strconv"
27 "strings"
28 "unicode"
29 "unicode/utf8"
30)
31
32// Status is where a zone stands in curation.
33type Status string
34
35const (
36 // Pending is a proposal nobody has reviewed yet. Every zone starts here.
37 Pending Status = "pending"
38 // Approved is an official zone: the one state a reader should trust.
39 Approved Status = "approved"
40 // Rejected is a proposal a curator turned down, with the reason kept.
41 Rejected Status = "rejected"
42 // Retired is a zone that was official and no longer runs. It stays
43 // listed, because a node operator holding its chain id deserves to find
44 // out why nothing answers, until MaxRetired newer retirements push it out.
45 Retired Status = "retired"
46)
47
48// Kind says what sort of network a zone is.
49type Kind string
50
51const (
52 Mainnet Kind = "mainnet"
53 Testnet Kind = "testnet"
54 Devnet Kind = "devnet"
55 Local Kind = "local"
56)
57
58// EndpointKind says what an endpoint is for, and therefore which address
59// shape it accepts.
60type EndpointKind string
61
62const (
63 RPC EndpointKind = "rpc" // a tm2 JSON-RPC: http(s) and tcp for gnokey, ws(s) for a subscriber
64 Gnoweb EndpointKind = "gnoweb" // a gnoweb frontend
65 Seed EndpointKind = "seed" // a p2p seed, for p2p.seeds
66 Peer EndpointKind = "peer" // a p2p node, for p2p.persistent_peers
67 Indexer EndpointKind = "indexer" // a tx-indexer GraphQL endpoint
68 Faucet EndpointKind = "faucet" // a faucet page or API
69 Explorer EndpointKind = "explorer" // a block explorer
70)
71
72// Verification is a curator's verdict on an endpoint.
73type Verification string
74
75const (
76 // Unverified is every endpoint until a curator looks at it. Listed, and
77 // labelled as such, never hidden: an unverified RPC is still an RPC.
78 Unverified Verification = "unverified"
79 // Verified means a curator checked it answers for this zone.
80 Verified Verification = "verified"
81 // Flagged means a curator says do not use it, and says why.
82 Flagged Verification = "flagged"
83)
84
85// Statuses, Kinds, EndpointKinds and Verifications list every value of each
86// enum in display order, for a Render that wants one section per value.
87func Statuses() []Status { return []Status{Approved, Pending, Rejected, Retired} }
88
89func Kinds() []Kind { return []Kind{Mainnet, Testnet, Devnet, Local} }
90
91func EndpointKinds() []EndpointKind {
92 return []EndpointKind{RPC, Gnoweb, Seed, Peer, Indexer, Faucet, Explorer}
93}
94
95func Verifications() []Verification { return []Verification{Verified, Unverified, Flagged} }
96
97// Bounds on every caller-supplied string. A bound rather than none: every
98// stored byte locks a storage deposit, and an unbounded field is a bill a
99// stranger chooses the size of.
100const (
101 MinSlugLen = 2
102 MaxSlugLen = 32
103 MaxChainIDLen = 50 // tm2's own limit on a chain id
104 MaxTitleLen = 64
105 MaxDescriptionLen = 512
106 MaxURLLen = 256
107 MaxLabelLen = 64
108 MaxReasonLen = 280
109)
110
111// Info is everything a proposer describes about a zone. It is the part that
112// can be edited; the slug, the status and the history cannot.
113type Info struct {
114 ChainID string // what a node's genesis and a signer's -chainid say
115 Title string
116 Description string
117 Kind Kind
118 GnowebURL string // optional: a local chain may not run one
119 RPCURL string // required: the one endpoint every tool needs
120 GenesisURL string // optional: where to download genesis.json
121}
122
123// Zone is a network, as the registry holds it.
124//
125// Flat on purpose: every field is a scalar. A nested struct inside a persisted
126// object is stored as an object of its own, and `gnokey query vm/qeval` prints
127// it as an opaque ref(...) instead of its fields, so a reader asking a node for
128// a zone would get the slug and nothing they came for. [Zone.Info] gives the
129// editable part back as one value.
130type Zone struct {
131 Slug string // the key: [a-z0-9-], stable, and what a URL carries
132 ChainID string
133 Title string
134 Description string
135 Kind Kind
136 GnowebURL string
137 RPCURL string
138 GenesisURL string
139
140 Status Status
141 Proposer address
142 ProposedAt int64
143
144 // Revision changes on every edit of the zone's Info, on every status
145 // change and on every restated decision, and is never reused,
146 // not even by a zone removed and proposed again under the same slug. A
147 // curator acting on a zone names the revision they read, so an edit that
148 // lands between their reading and their decision makes the decision fail
149 // instead of attaching their name to text they never saw.
150 Revision int64
151 EditedBy address // who last edited the Info; empty if nobody has
152 EditedAt int64
153 Entered int64 // when it entered its current status, in registry order: eviction goes oldest first
154
155 // The latest curator decision, empty until there is one. A curator's edit
156 // to an approved zone is a decision too, and replaces these.
157 ReviewedBy address
158 ReviewedAt int64
159 Reason string
160}
161
162// Info returns the part of the zone a proposer described.
163func (z Zone) Info() Info {
164 return Info{
165 ChainID: z.ChainID,
166 Title: z.Title,
167 Description: z.Description,
168 Kind: z.Kind,
169 GnowebURL: z.GnowebURL,
170 RPCURL: z.RPCURL,
171 GenesisURL: z.GenesisURL,
172 }
173}
174
175// Reviewed reports whether a curator has decided anything about the zone,
176// including an edit made after its first review.
177func (z Zone) Reviewed() bool { return z.ReviewedBy != "" }
178
179func (z *Zone) setInfo(in Info) {
180 z.ChainID = in.ChainID
181 z.Title = in.Title
182 z.Description = in.Description
183 z.Kind = in.Kind
184 z.GnowebURL = in.GnowebURL
185 z.RPCURL = in.RPCURL
186 z.GenesisURL = in.GenesisURL
187}
188
189// Endpoint is one way into a zone, as the registry holds it. Flat for the same
190// reason as [Zone].
191type Endpoint struct {
192 ID int64
193 Zone string // the zone's slug
194 Kind EndpointKind
195 Address string // a URL, or id@host:port for a seed or a peer
196 Label string // who runs it, or what it is, in the registrant's words
197 Registrant address
198 RegisteredAt int64
199
200 Status Verification
201 ReviewedBy address
202 ReviewedAt int64
203 Reason string
204 // Revision is bumped, from the registry-wide counter zones use, when the
205 // endpoint is registered, on every verdict and on every reset. A verdict
206 // and a removal name it, so either fails on an endpoint that changed
207 // after it was read.
208 Revision int64
209 // Exempt marks an endpoint registered through RegisterExempt, by a
210 // reviewer the holder trusts: never clearable as a never-reviewed one,
211 // whoever is a reviewer later.
212 Exempt bool
213}
214
215// Clearable reports whether nobody has ruled on the endpoint and it was not a
216// reviewer's own registration: no verdict (every verdict names its
217// reviewer), no reset (every reset leaves a reason), not Exempt. It is what
218// a bulk clear of a flood may remove.
219func (e Endpoint) Clearable() bool { return !e.Exempt && e.ReviewedBy == "" && e.Reason == "" }
220
221// ParseStatus reads a status from a caller's string. "" is the zero Status,
222// which a filter reads as "any".
223func ParseStatus(s string) (Status, error) {
224 switch st := Status(TrimSpaces(s)); st {
225 case "", Pending, Approved, Rejected, Retired:
226 return st, nil
227 }
228 return "", errors.New("zones: unknown status " + strconv.Quote(s) + ", want approved, pending, rejected or retired")
229}
230
231// ParseKind reads a zone kind. "" is the zero Kind, "any" to a filter.
232func ParseKind(s string) (Kind, error) {
233 switch k := Kind(TrimSpaces(s)); k {
234 case "", Mainnet, Testnet, Devnet, Local:
235 return k, nil
236 }
237 return "", errors.New("zones: unknown kind " + strconv.Quote(s) + ", want mainnet, testnet, devnet or local")
238}
239
240// ParseEndpointKind reads an endpoint kind. "" is "any" to a filter.
241func ParseEndpointKind(s string) (EndpointKind, error) {
242 switch k := EndpointKind(TrimSpaces(s)); k {
243 case "", RPC, Gnoweb, Seed, Peer, Indexer, Faucet, Explorer:
244 return k, nil
245 }
246 return "", errors.New("zones: unknown endpoint kind " + strconv.Quote(s) +
247 ", want rpc, gnoweb, seed, peer, indexer, faucet or explorer")
248}
249
250// ParseVerification reads an endpoint verdict. "" is "any" to a filter.
251func ParseVerification(s string) (Verification, error) {
252 switch v := Verification(TrimSpaces(s)); v {
253 case "", Unverified, Verified, Flagged:
254 return v, nil
255 }
256 return "", errors.New("zones: unknown verification " + strconv.Quote(s) + ", want verified, unverified or flagged")
257}
258
259// ValidateSlug accepts 2 to 32 characters of [a-z0-9-], starting and ending
260// with a letter or a digit.
261//
262// Checked at write time rather than escaped at render time, deliberately: the
263// slug is also an index key and a URL path segment, so one carrying a slash or
264// a pipe would break the link and the table as well as the page.
265func ValidateSlug(s string) error {
266 if len(s) < MinSlugLen || len(s) > MaxSlugLen {
267 return errors.New("zones: a slug is " + strconv.Itoa(MinSlugLen) + " to " +
268 strconv.Itoa(MaxSlugLen) + " characters, got " + strconv.Quote(s))
269 }
270 for i, r := range s {
271 alnum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
272 if alnum || (r == '-' && i > 0 && i < len(s)-1) {
273 continue
274 }
275 return errors.New("zones: a slug is [a-z0-9-] and starts and ends alphanumeric, got " + strconv.Quote(s))
276 }
277 return nil
278}
279
280// ValidateChainID accepts what tm2 accepts for a chain id: 1 to 50
281// characters, here narrowed to [A-Za-z0-9._-] so it is safe raw in a table.
282func ValidateChainID(s string) error {
283 if s == "" || len(s) > MaxChainIDLen {
284 return errors.New("zones: a chain id is 1 to " + strconv.Itoa(MaxChainIDLen) + " characters")
285 }
286 for _, r := range s {
287 switch {
288 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
289 default:
290 return errors.New("zones: a chain id is [A-Za-z0-9._-], got " + strconv.Quote(s))
291 }
292 }
293 return nil
294}
295
296// ValidateInfo checks every field of a zone's description, and returns the
297// first problem it finds.
298func ValidateInfo(in Info) error {
299 if err := ValidateChainID(in.ChainID); err != nil {
300 return err
301 }
302 if err := checkText("title", in.Title, 1, MaxTitleLen); err != nil {
303 return err
304 }
305 if err := checkText("description", in.Description, 0, MaxDescriptionLen); err != nil {
306 return err
307 }
308 // Exact values only: a Kind(" testnet ") stored as written would match no
309 // filter (the Registry trims before it validates; a direct caller must).
310 switch in.Kind {
311 case Mainnet, Testnet, Devnet, Local:
312 case "":
313 return errors.New("zones: a zone needs a kind: mainnet, testnet, devnet or local")
314 default:
315 return errors.New("zones: unknown kind " + strconv.Quote(string(in.Kind)) + ", want mainnet, testnet, devnet or local")
316 }
317 if err := checkURL("rpc url", in.RPCURL, primarySchemes); err != nil {
318 return err
319 }
320 // gnokey's -remote is <scheme>://<host>[:<port>] and reads anything after
321 // :// as the socket address: a path, a query, a fragment, even a lone
322 // trailing slash, prints a command that cannot dial (https://host/ dials
323 // "host/:443"). An rpc ENDPOINT may still carry a path.
324 if _, rest, _ := strings.Cut(in.RPCURL, "://"); strings.IndexAny(rest, "/?#") >= 0 {
325 return errors.New("zones: the rpc url is <scheme>://<host>[:<port>], what gnokey -remote takes, with no path, not even a trailing slash: " +
326 strconv.Quote(in.RPCURL))
327 }
328 for _, u := range [][2]string{{"gnoweb url", in.GnowebURL}, {"genesis url", in.GenesisURL}} {
329 if u[1] == "" {
330 continue
331 }
332 if err := checkURL(u[0], u[1], webSchemes); err != nil {
333 return err
334 }
335 }
336 // A loopback or private address names a different machine for every
337 // reader. Fine for a local zone, which is exactly that; on any other kind
338 // it points a config generator at whatever answers inside the reader's own
339 // network.
340 if in.Kind != Local {
341 for _, u := range []string{in.RPCURL, in.GnowebURL, in.GenesisURL} {
342 if u != "" && IsPrivateHost(HostOf(RPC, u)) {
343 return errors.New("zones: " + strconv.Quote(u) + " names a private or special-use host; only a local zone lists those")
344 }
345 }
346 }
347 return nil
348}
349
350// HostOf returns the host of an endpoint address: the part between :// and
351// the port or path of a URL, or between @ and the port of a peer. It assumes
352// an address that already passed validation.
353func HostOf(kind EndpointKind, addr string) string {
354 if kind == Seed || kind == Peer {
355 _, hostport, _ := strings.Cut(addr, "@")
356 if i := strings.LastIndexByte(hostport, ':'); i >= 0 {
357 return hostport[:i]
358 }
359 return hostport
360 }
361 _, rest, _ := strings.Cut(addr, "://")
362 if i := strings.IndexAny(rest, "/?"); i >= 0 {
363 rest = rest[:i]
364 }
365 if i := strings.LastIndexByte(rest, ':'); i >= 0 {
366 rest = rest[:i]
367 }
368 return rest
369}
370
371// IsPrivateHost reports whether host names a machine that is different for
372// every reader, or no machine at all:
373//
374// - a name with no dot (resolved through the reader's own search domain), or
375// one under a suffix reserved for local or private use, or that public DNS
376// does not delegate: .localhost, .local (mDNS), .internal, .localdomain,
377// .test, .example, .invalid, .lan, .home, .corp, .mail, .intranet,
378// .private, .alt, .onion and .i2p, the service-discovery and container names
379// .consul, .lxd, .incus, .docker, .podman, .localnet and .svc, and every
380// .arpa name, which is infrastructure
381// and never a public service (.home.arpa, ipv4only.arpa,
382// default.service.arpa). Hosts files' localhost4, localhost6,
383// ip6-localhost and ip6-loopback have no dot and fall under the first rule;
384// - an IPv4 address in a loopback, private, link-local, carrier-grade NAT,
385// "this network", IETF-protocol, documentation, benchmarking, 6to4 relay
386// anycast, multicast or reserved range;
387// - anything else shaped like a number but not a valid dotted quad, so an
388// outside caller is not told 127.1 is public (validation refuses it anyway).
389//
390// It fails closed: anything but a bare host ([A-Za-z0-9.-] only) is private
391// to it. It looks at the string only; a public name that resolves to a private
392// address is beyond what a registry can know.
393func IsPrivateHost(host string) bool {
394 // Fail closed: anything but a bare host (a port, a path, a stray
395 // character) is not one this can vouch for.
396 for i := 0; i < len(host); i++ {
397 if c := host[i]; !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '-') {
398 return true
399 }
400 }
401 h := strings.ToLower(strings.TrimSuffix(host, "."))
402 if !strings.Contains(h, ".") {
403 return true
404 }
405 for _, suffix := range []string{".localhost", ".local", ".internal", ".arpa", ".localdomain",
406 ".test", ".example", ".invalid", ".lan", ".home", ".corp", ".mail", ".intranet", ".private", ".onion", ".alt",
407 ".consul", ".lxd", ".incus", ".docker", ".podman", ".localnet", ".svc", ".i2p"} {
408 if strings.HasSuffix(h, suffix) {
409 return true
410 }
411 }
412 labels := strings.Split(h, ".")
413 last := labels[len(labels)-1]
414 numeric := strings.Trim(last, "0123456789") == "" || strings.HasPrefix(last, "0x")
415 if !numeric {
416 return false
417 }
418 if checkIPv4(h) != nil {
419 return true
420 }
421 a, _ := strconv.Atoi(labels[0])
422 b, _ := strconv.Atoi(labels[1])
423 c, _ := strconv.Atoi(labels[2])
424 switch {
425 case a == 0, a == 10, a == 127, a >= 224:
426 return true
427 case a == 169 && b == 254, a == 192 && b == 168:
428 return true
429 case a == 172 && b >= 16 && b <= 31, a == 100 && b >= 64 && b <= 127:
430 return true
431 case a == 192 && b == 0 && c == 0, a == 198 && (b == 18 || b == 19), a == 192 && b == 88 && c == 99:
432 return true
433 case a == 192 && b == 0 && c == 2, a == 198 && b == 51 && c == 100, a == 203 && b == 0 && c == 113:
434 return true // documentation ranges
435 }
436 return false
437}
438
439// ValidateEndpoint checks an endpoint's address against the shape its kind
440// needs: a URL for everything but a seed or a peer, which are id@host:port.
441func ValidateEndpoint(kind EndpointKind, addr string) error {
442 switch kind {
443 case RPC:
444 if err := checkURL("rpc address", addr, rpcSchemes); err != nil {
445 return err
446 }
447 // gnokey dials a tcp:// remote as host:port, path and all, so one with
448 // a path is undialable, and Canonical would read it as an http URL
449 // that is a different resource.
450 if scheme, rest, _ := strings.Cut(addr, "://"); strings.EqualFold(scheme, "tcp") &&
451 strings.IndexAny(rest, "/?") >= 0 {
452 return errors.New("zones: a tcp:// rpc address is host and port only, no path or query")
453 }
454 return nil
455 case Indexer:
456 // A tx-indexer serves GraphQL subscriptions over a websocket too.
457 return checkURL("indexer address", addr, rpcSchemes[:4])
458 case Gnoweb, Faucet, Explorer:
459 return checkURL(string(kind)+" address", addr, webSchemes)
460 case Seed, Peer:
461 return checkPeer(addr)
462 case "":
463 return errors.New("zones: an endpoint needs a kind")
464 }
465 // Exact values only. The Parse functions trim a caller's string, so an
466 // EndpointKind(" rpc ") would parse, be stored as written, and then match
467 // no filter: refused here rather than normalised behind the caller's back.
468 return errors.New("zones: unknown endpoint kind " + strconv.Quote(string(kind)) +
469 ", want rpc, gnoweb, seed, peer, indexer, faucet or explorer")
470}
471
472// ValidateLabel accepts an optional single line of up to 64 characters.
473func ValidateLabel(s string) error { return checkText("label", s, 0, MaxLabelLen) }
474
475// ValidateReason accepts an optional single line of up to 280 characters.
476// Whether a reason is REQUIRED depends on the decision; see [Registry].
477func ValidateReason(s string) error { return checkText("reason", s, 0, MaxReasonLen) }
478
479var (
480 // rpcSchemes is what an rpc ENDPOINT may be: a websocket subscriber is a
481 // real consumer of one.
482 rpcSchemes = []string{"https", "http", "wss", "ws", "tcp"}
483 // primarySchemes is what a zone's main RPC may be, narrower on purpose: it
484 // is what every tool is pointed at first, gnokey's -remote included, and
485 // gnokey's query client dials http, https and tcp only, so a wss:// main
486 // RPC would print a command that cannot run.
487 primarySchemes = []string{"https", "http", "tcp"}
488 webSchemes = []string{"https", "http"}
489)
490
491// ValidAddress reports whether a is a valid g1 address in the one spelling the
492// chain uses for it: lowercase. bech32 also decodes an all-uppercase string, so
493// IsValid accepts G1ABC…, but every comparison here (curators, proposers,
494// registrants, node ids) is on the string, and an uppercase spelling of a real
495// address would be a second identity for it.
496func ValidAddress(a address) bool {
497 return a.IsValid() && string(a) == strings.ToLower(string(a))
498}
499
500// HasVisible reports whether s has at least one character a reader can see:
501// not a space, not a combining mark, not an invisible format character, not a
502// blank filler. A
503// required title or reason must, or it passes the "needs a reason" check and
504// renders blank.
505func HasVisible(s string) bool {
506 for _, r := range s {
507 if !unicode.IsSpace(r) && !unicode.IsMark(r) && !unicode.Is(unicode.Cf, r) && !isBlankFiller(r) {
508 return true
509 }
510 }
511 return false
512}
513
514// isBlankFiller is the handful of characters that are letters or symbols by
515// category and still draw nothing: the Hangul fillers, the blank Braille cell,
516// the musical null notehead, the Egyptian hieroglyph blanks, and the Khmer
517// inherent vowels and the Khitan filler (marks by category, drawn as nothing). Unicode does not class them as format
518// characters, so they need naming.
519func isBlankFiller(r rune) bool {
520 switch r {
521 case 0x115F, 0x1160, 0x3164, 0xFFA0, 0x2800, 0x1D159, 0x17B4, 0x17B5,
522 0x13441, 0x13442, 0x16FE4:
523 return true
524 }
525 return false
526}
527
528// isSelector is the variation selectors, all three blocks (U+180E, inside the
529// Mongolian range, is a format character and refused as one): invisible on their
530// own, and an invisible difference between two spellings of a name.
531func isSelector(r rune) bool {
532 return (r >= 0xFE00 && r <= 0xFE0F) || (r >= 0xE0100 && r <= 0xE01EF) || (r >= 0x180B && r <= 0x180F)
533}
534
535// selects reports whether a variation selector modifies the character before
536// it, the one place it is text rather than an invisible difference: the
537// emoji and text presentation selectors after a symbol (a phone writes ❤️ as
538// U+2764 U+FE0F), the Mongolian free variation selectors after a Mongolian
539// letter, and an ideographic variation sequence after a Han ideograph. A
540// selector cannot make a badge: every symbol that looks like one is refused on
541// its own.
542func selects(base, sel rune) bool {
543 switch {
544 case sel == 0xFE0E || sel == 0xFE0F:
545 // The symbols, and the five emoji whose base is punctuation or a
546 // letter by category: ‼ ⁉ ℹ 〰 〽.
547 return unicode.In(base, unicode.So, unicode.Sm) ||
548 base == 0x203C || base == 0x2049 || base == 0x2139 || base == 0x3030 || base == 0x303D
549 case sel >= 0x180B && sel <= 0x180F && sel != 0x180E:
550 return base >= 0x1820 && base <= 0x18AA
551 case sel >= 0xE0100 && sel <= 0xE01EF:
552 return unicode.Is(unicode.Han, base)
553 }
554 return false
555}
556
557// isBadge is the status glyphs Render draws, and their look-alikes, refused in
558// free text so a title cannot claim "✔ official" beside a pending badge.
559func isBadge(r rune) bool {
560 switch r {
561 case 0x2705, 0x26A0, 0x23F3, 0x274C, 0x23F9, // ✅ ⚠ ⏳ ❌ ⏹, what Render draws
562 0x2713, 0x2714, 0x2611, 0x1F5F8, 0x1F5F9, 0x1F197, // check marks, 🆗
563 0x2716, 0x2717, 0x2718, 0x2715, 0x274E, 0x2612, 0x1F6AB, 0x26D4, 0x1F6D1, // crosses, no-entry
564 0x1F5F4, 0x1F5F5, 0x1F5F6, 0x1F5F7, // ballot x and ballot box with x
565 0x231B, 0x23F8, 0x23FA, // ⌛ ⏸ ⏺
566 0x1FBB1, 0x237B, 0x10102, // more check marks
567 0x1F5D9, 0x2A2F, 0x2573, 0x2BBD, 0x2BBE, 0x2BBF, // more crosses and ballot boxes
568 0x29D6, 0x29D7, // hourglasses
569 0x2613, 0x2A09, // saltire, n-ary times (× itself is everyday text: 1920×1080)
570 0x22A0, 0x2327, 0x1F147, 0x1F187, 0x2297, 0x2A02, 0x1F167, // boxed and circled crosses
571 0x1F6C7, 0x2298, 0x29B8, // prohibition signs
572 0x24CD, 0x24E7, 0x24B3, 0x1F127, 0x29BB, 0x2A34, 0x2A35, 0x2A37, 0x292B, 0x292C, // circled, parenthesized and crossing x
573 0x1F532, 0x1F533, // square buttons, beside ⏹
574 0x26DD, 0x1F5BE, 0x2B59, 0x2A36, 0x26D2, 0x1FBC0, 0x1D145, 0x26CC, 0x2A3B, // more boxed, circled and heavy crosses
575 0x1FBBD, 0x1F6AD, 0x1F6AF, 0x1F6B1, 0x1F6B3, 0x1F6B7, 0x1F4F5, 0x1F51E, 0x1F10D, 0x1F10F, 0x1F16E: // more no-entry signs
576 return true
577 }
578 return r >= 0x1F7A8 && r <= 0x1F7AE // the geometric crosses beside ✖
579}
580
581// checkText bounds a free-text field by runes (so at most four times as many
582// bytes) and keeps it to one line of text that shows what it stores:
583//
584// - no control character: C0, DEL, C1, U+2028, U+2029. Several are line
585// breaks that ui.Inline folds to a space, the rest draw nothing; either
586// way the text shown would not be the text stored.
587// - no invisible or undrawable character: every format character (Unicode
588// Cf, which covers the bidi controls and isolates, the zero-width
589// characters, U+061C, word joiners, tags), every character that is not
590// graphic (private use, unassigned, noncharacters), the blank fillers, a
591// variation selector except right after a base it modifies (selects), the
592// combining grapheme joiner. A title made of them
593// would pass the length check and render blank or as a box.
594// - no enclosing mark, which draws a badge's frame around any character,
595// and no run of more than four nonspacing marks, which stack over
596// neighbouring lines (a spacing mark takes its own width and ends a run,
597// so Devanagari, Gurmukhi, Burmese and Tibetan words pass), and none of
598// the status glyphs a Render draws.
599// - valid UTF-8, because the page would show U+FFFD for a byte stored raw.
600//
601// Refused rather than stripped, so what is stored is what is shown.
602func checkText(field, s string, min, max int) error {
603 // No rune is more than four bytes: anything longer cannot be within max
604 // characters, and is refused before a per-rune scan the writer pays for.
605 if len(s) > 4*max {
606 return errors.New("zones: the " + field + " is longer than " + strconv.Itoa(max) + " characters")
607 }
608 n, marks, prev := 0, 0, rune(0)
609 for i, r := range s {
610 if r == utf8.RuneError {
611 if _, size := utf8.DecodeRuneInString(s[i:]); size <= 1 {
612 return errors.New("zones: the " + field + " is not valid UTF-8")
613 }
614 }
615 if r < 0x20 || (r >= 0x7f && r <= 0x9f) || r == 0x2028 || r == 0x2029 {
616 return errors.New("zones: the " + field + " contains a control character")
617 }
618 if unicode.Is(unicode.Cf, r) || isBlankFiller(r) || r == 0x034F || (isSelector(r) && !selects(prev, r)) {
619 return errors.New("zones: the " + field + " contains an invisible or bidi character")
620 }
621 if !unicode.IsGraphic(r) && !unicode.IsSpace(r) {
622 // The chain's unicode tables are Unicode 15.0, so a character
623 // assigned since then is refused here even where gno test (which
624 // uses the host's newer tables) accepts it.
625 return errors.New("zones: the " + field + " contains " + strconv.QuoteToASCII(string(r)) +
626 ", which this chain does not draw: private use, a noncharacter, or unassigned in its Unicode 15.0 tables")
627 }
628 if isBadge(r) {
629 return errors.New("zones: the " + field + " contains a status glyph a Render draws, or a look-alike of one: " + strconv.Quote(string(r)))
630 }
631 if unicode.Is(unicode.Me, r) {
632 // An enclosing mark draws a frame around what precedes it: !\u20E4
633 // is a warning triangle, v\u20DE a checked box. No living script
634 // needs one, and keycaps need U+FE0F, refused above.
635 return errors.New("zones: the " + field + " contains an enclosing mark, which draws a status glyph's frame")
636 }
637 if unicode.Is(unicode.Mn, r) {
638 marks++
639 if marks > 4 {
640 return errors.New("zones: the " + field + " stacks more than four combining marks")
641 }
642 } else {
643 marks = 0
644 }
645 prev = r
646 n++
647 if n > max {
648 break // refused below; no need to classify the rest
649 }
650 }
651 if n > max {
652 return errors.New("zones: the " + field + " is longer than " + strconv.Itoa(max) + " characters")
653 }
654 if n < min {
655 return errors.New("zones: the " + field + " is " + strconv.Itoa(min) + " to " +
656 strconv.Itoa(max) + " characters, got " + strconv.Itoa(n))
657 }
658 if s != "" && !HasVisible(s) {
659 return errors.New("zones: the " + field + " has nothing visible in it")
660 }
661 return nil
662}
663
664// checkURL accepts scheme://host[:port][/path][?query], in visible ASCII (no
665// space, no control), with no character that could close a markdown link or
666// open a new construct around it. Narrower than RFC 3986 on purpose: these
667// URLs are rendered as links and copied into config files, and neither wants
668// a quote or a bracket. Also refused:
669//
670// - a fragment (#): it is never sent to the server, so every #1, #2 would be
671// another listing of the same endpoint.
672// - a % not followed by two hex digits, and an &name; shape: the link
673// sanitizer re-encodes both, so the href would differ from the text shown.
674func checkURL(field, s string, schemes []string) error {
675 if s == "" {
676 return errors.New("zones: the " + field + " is empty")
677 }
678 if len(s) > MaxURLLen {
679 return errors.New("zones: the " + field + " is longer than " + strconv.Itoa(MaxURLLen) + " bytes")
680 }
681 n := len(s)
682 for i := 0; i < n; i++ {
683 c := s[i]
684 if c <= ' ' || c >= 0x7f || isURLForbidden(c) {
685 r, _ := utf8.DecodeRuneInString(s[i:]) // the character, not its first byte
686 return errors.New("zones: the " + field + " contains " + strconv.QuoteRune(r) + ": " + strconv.Quote(s))
687 }
688 if c == '%' && (i+2 >= n || !isHex(s[i+1]) || !isHex(s[i+2])) {
689 return errors.New("zones: the " + field + " has a % that is not followed by two hex digits: " + strconv.Quote(s))
690 }
691 if c == '%' && isUnreserved(unhex(s[i+1])<<4|unhex(s[i+2])) {
692 // %7E and ~ are the same URL (RFC 3986): one spelling, so two
693 // listings cannot be the same endpoint.
694 return errors.New("zones: the " + field + " escapes a character that needs no escaping: " + strconv.Quote(s))
695 }
696 if c == '&' && isEntity(s[i+1:]) {
697 return errors.New("zones: the " + field + " contains an HTML entity shape: " + strconv.Quote(s))
698 }
699 }
700 scheme, rest, ok := strings.Cut(s, "://")
701 if !ok {
702 return errors.New("zones: the " + field + " needs a scheme (" + strings.Join(schemes, ", ") + "): " + strconv.Quote(s))
703 }
704 // A scheme is case-insensitive, so HTTPS:// is https://; Canonical lowercases
705 // it the same way, which is what makes the two one endpoint.
706 known := false
707 for _, sc := range schemes {
708 if strings.ToLower(scheme) == sc {
709 known = true
710 break
711 }
712 }
713 if !known {
714 return errors.New("zones: the " + field + " scheme is " + strconv.Quote(scheme) + ", want one of " +
715 strings.Join(schemes, ", "))
716 }
717 authority := rest
718 if i := strings.IndexAny(authority, "/?#"); i >= 0 {
719 authority = authority[:i]
720 // A "." or ".." path segment is resolved away by every browser and by
721 // RFC 3986, so it would be a second spelling of another URL.
722 path := rest[i:]
723 if j := strings.IndexByte(path, '?'); j >= 0 {
724 path = path[:j]
725 }
726 for _, seg := range strings.Split(path, "/") {
727 if seg == "." || seg == ".." {
728 return errors.New("zones: the " + field + " has a . or .. path segment: " + strconv.Quote(s))
729 }
730 }
731 }
732 if strings.Contains(authority, "@") {
733 return errors.New("zones: the " + field + " carries credentials: " + strconv.Quote(s))
734 }
735 host, port := authority, ""
736 if i := strings.LastIndexByte(authority, ':'); i >= 0 {
737 host, port = authority[:i], authority[i+1:]
738 if err := checkPort(port); err != nil {
739 return errors.New("zones: the " + field + "'s port is 1 to 65535, got " + strconv.Quote(port))
740 }
741 }
742 if host == "" {
743 return errors.New("zones: the " + field + " has no host: " + strconv.Quote(s))
744 }
745 if err := checkHost(host); err != nil {
746 return errors.New("zones: the " + field + "'s host is a DNS name or an IPv4 address, got " + strconv.Quote(host))
747 }
748 return nil
749}
750
751// checkHost accepts a DNS name or a dotted IPv4 address.
752//
753// A DNS name is labels of 1 to 63 characters of [A-Za-z0-9-], alphanumeric at
754// both ends, at most 253 characters in all, with one optional terminal dot.
755// Anything a browser would parse as IPv4 must BE a valid dotted-quad address:
756// under the WHATWG URL parser a host whose last label is numeric (decimal, or
757// 0x hex) is an IPv4 address, so 0x7f.1 opens 127.0.0.1 and a.1 is no URL at
758// all. A last label starting 0x is held to the address rule even when the rest
759// is not hex (a browser would take 0xyz as a name): stricter, never looser.
760// An address takes no terminal dot. Bracketed IPv6 is refused, deliberately: it is the one host shape that
761// needs characters every other field here refuses, and no zone needs it yet.
762func checkHost(host string) error {
763 name := strings.TrimSuffix(host, ".")
764 if name == "" || len(name) > 253 {
765 return errors.New("bad host")
766 }
767 labels := strings.Split(name, ".")
768 last := strings.ToLower(labels[len(labels)-1])
769 if strings.Trim(last, "0123456789") == "" || strings.HasPrefix(last, "0x") {
770 // No terminal dot on an address: browsers accept 1.2.3.4. but Go's
771 // dialer, so gnokey and tm2, look it up as a name and fail.
772 if name != host {
773 return errors.New("bad ipv4")
774 }
775 return checkIPv4(name)
776 }
777 for _, label := range labels {
778 if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
779 return errors.New("bad host")
780 }
781 for _, r := range label {
782 switch {
783 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-':
784 default:
785 return errors.New("bad host")
786 }
787 }
788 }
789 return nil
790}
791
792func checkIPv4(s string) error {
793 parts := strings.Split(s, ".")
794 if len(parts) != 4 {
795 return errors.New("bad ipv4")
796 }
797 for _, p := range parts {
798 if p == "" || len(p) > 3 || strings.Trim(p, "0123456789") != "" || (len(p) > 1 && p[0] == '0') {
799 return errors.New("bad ipv4")
800 }
801 if n, _ := strconv.Atoi(p); n > 255 {
802 return errors.New("bad ipv4")
803 }
804 }
805 return nil
806}
807
808// checkPort accepts 1 to 65535, decimal digits only, no leading zero. Digits
809// only because strconv.Atoi takes a sign, and +443 is no port to url.Parse,
810// to a browser, or to tm2's peer parser.
811func checkPort(port string) error {
812 if port == "" || strings.Trim(port, "0123456789") != "" || port[0] == '0' || len(port) > 5 {
813 return errors.New("bad port")
814 }
815 if p, _ := strconv.Atoi(port); p > 65535 {
816 return errors.New("bad port")
817 }
818 return nil
819}
820
821// isURLForbidden is the visible ASCII a URL here may not carry: what could
822// close a markdown link or open a construct around it, and a fragment.
823func isURLForbidden(c byte) bool {
824 switch c {
825 case '<', '>', '"', '\'', '`', '(', ')', '[', ']', '{', '}', '|', '\\', '^', '#':
826 return true
827 }
828 return false
829}
830
831func isHex(c byte) bool {
832 return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
833}
834
835func unhex(c byte) byte {
836 switch {
837 case c >= '0' && c <= '9':
838 return c - '0'
839 case c >= 'a' && c <= 'f':
840 return c - 'a' + 10
841 }
842 return c - 'A' + 10
843}
844
845// isUnreserved is RFC 3986's unreserved set: what a URL never needs to escape.
846func isUnreserved(c byte) bool {
847 return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
848 c == '-' || c == '.' || c == '_' || c == '~'
849}
850
851// isEntity reports whether s starts with what an HTML entity would follow an
852// ampersand with: a run of [A-Za-z0-9#] closed by a semicolon.
853func isEntity(s string) bool {
854 for i := 0; i < len(s); i++ {
855 c := s[i]
856 switch {
857 case c == ';':
858 return i > 0
859 case (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '#':
860 default:
861 return false
862 }
863 }
864 return false
865}
866
867// TrimSpaces trims spaces (Unicode Zs: U+0020, no-break space, U+3000 and the
868// like) and tabs from both ends, and nothing else. strings.TrimSpace also
869// strips line breaks and separators (CR, LF, U+0085, U+2028), which free text
870// refuses: trimmed first, they would be accepted silently, changed. A line
871// break a caller types reaches the validator and is refused there.
872func TrimSpaces(s string) string {
873 return strings.TrimFunc(s, func(r rune) bool { return r == '\t' || unicode.Is(unicode.Zs, r) })
874}
875
876// lowerAuthority lowercases a URL's scheme and host, both case-insensitive,
877// and leaves the path and query as typed. Stored that way because gno's link
878// sanitizer is not case-insensitive (an HTTPS:// link would render with an
879// empty href), and so every later lowercasing of the host (Canonical,
880// IsPrivateHost) finds nothing to change: a scan, no copy. ASCII only, see
881// asciiLower.
882func lowerAuthority(addr string) string {
883 scheme, rest, ok := strings.Cut(addr, "://")
884 if !ok {
885 return addr
886 }
887 host, tail := rest, ""
888 if i := strings.IndexAny(rest, "/?"); i >= 0 {
889 host, tail = rest[:i], rest[i:]
890 }
891 return asciiLower(scheme) + "://" + asciiLower(host) + tail
892}
893
894// trimEmptyTail drops an empty tail from a URL, by Canonical's own rules: an
895// empty query's "?" (when it is the tail's only "?") and a bare "/". gnokey
896// would dial a host with them attached, so an endpoint is not stored with them
897// (nor a zone's gnoweb and genesis URLs, which trimInfo repairs the same way, as
898// a browser's address bar writes https://host/; its main RPC is refused with
899// them, ValidateInfo). Canonical
900// drops both too, so Canonical(trimEmptyTail(x)) == Canonical(x), and a check
901// made on what a caller typed holds for what is stored. A path is kept as
902// typed otherwise ("/?q" stays: Canonical reads it as "?q", a browser too).
903func trimEmptyTail(addr string) string {
904 scheme, rest, ok := strings.Cut(addr, "://")
905 if !ok {
906 return addr
907 }
908 i := strings.IndexAny(rest, "/?")
909 if i < 0 {
910 return addr
911 }
912 authority, tail := rest[:i], rest[i:]
913 if strings.HasSuffix(tail, "?") && strings.Count(tail, "?") == 1 {
914 tail = tail[:len(tail)-1]
915 }
916 if tail == "/" {
917 tail = ""
918 }
919 return scheme + "://" + authority + tail
920}
921
922// asciiLower lowercases A to Z and nothing else. strings.ToLower also folds
923// a few non-ASCII letters into ASCII ones (U+212A KELVIN SIGN to k, U+0130 to
924// i), which would turn a URL validation refuses into one it accepts, under a
925// spelling the caller never typed.
926func asciiLower(s string) string {
927 for i := 0; i < len(s); i++ {
928 if c := s[i]; c >= 'A' && c <= 'Z' {
929 b := []byte(s)
930 for j := i; j < len(b); j++ {
931 if b[j] >= 'A' && b[j] <= 'Z' {
932 b[j] += 'a' - 'A'
933 }
934 }
935 return string(b)
936 }
937 }
938 return s
939}
940
941// Canonical is the form two addresses are compared in, so one endpoint cannot
942// be listed twice under two spellings: the scheme and the host lowercased (both
943// are case-insensitive), the host's terminal dot dropped, the scheme's default
944// port dropped (:443 for https and wss, :80 for http and ws), an empty path
945// before a query dropped, an empty query's "?" and a bare "/" dropped, the hex
946// digits of every %XX escape uppercased, and for an rpc endpoint tcp:// read as
947// http://, which is how gnokey dials it. A path or a query that says something
948// is kept as typed: those are case-sensitive and name different resources. A
949// peer is lowercased whole and loses its host's terminal dot: its node id is
950// lowercase bech32 and its host is a name.
951func Canonical(kind EndpointKind, addr string) string {
952 if kind == Seed || kind == Peer {
953 id, hostport, ok := strings.Cut(asciiLower(addr), "@")
954 if !ok {
955 return asciiLower(addr)
956 }
957 if i := strings.LastIndexByte(hostport, ':'); i >= 0 {
958 hostport = strings.TrimSuffix(hostport[:i], ".") + hostport[i:]
959 }
960 return id + "@" + hostport
961 }
962 scheme, rest, ok := strings.Cut(addr, "://")
963 if !ok {
964 return addr
965 }
966 scheme = asciiLower(scheme)
967 if kind == RPC && scheme == "tcp" {
968 scheme = "http"
969 }
970 i := strings.IndexAny(rest, "/?")
971 if i < 0 {
972 i = len(rest)
973 }
974 authority, tail := asciiLower(rest[:i]), rest[i:]
975 host, port := authority, ""
976 if j := strings.LastIndexByte(authority, ':'); j >= 0 {
977 host, port = authority[:j], authority[j+1:]
978 }
979 host = strings.TrimSuffix(host, ".")
980 switch {
981 case port == "443" && (scheme == "https" || scheme == "wss"),
982 port == "80" && (scheme == "http" || scheme == "ws"):
983 port = ""
984 }
985 if port != "" {
986 host += ":" + port
987 }
988 if strings.HasSuffix(tail, "?") && strings.Count(tail, "?") == 1 {
989 tail = tail[:len(tail)-1] // an empty query, not a query ending in "?"
990 }
991 if strings.HasPrefix(tail, "/?") {
992 tail = tail[1:]
993 }
994 if tail == "/" {
995 tail = ""
996 }
997 return scheme + "://" + host + upperEscapes(tail)
998}
999
1000// upperEscapes uppercases the two hex digits of every %XX in s.
1001func upperEscapes(s string) string {
1002 if !strings.Contains(s, "%") {
1003 return s
1004 }
1005 b := []byte(s)
1006 for i := 0; i+2 < len(b); i++ {
1007 if b[i] == '%' {
1008 b[i+1] = toUpperHex(b[i+1])
1009 b[i+2] = toUpperHex(b[i+2])
1010 }
1011 }
1012 return string(b)
1013}
1014
1015func toUpperHex(c byte) byte {
1016 if c >= 'a' && c <= 'f' {
1017 return c - 'a' + 'A'
1018 }
1019 return c
1020}
1021
1022// checkPeer accepts a tm2 p2p address: <node id>@<host>:<port>, where the node
1023// id is the node's g1 address, as `gnoland secrets get node_id` prints it.
1024func checkPeer(s string) error {
1025 id, hostport, ok := strings.Cut(s, "@")
1026 if !ok {
1027 return errors.New("zones: a peer is <node id>@<host>:<port>, got " + strconv.Quote(s))
1028 }
1029 if len(s) > MaxURLLen {
1030 return errors.New("zones: a peer is longer than " + strconv.Itoa(MaxURLLen) + " bytes")
1031 }
1032 // Lowercase only: bech32 also decodes an all-uppercase id, but tm2 compares
1033 // node ids byte for byte when it dials, so an uppercase one never connects.
1034 if !ValidAddress(address(id)) {
1035 return errors.New("zones: a peer's node id is a lowercase g1 address, got " + strconv.Quote(id))
1036 }
1037 i := strings.LastIndexByte(hostport, ':')
1038 if i <= 0 {
1039 return errors.New("zones: a peer needs a port: " + strconv.Quote(s))
1040 }
1041 host, port := hostport[:i], hostport[i+1:]
1042 if err := checkPort(port); err != nil {
1043 return errors.New("zones: a peer's port is 1 to 65535, got " + strconv.Quote(port))
1044 }
1045 if err := checkHost(host); err != nil {
1046 return errors.New("zones: a peer's host is a DNS name or an IPv4 address, got " + strconv.Quote(host))
1047 }
1048 return nil
1049}