| 1 | package tinygit |
| 2 | |
| 3 | import ( |
| 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 | |
| 17 | const ( |
| 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. |
| 28 | type 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. |
| 38 | func (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. |
| 79 | func (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. |
| 165 | func 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 | |
| 211 | func 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 | |
| 221 | func 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 | |
| 231 | func 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 | |
| 241 | func 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 | |
| 262 | func 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 | |
| 289 | func 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 | } |