tinyrelayfile
tinyrelay/tinygit/pr_repair.go/

tinygit/pr_repair.go raw
1package tinygit
2
3import (
4 "bufio"
5 "context"
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "time"
13
14 event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
15)
16
17const (
18 // PR repair is fed by clone URLs carried in signed events. Keep its
19 // temporary object graph finite while leaving ordinary branch repair
20 // compatible with complete repositories.
21 prRepairDepth = 128
22 prRepairMaxBytes = 256 << 20
23)
24
25// PullRequestRepair groups a PR with its later tip updates. The event store
26// remains the authority for these events; this value only describes the Git
27// objects that GRASP-02 should fetch for them.
28type PullRequestRepair struct {
29 Root event.Event
30 Updates []event.Event
31}
32
33// RepairPullRequestObjects fetches the commits named by a NIP-34 PR and its
34// updates into the supplied hosted repository.
35//
36// Updates are accepted only from the PR author and must point to the root PR.
37// This prevents an unrelated event from supplying a ref to a hosted PR.
38func (g *GitRelay) RepairPullRequestObjects(ctx context.Context, hosted Repository, set PullRequestRepair) error {
39 root, err := pullRequestTarget(set.Root, 1618)
40 if err != nil {
41 return err
42 }
43 if !hasRepositoryCoordinate(set.Root, hosted) {
44 return errors.New("blocked: pull request targets another repository")
45 }
46 // The hosted repository owns its path and access policy. PR events only
47 // contribute temporary refs and clone sources.
48 root = hosted
49 root.Alternative = false
50 root.EventID = set.Root.ID
51 sources := cloneURLs(set.Root)
52 refs := map[string]string{"refs/nostr/" + set.Root.ID: event.Tag(set.Root, "c")}
53 for _, update := range set.Updates {
54 if err := validatePullRequestUpdate(set.Root, update); err != nil {
55 return err
56 }
57 commit := event.Tag(update, "c")
58 if commit == "" {
59 continue
60 }
61 refs["refs/nostr/"+update.ID] = commit
62 sources = append(sources, cloneURLs(update)...)
63 }
64 if len(sources) == 0 {
65 return errors.New("GRASP-02: pull request has no clone source")
66 }
67 sources = uniqueStrings(sources)
68 if err := g.stagePullRequestObjects(ctx, root, sources, refs); err != nil {
69 return fmt.Errorf("GRASP-02 pull request repair: %w", err)
70 }
71 return nil
72}
73
74// stagePullRequestObjects admits an untrusted PR clone into a quarantine bare
75// repository first. Only after every expected object is present do we import
76// the temporary refs into the hosted repository and install the signed refs.
77// The quarantine is removed on return, and failed repairs never alter hosted
78// refs. In particular, its shallow boundary is never copied to the host.
79func (g *GitRelay) stagePullRequestObjects(ctx context.Context, hosted Repository, sources []string, expected map[string]string) error {
80 if err := g.ensureRepo(hosted); err != nil {
81 return err
82 }
83 // Keep the quarantine outside the hosted repository namespace. A clone URL
84 // is signed input and can be processed concurrently with another repair.
85 quarantineRoot, err := os.MkdirTemp("", "tinyrelay-pr-repair-")
86 if err != nil {
87 return fmt.Errorf("create PR repair quarantine: %w", err)
88 }
89 defer os.RemoveAll(quarantineRoot)
90 stage := Repository{Owner: hosted.Owner, Identifier: "objects", Private: hosted.Private}
91 g.mu.RLock()
92 stageRelay := &GitRelay{
93 root: quarantineRoot, policy: g.policy, serviceURL: g.serviceURL,
94 allowPrivate: g.allowPrivate, privatePeers: append([]string(nil), g.privatePeers...),
95 httpAuth: g.httpAuth, configured: make(map[string]struct{}),
96 }
97 g.mu.RUnlock()
98 if err := stageRelay.ensureRepo(stage); err != nil {
99 return fmt.Errorf("create PR repair quarantine: %w", err)
100 }
101 stagePath := stageRelay.repoPath(stage)
102 // Let a PR build on objects already admitted to the hosted repository, but
103 // never copy the quarantine's shallow boundary into that repository.
104 alternates := filepath.Join(stagePath, "objects", "info", "alternates")
105 hostObjects, err := filepath.Abs(filepath.Join(g.repoPath(hosted), "objects"))
106 if err != nil {
107 return fmt.Errorf("locate hosted PR base objects: %w", err)
108 }
109 if err := os.WriteFile(alternates, []byte(hostObjects+"\n"), 0600); err != nil {
110 return fmt.Errorf("configure PR repair quarantine: %w", err)
111 }
112 if err := stageRelay.fetchMissing(ctx, stage, sources, expected, prRepairDepth, prRepairMaxBytes); err != nil {
113 return err
114 }
115 if err := validatePRQuarantine(ctx, stagePath, nonEmptyObjectIDs(expected)); err != nil {
116 return err
117 }
118
119 // Import into disposable refs. If a later refspec fails, clean them up and
120 // leave the signed hosted refs untouched.
121 cleanup := make([]string, 0, len(expected))
122 defer func() {
123 if len(cleanup) == 0 {
124 return
125 }
126 var commands strings.Builder
127 for _, ref := range cleanup {
128 fmt.Fprintf(&commands, "delete %s\n", ref)
129 }
130 cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
131 defer cancel()
132 cmd := exec.CommandContext(cleanupCtx, "git", "--git-dir", g.repoPath(hosted), "update-ref", "--stdin")
133 cmd.Stdin = strings.NewReader(commands.String())
134 _ = cmd.Run()
135 }()
136 for ref, oid := range expected {
137 if oid == "" {
138 continue
139 }
140 staged := "refs/tinyrelay/pr-repair/" + filepath.Base(quarantineRoot) + "/" + safeRef(ref)
141 cleanup = append(cleanup, staged)
142 cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(hosted), "fetch", "--no-tags", stagePath, "+"+oid+":"+staged)
143 cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1")
144 if out, err := cmd.CombinedOutput(); err != nil {
145 return fmt.Errorf("import PR repair object %s: %w: %s", oid, err, strings.TrimSpace(string(out)))
146 }
147 }
148 updates := make([]string, 0, len(expected))
149 for ref, oid := range expected {
150 if oid != "" {
151 updates = append(updates, "update "+ref+" "+oid+"\n")
152 }
153 }
154 cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(hosted), "update-ref", "--stdin")
155 cmd.Stdin = strings.NewReader("start\n" + strings.Join(updates, "") + "prepare\ncommit\n")
156 if out, err := cmd.CombinedOutput(); err != nil {
157 return fmt.Errorf("promote PR repair refs: %w: %s", err, strings.TrimSpace(string(out)))
158 }
159 return nil
160}
161
162// validatePRQuarantine checks the complete object graph after removing the
163// shallow marker. rev-list reports missing commits, trees and blobs through
164// its --missing output; alternates make hosted base objects visible here.
165func validatePRQuarantine(ctx context.Context, gitDir string, oids []string) error {
166 if len(oids) == 0 {
167 return errors.New("PR repair has no expected objects")
168 }
169 if err := os.Remove(filepath.Join(gitDir, "shallow")); err != nil && !errors.Is(err, os.ErrNotExist) {
170 return fmt.Errorf("remove PR repair shallow boundary: %w", err)
171 }
172 args := append([]string{"--git-dir", gitDir, "rev-list", "--objects", "--missing=print"}, oids...)
173 cmd := exec.CommandContext(ctx, "git", args...)
174 stdout, err := cmd.StdoutPipe()
175 if err != nil {
176 return fmt.Errorf("validate PR repair object graph: create pipe: %w", err)
177 }
178 if err := cmd.Start(); err != nil {
179 return fmt.Errorf("validate PR repair object graph: %w", err)
180 }
181 scanner := bufio.NewScanner(stdout)
182 scanner.Buffer(make([]byte, 4096), 1<<20)
183 for scanner.Scan() {
184 line := scanner.Text()
185 if strings.HasPrefix(line, "?") {
186 _ = cmd.Process.Kill()
187 _ = cmd.Wait()
188 return fmt.Errorf("PR repair object graph is incomplete: %s", strings.TrimSpace(line))
189 }
190 }
191 if err := scanner.Err(); err != nil {
192 _ = cmd.Process.Kill()
193 _ = cmd.Wait()
194 return fmt.Errorf("validate PR repair object graph: read output: %w", err)
195 }
196 if err := cmd.Wait(); err != nil {
197 return fmt.Errorf("validate PR repair object graph: %w", err)
198 }
199 // Ensure each advertised tip is an actual commit, rather than accepting a
200 // tree or blob object under a commit-shaped event tag.
201 for _, oid := range oids {
202 check := exec.CommandContext(ctx, "git", "--git-dir", gitDir, "cat-file", "-t", oid)
203 kind, err := check.Output()
204 if err != nil || strings.TrimSpace(string(kind)) != "commit" {
205 return fmt.Errorf("PR repair tip %s is not a complete commit", oid)
206 }
207 }
208 return nil
209}
210
211func nonEmptyObjectIDs(refs map[string]string) []string {
212 ids := make([]string, 0, len(refs))
213 for _, oid := range refs {
214 if oid != "" {
215 ids = append(ids, oid)
216 }
217 }
218 return ids
219}
220
221func hasRepositoryCoordinate(pr event.Event, repo Repository) bool {
222 want := "30617:" + repo.Owner + ":" + repo.Identifier
223 for _, tag := range pr.Tags {
224 if len(tag) > 1 && (tag[0] == "a" || tag[0] == "A") && tag[1] == want {
225 return true
226 }
227 }
228 return false
229}
230
231func cloneURLs(e event.Event) []string {
232 var out []string
233 for _, tag := range e.Tags {
234 if len(tag) > 1 && tag[0] == "clone" {
235 out = append(out, tag[1:]...)
236 }
237 }
238 return out
239}
240
241func pullRequestTarget(raw event.Event, kind int) (Repository, error) {
242 if err := event.Validate(raw); err != nil {
243 return Repository{}, err
244 }
245 if raw.Kind != kind {
246 return Repository{}, fmt.Errorf("unsupported: event kind %d", raw.Kind)
247 }
248 commit := event.Tag(raw, "c")
249 if !isObjectID(commit) {
250 return Repository{}, errors.New("invalid: pull request commit is not a SHA-1")
251 }
252 return Repository{
253 Owner: raw.PubKey,
254 Identifier: raw.ID,
255 EventID: raw.ID,
256 Alternative: true,
257 Clone: cloneURLs(raw),
258 Refs: map[string]string{"refs/nostr/" + raw.ID: commit},
259 }, nil
260}
261
262func validatePullRequestUpdate(root, update event.Event) error {
263 if err := event.Validate(update); err != nil {
264 return err
265 }
266 if update.Kind != 1619 {
267 return fmt.Errorf("unsupported: event kind %d is not a pull request update", update.Kind)
268 }
269 parent := event.Tag(update, "E")
270 if parent == "" {
271 parent = event.Tag(update, "e")
272 }
273 if parent != root.ID {
274 return errors.New("blocked: pull request update does not reference its root")
275 }
276 declaredAuthor := event.Tag(update, "P")
277 if declaredAuthor != root.PubKey {
278 return errors.New("blocked: pull request update author tag does not match root")
279 }
280 if update.PubKey != root.PubKey {
281 return errors.New("blocked: pull request update signer is not the PR author")
282 }
283 if !isObjectID(event.Tag(update, "c")) {
284 return errors.New("invalid: pull request update commit is not a SHA-1")
285 }
286 return nil
287}
288
289func uniqueStrings(values []string) []string {
290 seen := make(map[string]struct{}, len(values))
291 out := make([]string, 0, len(values))
292 for _, value := range values {
293 if _, ok := seen[value]; ok {
294 continue
295 }
296 seen[value] = struct{}{}
297 out = append(out, value)
298 }
299 return out
300}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs