tinyrelayfile
tinyrelay/tinygit/repository.go/

tinygit/repository.go raw
1package tinygit
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "sort"
11 "strings"
12 "time"
13
14 event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
15)
16
17func (g *GitRelay) repoPath(r Repository) string {
18 if r.Alternative {
19 return filepath.Join(g.root, "prs", r.Owner, r.Identifier+".git")
20 }
21 return filepath.Join(g.root, r.Owner, r.Identifier+".git")
22}
23func (g *GitRelay) statePath(r Repository) string {
24 return filepath.Join(g.repoPath(r), "tinyrelay.refs")
25}
26func (g *GitRelay) pendingStatePath(r Repository) string {
27 return filepath.Join(g.repoPath(r), "tinyrelay.pending")
28}
29
30func (g *GitRelay) pendingHeadPath(r Repository) string {
31 return filepath.Join(g.repoPath(r), "tinyrelay.pending.head")
32}
33
34func (g *GitRelay) stateObjectsPresent(ctx context.Context, r Repository) bool {
35 oids := make([]string, 0, len(r.Refs))
36 for _, oid := range r.Refs {
37 if oid != "" {
38 oids = append(oids, oid)
39 }
40 }
41 return g.objectsPresent(ctx, r, oids)
42}
43
44// objectsPresent checks all object IDs through one long-lived cat-file
45// process. Repository state can contain hundreds of refs; starting one Git
46// process per ref makes promotion latency grow linearly with process startup.
47func (g *GitRelay) objectsPresent(ctx context.Context, r Repository, oids []string) bool {
48 if len(oids) == 0 {
49 return true
50 }
51 cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(r), "cat-file", "--batch-check")
52 var out bytes.Buffer
53 cmd.Stdin = strings.NewReader(strings.Join(oids, "\n") + "\n")
54 cmd.Stdout = &out
55 if err := cmd.Run(); err != nil {
56 return false
57 }
58 lines := strings.Split(strings.TrimSpace(out.String()), "\n")
59 if len(lines) != len(oids) {
60 return false
61 }
62 for _, line := range lines {
63 fields := strings.Fields(line)
64 if len(fields) < 2 || fields[1] == "missing" {
65 return false
66 }
67 }
68 return true
69}
70func (g *GitRelay) ensureRepo(r Repository) error {
71 p := g.repoPath(r)
72 if _, err := os.Stat(filepath.Join(p, "HEAD")); err == nil {
73 return g.configureRepo(r)
74 }
75 if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
76 return err
77 }
78 cmd := exec.Command("git", "init", "--bare", p)
79 if out, err := cmd.CombinedOutput(); err != nil {
80 return fmt.Errorf("git init: %w: %s", err, strings.TrimSpace(string(out)))
81 }
82 return g.configureRepo(r)
83}
84
85func (g *GitRelay) configureRepo(r Repository) error {
86 g.repoConfigMu.Lock()
87 defer g.repoConfigMu.Unlock()
88 if _, ok := g.configured[g.repoPath(r)]; ok {
89 return nil
90 }
91 for _, pair := range [][2]string{{"http.receivepack", "true"}, {"http.uploadpack", "true"}} {
92 cmd := exec.Command("git", "--git-dir", g.repoPath(r), "config", pair[0], pair[1])
93 if out, err := cmd.CombinedOutput(); err != nil {
94 return fmt.Errorf("git config %s: %w: %s", pair[0], err, strings.TrimSpace(string(out)))
95 }
96 }
97 if err := g.installHook(r); err != nil {
98 return err
99 }
100 if g.configured == nil {
101 g.configured = make(map[string]struct{})
102 }
103 g.configured[g.repoPath(r)] = struct{}{}
104 return nil
105}
106
107func (g *GitRelay) installHook(r Repository) error {
108 hook := filepath.Join(g.repoPath(r), "hooks", "pre-receive")
109 state, pending := g.statePath(r), g.pendingStatePath(r)
110 // Load signed maps once per push, not one process per ref. The hook is
111 // static; metadata changes only replace the signed ref files.
112 script := "#!/bin/sh\nset -eu\nTINY_REFS=" + shellQuote(state) + " TINY_PENDING=" + shellQuote(pending) + ` awk '
113BEGIN {
114 while ((getline line < ENVIRON["TINY_REFS"]) > 0) { split(line, row, " "); expected[row[1]]=row[2] }
115 while ((getline line < ENVIRON["TINY_PENDING"]) > 0) { split(line, row, " "); expected[row[1]]=row[2] }
116}
117NF != 3 || expected[$3] != $2 {
118 print "tinyrelay: ref is not authorized by signed repository state" > "/dev/stderr"
119 exit 1
120}
121'
122`
123 if err := atomicExecutable(hook, []byte(script)); err != nil {
124 return err
125 }
126 post := filepath.Join(g.repoPath(r), "hooks", "post-receive")
127 postScript := "#!/bin/sh\nset -eu\nif [ -f " + shellQuote(pending) + " ]; then\n checked=$(awk '{print $2}' " + shellQuote(pending) + " | git cat-file --batch-check) || exit 1\n if printf '%s\\n' \"$checked\" | awk 'NF != 3 || $2 == \"missing\" { bad=1 } END { exit bad }'; then mv -f " + shellQuote(pending) + " " + shellQuote(state) + "; fi\nfi\n"
128 return atomicExecutable(post, []byte(postScript))
129}
130
131func atomicExecutable(path string, data []byte) error {
132 tmp, err := os.CreateTemp(filepath.Dir(path), ".tinyrelay-hook-")
133 if err != nil {
134 return err
135 }
136 tmpName := tmp.Name()
137 defer os.Remove(tmpName)
138 if err := tmp.Chmod(0700); err != nil {
139 _ = tmp.Close()
140 return err
141 }
142 if _, err := tmp.Write(data); err != nil {
143 _ = tmp.Close()
144 return err
145 }
146 if err := tmp.Sync(); err != nil {
147 _ = tmp.Close()
148 return err
149 }
150 if err := tmp.Close(); err != nil {
151 return err
152 }
153 return os.Rename(tmpName, path)
154}
155
156func shellQuote(value string) string {
157 return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
158}
159
160func (g *GitRelay) writeState(r Repository) error {
161 if err := g.ensureRepo(r); err != nil {
162 return err
163 }
164 if err := g.projectNativeRefs(r, r.Refs); err != nil {
165 return err
166 }
167 if err := g.writeRefsFile(g.statePath(r), r.Refs); err != nil {
168 return err
169 }
170 if r.Head != "" {
171 ref := strings.TrimPrefix(r.Head, "ref: ")
172 if out, err := exec.Command("git", "--git-dir", g.repoPath(r), "symbolic-ref", "HEAD", ref).CombinedOutput(); err != nil {
173 return fmt.Errorf("install Git HEAD: %w: %s", err, strings.TrimSpace(string(out)))
174 }
175 }
176 return nil
177}
178
179func (g *GitRelay) projectNativeRefs(r Repository, next map[string]string) error {
180 if len(next) > 0 && !g.objectsPresent(context.Background(), r, refObjectIDs(next)) {
181 // Alternative PR metadata may arrive before its object. Keep the
182 // signed file authoritative for the receive hook, but do not create a
183 // native ref Git cannot resolve until the object is present.
184 return nil
185 }
186 previous, err := g.nativeRefs(r)
187 if err != nil {
188 return err
189 }
190 refs := make(map[string]struct{}, len(previous)+len(next))
191 for ref := range previous {
192 refs[ref] = struct{}{}
193 }
194 for ref := range next {
195 if !internalRef(ref) {
196 refs[ref] = struct{}{}
197 }
198 }
199 keys := make([]string, 0, len(refs))
200 for ref := range refs {
201 keys = append(keys, ref)
202 }
203 sort.Strings(keys)
204 var tx strings.Builder
205 tx.WriteString("start\n")
206 for _, ref := range keys {
207 if oid, ok := next[ref]; ok && !internalRef(ref) {
208 if previousOID, exists := previous[ref]; exists && previousOID == oid {
209 continue
210 }
211 tx.WriteString("update ")
212 tx.WriteString(ref)
213 tx.WriteByte(' ')
214 tx.WriteString(oid)
215 tx.WriteByte('\n')
216 continue
217 }
218 if _, ok := previous[ref]; ok {
219 tx.WriteString("delete ")
220 tx.WriteString(ref)
221 tx.WriteByte('\n')
222 }
223 }
224 tx.WriteString("prepare\ncommit\n")
225 cmd := exec.Command("git", "--git-dir", g.repoPath(r), "update-ref", "--stdin")
226 cmd.Stdin = strings.NewReader(tx.String())
227 if out, err := cmd.CombinedOutput(); err != nil {
228 return fmt.Errorf("project Git refs: %w: %s", err, strings.TrimSpace(string(out)))
229 }
230 return nil
231}
232
233func (g *GitRelay) nativeRefs(r Repository) (map[string]string, error) {
234 cmd := exec.Command("git", "--git-dir", g.repoPath(r), "for-each-ref", "--format=%(refname) %(objectname)")
235 out, err := cmd.Output()
236 if err != nil {
237 return nil, fmt.Errorf("read native Git refs: %w", err)
238 }
239 refs := make(map[string]string)
240 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
241 fields := strings.Fields(line)
242 if len(fields) == 2 && !internalRef(fields[0]) {
243 refs[fields[0]] = fields[1]
244 }
245 }
246 return refs, nil
247}
248
249func refObjectIDs(refs map[string]string) []string {
250 oids := make([]string, 0, len(refs))
251 for _, oid := range refs {
252 if oid != "" {
253 oids = append(oids, oid)
254 }
255 }
256 return oids
257}
258
259func internalRef(ref string) bool { return strings.HasPrefix(ref, "refs/tinyrelay/") }
260
261func readRefsFile(path string) (map[string]string, error) {
262 b, err := os.ReadFile(path)
263 if err != nil {
264 return nil, err
265 }
266 refs := make(map[string]string)
267 for _, line := range strings.Split(string(b), "\n") {
268 fields := strings.Fields(line)
269 if len(fields) == 2 {
270 refs[fields[0]] = fields[1]
271 }
272 }
273 return refs, nil
274}
275
276func (g *GitRelay) writePendingState(r Repository) error {
277 if err := g.ensureRepo(r); err != nil {
278 return err
279 }
280 if err := g.writeRefsFile(g.pendingStatePath(r), r.Refs); err != nil {
281 return err
282 }
283 if r.Head == "" {
284 return nil
285 }
286 return os.WriteFile(g.pendingHeadPath(r), []byte(r.Head+"\n"), 0600)
287}
288
289func (g *GitRelay) writeRefsFile(path string, refs map[string]string) error {
290 keys := make([]string, 0, len(refs))
291 for ref := range refs {
292 keys = append(keys, ref)
293 }
294 sort.Strings(keys)
295 var b strings.Builder
296 for _, ref := range keys {
297 b.WriteString(ref)
298 b.WriteByte(' ')
299 b.WriteString(refs[ref])
300 b.WriteByte('\n')
301 }
302 tmp := path + ".tmp"
303 if err := os.WriteFile(tmp, []byte(b.String()), 0600); err != nil {
304 return err
305 }
306 if err := syncFile(tmp); err != nil {
307 return err
308 }
309 return os.Rename(tmp, path)
310}
311
312func (g *GitRelay) lookup(owner, id string) (Repository, error) {
313 g.mu.RLock()
314 r, ok := g.repos[key(owner, id)]
315 g.mu.RUnlock()
316 if ok {
317 return r, nil
318 }
319 q, err := g.store.Query(context.Background(), event.Filter{Authors: []string{owner}, Kinds: []int{30617}, Tags: map[string][]string{"d": {id}}}, time.Now().Unix(), 1)
320 if err != nil {
321 return Repository{}, err
322 }
323 if len(q) == 0 {
324 return Repository{}, os.ErrNotExist
325 }
326 r, err = g.parseRepository(q[0])
327 if err != nil {
328 return Repository{}, err
329 }
330 // The event store may acknowledge a state before the asynchronous Git
331 // work intent has run. Load the latest signed state here so an immediate
332 // receive-pack request can stage its authorization hook synchronously.
333 // A maintainer, including an agent the host vouches for, may have signed
334 // it; parseRepository rejects any author who is not one.
335 states, stateErr := g.store.Query(context.Background(), event.Filter{Kinds: []int{30618}, Tags: map[string][]string{"d": {id}}}, time.Now().Unix(), 8)
336 if stateErr == nil {
337 for _, candidate := range states {
338 state, parseErr := g.parseRepository(candidate)
339 if parseErr != nil || state.Owner != owner {
340 continue
341 }
342 state.Clone, state.Relays, state.Maintainers = r.Clone, r.Relays, r.Maintainers
343 r = state
344 break
345 }
346 }
347 g.mu.Lock()
348 g.repos[key(owner, id)] = r
349 g.mu.Unlock()
350 return r, nil
351}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs