keys.gno
5.57 Kb · 167 lines
1package config
2
3import (
4 "strings"
5
6 "chain/runtime"
7 "chain/runtime/unsafe"
8)
9
10// The key grammar.
11//
12// A key is a NAME, optionally followed by "@" and a SCOPE:
13//
14// pause applies to every realm that asks
15// pause@r/moul/home applies to that realm only
16//
17// The scope is a package path with the chain domain stripped, because the
18// domain is the same for every realm reading this one and repeating it in a
19// few dozen keys buys nothing but storage.
20//
21// Two settings therefore answer every scoped question, and each reader decides
22// how they combine: pause takes the stricter of the two (pausable.Strictest),
23// while the notice blocks show both. That choice belongs to the reader and not
24// here, because "stricter wins" and "show both" are both right, for different
25// settings.
26const (
27 // ScopeSep separates a name from the realm it applies to. "@" and not "."
28 // so a scope can never be mistaken for a longer name, and not ":" because
29 // gno realm render paths already use that.
30 ScopeSep = "@"
31
32 maxNameLen = 64
33 maxScopeLen = 128
34)
35
36// realmPath is this realm's own path, including its version. Stated once so
37// the page can link to itself and the proxy check can recognise a sibling
38// version without either restating the string.
39const realmPath = "gno.land/r/moul/config/v1"
40
41// validName reports whether name is a legal setting name: 1..maxNameLen bytes
42// of [a-z0-9._-]. The dot is the namespace separator ("block.top"), lowercase
43// only so a setting has exactly one name, and no whitespace so a name
44// round-trips through Manifest's tab-separated lines.
45func validName(name string) bool {
46 if len(name) == 0 || len(name) > maxNameLen {
47 return false
48 }
49 for i := 0; i < len(name); i++ {
50 c := name[i]
51 switch {
52 case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
53 case c == '-', c == '_', c == '.':
54 default:
55 return false
56 }
57 }
58 return true
59}
60
61// validScope reports whether scope is shaped like a package path with the
62// domain stripped: 1..maxScopeLen bytes of [a-z0-9._/-], no empty segment.
63func validScope(scope string) bool {
64 if len(scope) == 0 || len(scope) > maxScopeLen {
65 return false
66 }
67 lastSlash := true // a leading slash would be an empty first segment
68 for i := 0; i < len(scope); i++ {
69 c := scope[i]
70 switch {
71 case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
72 lastSlash = false
73 case c == '-', c == '_', c == '.':
74 lastSlash = false
75 case c == '/':
76 if lastSlash {
77 return false
78 }
79 lastSlash = true
80 default:
81 return false
82 }
83 }
84 return !lastSlash
85}
86
87// validKey reports whether key is a legal name, or a legal name and scope
88// joined by ScopeSep.
89func validKey(key string) bool {
90 name, scope := SplitKey(key)
91 if scope == "" {
92 return !strings.Contains(key, ScopeSep) && validName(name)
93 }
94 return validName(name) && validScope(scope)
95}
96
97// SplitKey takes a key apart. An unscoped key returns an empty scope, and so
98// does a malformed one: callers pair this with validKey rather than trusting
99// the split.
100func SplitKey(key string) (name, scope string) {
101 i := strings.Index(key, ScopeSep)
102 if i < 0 {
103 return key, ""
104 }
105 return key[:i], key[i+1:]
106}
107
108// Scope turns a package path into the scope half of a key, stripping the chain
109// domain: "gno.land/r/moul/home" and "r/moul/home" both give "r/moul/home".
110//
111// A path that is not shaped like one comes back empty, and every caller here
112// treats that as "no scope", falling back to the global setting rather than
113// inventing a key nobody can type.
114//
115// The chain domain is tried first and the literal "gno.land/" second, so a key
116// written on one chain still resolves on another whose domain differs. Twin of
117// mygnoscan.TrimDomain, which answers the same question for a URL and is
118// stricter about the characters, because its output lands inside a link.
119func Scope(pkgPath string) string {
120 s := strings.TrimPrefix(pkgPath, runtime.ChainDomain()+"/")
121 s = strings.TrimPrefix(s, "gno.land/")
122 s = strings.Trim(s, "/")
123 if !validScope(s) {
124 return ""
125 }
126 return s
127}
128
129// KeyFor builds the scoped key for a setting on one realm, and returns the
130// bare name when pkgPath names no realm this can scope to.
131//
132// This is what a realm should render when it wants to tell a manager which
133// setting to change, so the command in the page is the command that works.
134func KeyFor(name, pkgPath string) string {
135 scope := Scope(pkgPath)
136 if scope == "" {
137 return name
138 }
139 return name + ScopeSep + scope
140}
141
142// caller is the package path of the realm that called into this one.
143//
144// Every exported function here that uses it is a plain read with no `cur realm`
145// parameter, so it can only ever be BORROWED: gno runs it without opening a
146// realm frame, and unsafe.CurrentRealm() therefore reports the borrower. That
147// is what makes the zero-argument helpers (TopBlock, IsPaused, …) able to name
148// their caller at all.
149//
150// Measured in the test harness on 2026-09-22: a call from a code realm at
151// gno.land/r/test/caller reports CurrentRealm=gno.land/r/test/caller and
152// PreviousRealm=gno.land/r/moul/config/v1.
153//
154// The moment one of these grows a `cur realm` parameter this stops being true
155// and starts reporting this realm instead. Do not add one. Anything that needs
156// a realm frame takes the path explicitly, which is what the …For variants are.
157func caller() string { return unsafe.CurrentRealm().PkgPath() }
158
159// scopedPair reads both halves of a scoped setting: the global one and the one
160// for pkgPath, either of which may be empty.
161func scopedPair(name, pkgPath string) (global, scoped string) {
162 global = Get(name)
163 if scope := Scope(pkgPath); scope != "" {
164 scoped = Get(name + ScopeSep + scope)
165 }
166 return global, scoped
167}