validate.gno
5.54 Kb · 193 lines
1package gnopm
2
3import "strings"
4
5// Size caps. Every string the chain stores is bounded: an unbounded field is an
6// unbounded storage deposit, and on gno.land the deposit is paid per byte by
7// whoever writes it.
8const (
9 MaxPkgPathLen = 256
10 MaxRepoLen = 512
11 MaxDirLen = 256
12 MaxRefLen = 255 // git's own limit for a single ref name
13
14 // MaxClaimants bounds how many addresses may claim one package path.
15 // Unbounded, it is a spam surface: one path with ten thousand claims is a
16 // Render that never returns and a listing nobody can read.
17 MaxClaimants = 16
18)
19
20// ValidCommit reports whether s is a git object id: 40 (SHA-1) or 64 (SHA-256)
21// lowercase hex characters. Case is fixed so the same object always produces
22// the same stored bytes, and so a verifier comparing two claims compares two
23// comparable strings.
24func ValidCommit(s string) bool {
25 if len(s) != 40 && len(s) != 64 {
26 return false
27 }
28 for i := 0; i < len(s); i++ {
29 c := s[i]
30 if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
31 return false
32 }
33 }
34 return true
35}
36
37// ValidRepo reports whether s is a repository URL this registry accepts.
38//
39// Deliberately narrow: "https://" only, a host, and a path. Not a taste
40// judgement about git transports, a safety one. Whatever is stored here is
41// eventually rendered as a link by this realm, by gnoweb and by every explorer
42// that reads the registry, and the set of schemes that are safe to hand a
43// browser is exactly one. "javascript:", "data:" and "file:" are the attack;
44// "git://" and "ssh://" are merely unreachable from a web page, and a claimant
45// who needs one can point at the https mirror every forge already serves.
46func ValidRepo(s string) bool {
47 if s == "" || len(s) > MaxRepoLen {
48 return false
49 }
50 const scheme = "https://"
51 if !strings.HasPrefix(s, scheme) {
52 return false
53 }
54 rest := s[len(scheme):]
55 if rest == "" || strings.HasPrefix(rest, "/") {
56 return false
57 }
58 // A host must be present and must not be a userinfo trick
59 // ("https://[email protected]/x" fetches from evil.example while
60 // reading as GitHub).
61 slash := strings.IndexByte(rest, '/')
62 host := rest
63 if slash >= 0 {
64 host = rest[:slash]
65 }
66 if host == "" || strings.ContainsAny(host, "@:") {
67 return false
68 }
69 if !strings.Contains(host, ".") {
70 return false
71 }
72 for i := 0; i < len(s); i++ {
73 if !urlByteOK(s[i]) {
74 return false
75 }
76 }
77 return !strings.Contains(s, "..")
78}
79
80// urlByteOK is an allowlist, not a denylist: the printable ASCII that appears
81// in a real repository URL. Everything else, control characters and the bidi
82// overrides in particular, is refused rather than escaped, because a field that
83// can only hold safe bytes needs no escaping at any of its render sites.
84func urlByteOK(c byte) bool {
85 switch {
86 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
87 return true
88 }
89 switch c {
90 case '-', '.', '_', '~', ':', '/', '%', '+':
91 return true
92 }
93 return false
94}
95
96// ValidDir reports whether s names the subdirectory of the repository holding
97// the package. Empty means the repository root, which is the common case for a
98// single-package repo and must stay expressible.
99func ValidDir(s string) bool {
100 if s == "" {
101 return true
102 }
103 if len(s) > MaxDirLen {
104 return false
105 }
106 if strings.HasPrefix(s, "/") || strings.HasSuffix(s, "/") {
107 return false
108 }
109 for _, p := range strings.Split(s, "/") {
110 if p == "" || p == "." || p == ".." {
111 return false
112 }
113 for i := 0; i < len(p); i++ {
114 if !pathByteOK(p[i]) {
115 return false
116 }
117 }
118 }
119 return true
120}
121
122func pathByteOK(c byte) bool {
123 switch {
124 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
125 return true
126 }
127 switch c {
128 case '-', '.', '_':
129 return true
130 }
131 return false
132}
133
134// ValidRef reports whether s is a fully-qualified git ref, or empty.
135//
136// The ref is optional and is never the thing that is verified: a ref moves, a
137// commit does not. It is recorded so a reader can tell a claim pinned to a
138// released tag from one pinned to a commit on nobody's branch, and so a
139// verifier can check the claimed commit is still reachable from it rather than
140// dangling behind a force-push.
141func ValidRef(s string) bool {
142 if s == "" {
143 return true
144 }
145 if len(s) > MaxRefLen || !strings.HasPrefix(s, "refs/") {
146 return false
147 }
148 if strings.Contains(s, "..") || strings.Contains(s, "@{") {
149 return false
150 }
151 if strings.HasSuffix(s, "/") || strings.HasSuffix(s, ".") {
152 return false
153 }
154 parts := strings.Split(s, "/")
155 if len(parts) < 2 {
156 return false
157 }
158 for _, p := range parts {
159 if p == "" || p == "@" || strings.HasPrefix(p, ".") || strings.HasSuffix(p, ".lock") {
160 return false
161 }
162 for i := 0; i < len(p); i++ {
163 if !refByteOK(p[i]) {
164 return false
165 }
166 }
167 }
168 return true
169}
170
171// refByteOK is an allowlist, and deliberately narrower than git's own rule.
172//
173// git rejects a handful of metacharacters and permits everything else, which
174// leaves a ref free to contain a backtick, a pipe or a bracket. Every other
175// field here is an allowlist precisely so that no consumer has to escape
176// anything, and one denylist field would undo that for all of them: a ref
177// carrying a pipe breaks out of a markdown table cell, and one carrying a
178// backtick breaks out of the inline-code span a renderer wraps it in.
179//
180// The cost is refs nobody writes. What stays expressible is every ref anyone
181// actually has: alphanumerics, and the four separators git tooling puts in a
182// name.
183func refByteOK(c byte) bool {
184 switch {
185 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
186 return true
187 }
188 switch c {
189 case '-', '.', '_', '/', '+':
190 return true
191 }
192 return false
193}