ref.gno
7.88 Kb · 264 lines
1package forge
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "strconv"
7 "strings"
8)
9
10// Log entry kinds.
11const (
12 KindCreate = "create" // a ref that did not exist now points somewhere
13 KindUpdate = "update" // compare-and-swap succeeded
14 KindForce = "force" // the tip was replaced without a matching expectation
15 KindDelete = "delete" // the ref is gone (the log is not)
16 KindMerge = "merge" // an update performed by merging a change request
17)
18
19// Ref is the current state of one reference. The history of how it got here is
20// in the repo log, which nothing can rewrite.
21type Ref struct {
22 Name string
23 OID string
24 UpdatedAt int64
25 UpdatedBy address
26}
27
28// LogEntry is one link of the repo's reference log. The log is append-only and
29// hash-chained: Digest commits to every earlier entry, so publishing a single
30// digest (in a release note, a package manifest, a tweet) pins the entire
31// history of every ref up to that point.
32type LogEntry struct {
33 Seq int64
34 Ref string
35 OldOID string // "" when the ref did not exist
36 NewOID string // "" on delete
37 Actor address
38 Height int64
39 Kind string
40 ChangeID int64 // the merged change, 0 otherwise
41 Note string
42 Digest string // hex sha256 over the previous digest and this entry
43}
44
45// EntryDigest computes the chain digest of e given the previous entry's digest.
46// It is exported so an off-chain verifier can recompute the chain byte for byte
47// from the values it read back; the field order below is the wire format and
48// must not change within a version.
49func EntryDigest(prev string, e *LogEntry) string {
50 fields := []string{
51 prev,
52 strconv.FormatInt(e.Seq, 10),
53 e.Ref,
54 e.OldOID,
55 e.NewOID,
56 e.Actor.String(),
57 strconv.FormatInt(e.Height, 10),
58 e.Kind,
59 strconv.FormatInt(e.ChangeID, 10),
60 e.Note,
61 }
62 sum := sha256.Sum256([]byte(strings.Join(fields, "\n")))
63 return hex.EncodeToString(sum[:])
64}
65
66// appendLog writes one entry and advances the chain head. Callers have already
67// authorized and validated; this never fails.
68func (r *Repo) appendLog(actor address, height int64, ref, oldOID, newOID, kind string, changeID int64, note string) *LogEntry {
69 e := &LogEntry{
70 Seq: r.nextSeq,
71 Ref: ref,
72 OldOID: oldOID,
73 NewOID: newOID,
74 Actor: actor,
75 Height: height,
76 Kind: kind,
77 ChangeID: changeID,
78 Note: note,
79 }
80 e.Digest = EntryDigest(r.head, e)
81 r.log.Set(seqKey(e.Seq), e)
82 r.head = e.Digest
83 r.nextSeq++
84 return e
85}
86
87// SetRef moves a ref by compare-and-swap: expectedOID must be the tip the
88// caller last saw ("" to create a ref that does not exist yet). This is git's
89// --force-with-lease, except the lease is held by consensus rather than by the
90// server you are pushing to, so a concurrent push cannot be silently lost and a
91// rewritten history cannot be presented as if it had always been that way.
92//
93// The chain cannot check that newOID descends from expectedOID: it has no
94// objects. That check belongs to a client holding the repo, which is exactly
95// why every move is recorded rather than merely applied.
96func (r *Repo) SetRef(actor address, height int64, name, expectedOID, newOID, note string) (*LogEntry, error) {
97 if r.Archived {
98 return nil, ErrRepoArchived
99 }
100 if !r.Can(actor, RoleWriter) {
101 return nil, ErrUnauthorized
102 }
103 if !ValidRefName(name) {
104 return nil, ErrInvalidRefName
105 }
106 if !ValidOID(newOID) {
107 return nil, ErrInvalidOID
108 }
109 if !ValidLine(note, MaxNoteLen) {
110 return nil, ErrInvalidText
111 }
112 cur := r.Ref(name)
113 switch {
114 case cur == nil && expectedOID != "":
115 return nil, ErrRefNotFound
116 case cur != nil && expectedOID == "":
117 return nil, ErrRefExists
118 case cur != nil && cur.OID != expectedOID:
119 return nil, ErrStaleRef
120 case cur != nil && cur.OID == newOID:
121 return nil, ErrSameOID
122 }
123 kind := KindUpdate
124 old := ""
125 if cur == nil {
126 kind = KindCreate
127 } else {
128 old = cur.OID
129 }
130 r.refs.Set(name, &Ref{Name: name, OID: newOID, UpdatedAt: height, UpdatedBy: actor})
131 return r.appendLog(actor, height, name, old, newOID, kind, 0, note), nil
132}
133
134// ForceSetRef moves a ref without an expectation. It needs RoleMaintainer and
135// is permanently recorded as KindForce: the point is not to forbid a force-push
136// (sometimes it is the right call) but to make one impossible to hide.
137func (r *Repo) ForceSetRef(actor address, height int64, name, newOID, note string) (*LogEntry, error) {
138 if r.Archived {
139 return nil, ErrRepoArchived
140 }
141 if !r.Can(actor, RoleMaintainer) {
142 return nil, ErrUnauthorized
143 }
144 if !ValidRefName(name) {
145 return nil, ErrInvalidRefName
146 }
147 if !ValidOID(newOID) {
148 return nil, ErrInvalidOID
149 }
150 if !ValidLine(note, MaxNoteLen) {
151 return nil, ErrInvalidText
152 }
153 old := ""
154 if cur := r.Ref(name); cur != nil {
155 if cur.OID == newOID {
156 return nil, ErrSameOID
157 }
158 old = cur.OID
159 }
160 r.refs.Set(name, &Ref{Name: name, OID: newOID, UpdatedAt: height, UpdatedBy: actor})
161 return r.appendLog(actor, height, name, old, newOID, KindForce, 0, note), nil
162}
163
164// DeleteRef removes a ref by compare-and-swap. The ref disappears from the
165// current state; the log keeps every object it ever pointed at.
166//
167// Maintainer-only for an economic reason as much as a safety one: on gno.land
168// the storage-deposit refund goes to whoever frees the bytes, not to whoever
169// paid for them (`receiver := caller` in the vm keeper's deposit path, gno
170// master 2026-09-19), so an open delete path pays for vandalism.
171func (r *Repo) DeleteRef(actor address, height int64, name, expectedOID, note string) (*LogEntry, error) {
172 if r.Archived {
173 return nil, ErrRepoArchived
174 }
175 if !r.Can(actor, RoleMaintainer) {
176 return nil, ErrUnauthorized
177 }
178 if !ValidLine(note, MaxNoteLen) {
179 return nil, ErrInvalidText
180 }
181 cur := r.Ref(name)
182 if cur == nil {
183 return nil, ErrRefNotFound
184 }
185 if cur.OID != expectedOID {
186 return nil, ErrStaleRef
187 }
188 if name == r.DefaultRef {
189 return nil, ErrUnauthorized // the default branch is not deletable
190 }
191 r.refs.Remove(name)
192 return r.appendLog(actor, height, name, cur.OID, "", KindDelete, 0, note), nil
193}
194
195// Ref returns the current state of a ref, or nil.
196func (r *Repo) Ref(name string) *Ref {
197 v := r.refs.Get(name)
198 if v == nil {
199 return nil
200 }
201 return v.(*Ref)
202}
203
204// IterateRefs walks refs in name order.
205func (r *Repo) IterateRefs(cb func(*Ref) bool) {
206 r.refs.Iterate("", "", func(_ string, value any) bool {
207 return cb(value.(*Ref))
208 })
209}
210
211// LogHead is the digest of the last entry, "" for an empty log. Pin this value
212// anywhere off chain and the whole history becomes falsifiable.
213func (r *Repo) LogHead() string { return r.head }
214
215// LogSize is the number of entries ever appended.
216func (r *Repo) LogSize() int { return r.log.Size() }
217
218// LogEntryAt returns one entry by sequence number, or nil.
219func (r *Repo) LogEntryAt(seq int64) *LogEntry {
220 v := r.log.Get(seqKey(seq))
221 if v == nil {
222 return nil
223 }
224 return v.(*LogEntry)
225}
226
227// IterateLog walks the log oldest-first.
228func (r *Repo) IterateLog(offset, count int, cb func(*LogEntry) bool) {
229 if count <= 0 {
230 count = r.log.Size()
231 }
232 r.log.IterateByOffset(offset, count, func(_ string, value any) bool {
233 return cb(value.(*LogEntry))
234 })
235}
236
237// IterateLogReverse walks the log newest-first, which is what a UI wants.
238func (r *Repo) IterateLogReverse(offset, count int, cb func(*LogEntry) bool) {
239 if count <= 0 {
240 count = r.log.Size()
241 }
242 r.log.ReverseIterateByOffset(offset, count, func(_ string, value any) bool {
243 return cb(value.(*LogEntry))
244 })
245}
246
247// VerifyLog recomputes the whole digest chain and reports the first entry whose
248// digest does not follow from its predecessor. It should be impossible on a
249// live chain: it is here because a transparency log nobody can verify is just
250// a log, and a client should be running this against the values it read back.
251func (r *Repo) VerifyLog() (bool, int64) {
252 prev := ""
253 bad := int64(-1)
254 r.log.Iterate("", "", func(_ string, value any) bool {
255 e := value.(*LogEntry)
256 if EntryDigest(prev, e) != e.Digest {
257 bad = e.Seq
258 return true
259 }
260 prev = e.Digest
261 return false
262 })
263 return bad < 0, bad
264}