Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}