registry.gno
9.21 Kb · 259 lines
1// Package gnopm is the domain model of a source registry for gno packages: a
2// map from a deployed package path back to the repository, commit and directory
3// that produced it.
4//
5// It exists because gno deliberately broke the link Go gets for free. A Go
6// import path IS a repository URL, so pkg.go.dev needs no registry; a gno
7// import path is a chain address, so nothing anywhere says which source
8// produced the bytes running at gno.land/p/nt/tinyavl/v0.
9//
10// # What this can and cannot promise
11//
12// It cannot promise anything, and the design is built around saying so rather
13// than around hiding it. Two claims look alike and are not the same:
14//
15// 1. "this package came from that repository". UNPROVABLE, here or anywhere.
16// Anyone may deploy any bytes and anyone may claim any repository. No
17// registry design fixes this, and one that implies otherwise is worse than
18// none.
19// 2. "the bytes live at path P equal the addpkg payload of directory D in
20// repository R at commit C". Mechanically provable, by hashing both sides.
21// It is NOT provable here: a realm cannot clone a repository. It is
22// provable off chain by anything that can, and gnopm already does exactly
23// this against a local tree (`gnopm verify -deployed`).
24//
25// So this package stores (1), labelled as the claim it is, in the form (2)
26// needs to be checkable. A Claim is testimony under a signature. It becomes
27// evidence only once something outside the chain has gone and looked.
28//
29// # What a signature does buy
30//
31// The chain knows who signed a claim, and that is not nothing. A claim whose
32// claimant owns the package path's namespace comes from the party that controls
33// the path, which is the closest thing to a promise available without a
34// chain-level feature. OwnedBy reports that relationship; it is deliberately
35// computed on demand from a caller-supplied resolver and never stored, because
36// a name can be transferred and a stored answer would be a claim of its own.
37//
38// Anyone may still claim any path. That is the "open but tagged" choice, and it
39// is made on purpose: gating registration on namespace ownership would mean
40// that on day one, when almost nothing is registered by its own deployer, the
41// registry is empty and teaches nobody anything. The defence against a
42// misleading claim is that it renders as a stranger's claim, ranked below, and
43// never as a fact.
44package gnopm
45
46import (
47 "strings"
48
49 "gno.land/p/nt/avl/v0"
50)
51
52// Registry maps a package path to the claims made about its source.
53//
54// One path may carry several claims, at most one per claimant. That is not a
55// flaw to be resolved: a fork is a legitimate second answer, and so is a third
56// party filling in the map for a package whose author never will.
57type Registry struct {
58 packages *avl.Tree // package path -> *Package
59 claims int
60}
61
62// New returns an empty registry.
63func New() *Registry {
64 return &Registry{packages: avl.NewTree()}
65}
66
67// Package is every claim made about one package path.
68type Package struct {
69 PkgPath string
70 claims *avl.Tree // claimant address string -> *Claim
71}
72
73// Claim is one address's statement about where a package came from.
74//
75// Every field except Claimant, Height and UpdatedAt is attacker-controlled.
76// They are charset-validated at write time rather than escaped at read time, so
77// that a consumer which forgets to escape is still safe: the fields cannot hold
78// a byte that means anything to markdown, to a terminal or to a URL parser.
79type Claim struct {
80 PkgPath string
81 Repo string // https:// URL of the repository
82 Commit string // 40 or 64 lowercase hex
83 Dir string // subdirectory holding the package, "" for the repo root
84 Ref string // fully-qualified ref the commit was on, optional
85 Claimant address
86 Height int64 // block height of the first claim by this claimant
87 // UpdatedAt is the height of the most recent write. Equal to Height until
88 // the claimant re-registers, which is how a reader tells a claim that has
89 // been kept current from one made once and abandoned.
90 UpdatedAt int64
91}
92
93// Register records or replaces claimant's claim about pkgPath.
94//
95// Re-registering the same path overwrites that claimant's own previous claim
96// and nobody else's, so the ordinary "I deployed a new commit" flow is one call
97// with no read first. Height is preserved across an update: it dates the claim,
98// not the edit.
99func (r *Registry) Register(claimant address, height int64, pkgPath, repo, commit, dir, ref string) (*Claim, error) {
100 if !ValidPkgPath(pkgPath) {
101 return nil, ErrInvalidPkgPath
102 }
103 if !ValidRepo(repo) {
104 return nil, ErrInvalidRepo
105 }
106 if !ValidCommit(commit) {
107 return nil, ErrInvalidCommit
108 }
109 if !ValidDir(dir) {
110 return nil, ErrInvalidDir
111 }
112 if !ValidRef(ref) {
113 return nil, ErrInvalidRef
114 }
115
116 p := r.Package(pkgPath)
117 if p == nil {
118 p = &Package{PkgPath: pkgPath, claims: avl.NewTree()}
119 r.packages.Set(pkgPath, p)
120 }
121
122 key := claimant.String()
123 c := &Claim{
124 PkgPath: pkgPath,
125 Repo: repo,
126 Commit: commit,
127 Dir: dir,
128 Ref: ref,
129 Claimant: claimant,
130 Height: height,
131 UpdatedAt: height,
132 }
133 if prev := p.Claim(claimant); prev != nil {
134 c.Height = prev.Height
135 } else {
136 if p.claims.Size() >= MaxClaimants {
137 return nil, ErrTooManyClaimants
138 }
139 r.claims++
140 }
141 p.claims.Set(key, c)
142 return c, nil
143}
144
145// Withdraw removes claimant's own claim about pkgPath. A claimant can always
146// take back what they said; nobody can remove anyone else's.
147func (r *Registry) Withdraw(claimant address, pkgPath string) error {
148 p := r.Package(pkgPath)
149 if p == nil {
150 return ErrClaimNotFound
151 }
152 if _, removed := p.claims.Remove(claimant.String()); !removed {
153 return ErrClaimNotFound
154 }
155 r.claims--
156 if p.claims.Size() == 0 {
157 r.packages.Remove(pkgPath)
158 }
159 return nil
160}
161
162// Package returns every claim about a path, or nil.
163func (r *Registry) Package(pkgPath string) *Package {
164 v := r.packages.Get(pkgPath)
165 if v == nil {
166 return nil
167 }
168 return v.(*Package)
169}
170
171// Size is the number of package paths carrying at least one claim.
172func (r *Registry) Size() int { return r.packages.Size() }
173
174// Claims is the total number of claims across every path.
175func (r *Registry) Claims() int { return r.claims }
176
177// IteratePackages walks paths in lexical order, which groups a namespace's
178// packages together without needing a second index. The callback returns true
179// to STOP, following avl's own convention rather than inverting it here.
180func (r *Registry) IteratePackages(offset, count int, cb func(*Package) bool) {
181 r.packages.IterateByOffset(offset, count, func(_ string, v any) bool {
182 return cb(v.(*Package))
183 })
184}
185
186// IterateNamespace walks every claimed path under "<domain>/<kind>/<ns>/".
187func (r *Registry) IterateNamespace(domain, kind, ns string, cb func(*Package) bool) {
188 prefix := domain + "/" + kind + "/" + ns + "/"
189 r.packages.Iterate(prefix, "", func(k string, v any) bool {
190 if !strings.HasPrefix(k, prefix) {
191 return true // past the prefix: stop
192 }
193 return cb(v.(*Package))
194 })
195}
196
197// Claim returns claimant's claim about this package, or nil.
198func (p *Package) Claim(claimant address) *Claim {
199 v := p.claims.Get(claimant.String())
200 if v == nil {
201 return nil
202 }
203 return v.(*Claim)
204}
205
206// Count is how many addresses have claimed this package.
207func (p *Package) Count() int { return p.claims.Size() }
208
209// IterateClaims walks the claims in claimant-address order. Order is not
210// significance: a caller that wants the owner's claim first must ask OwnedBy,
211// because the registry has no opinion about which claim is true.
212func (p *Package) IterateClaims(cb func(*Claim) bool) {
213 p.claims.Iterate("", "", func(_ string, v any) bool {
214 return cb(v.(*Claim))
215 })
216}
217
218// OwnedBy reports whether c comes from the party that controls the package
219// path's namespace: either the address whose own namespace it is, or whoever
220// resolve says holds the registered name.
221//
222// resolve maps a namespace name to the address holding it and whether that name
223// is held at all. It is a parameter rather than an import because this package
224// stays pure: the realm supplies r/sys/users, and a test supplies a table.
225//
226// This is the one label in the whole design that is derived rather than
227// declared, so it is computed here and never stored. A name can be transferred;
228// a stored answer would age into a lie of exactly the kind this package exists
229// to avoid.
230func (c *Claim) OwnedBy(resolve func(name string) (address, bool)) bool {
231 _, _, ns, _, ok := SplitPkgPath(c.PkgPath)
232 if !ok {
233 return false
234 }
235 if AddressNamespace(ns) {
236 return ns == c.Claimant.String()
237 }
238 if resolve == nil {
239 return false
240 }
241 addr, held := resolve(ns)
242 return held && addr == c.Claimant
243}
244
245// SourceURL builds a browsable link to the claimed source: the commit, plus the
246// directory when the package is not at the repository root.
247//
248// GitHub's "/tree/<commit>/<dir>" layout is also GitLab's, Gitea's, Forgejo's
249// and Codeberg's, so one construction covers every forge this registry is
250// likely to meet. It is a convenience, not a promise the link resolves: the
251// repository may be gone, private or renamed since the claim was made, which is
252// itself something a verifier reports rather than something a realm can know.
253func (c *Claim) SourceURL() string {
254 u := strings.TrimSuffix(c.Repo, "/") + "/tree/" + c.Commit
255 if c.Dir != "" {
256 u += "/" + c.Dir
257 }
258 return u
259}