clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / tinygit / pr_repair.go
pr_repair.go 10.2K
1package tinygit23import (4 "bufio"5 "context"6 "errors"7 "fmt"8 "os"9 "os/exec"10 "path/filepath"11 "strings"12 "time"1314 event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"15)1617const (18 // PR repair is fed by clone URLs carried in signed events. Keep its19 // temporary object graph finite while leaving ordinary branch repair20 // compatible with complete repositories.21 prRepairDepth = 12822 prRepairMaxBytes = 256 << 2023)2425// PullRequestRepair groups a PR with its later tip updates. The event store26// remains the authority for these events; this value only describes the Git27// objects that GRASP-02 should fetch for them.28type PullRequestRepair struct {29 Root event.Event30 Updates []event.Event31}3233// RepairPullRequestObjects fetches the commits named by a NIP-34 PR and its34// 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 err42 }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 only47 // contribute temporary refs and clone sources.48 root = hosted49 root.Alternative = false50 root.EventID = set.Root.ID51 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 err56 }57 commit := event.Tag(update, "c")58 if commit == "" {59 continue60 }61 refs["refs/nostr/"+update.ID] = commit62 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 nil72}7374// stagePullRequestObjects admits an untrusted PR clone into a quarantine bare75// repository first. Only after every expected object is present do we import76// the temporary refs into the hosted repository and install the signed refs.77// The quarantine is removed on return, and failed repairs never alter hosted78// 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 err82 }83 // Keep the quarantine outside the hosted repository namespace. A clone URL84 // 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, but103 // 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 err114 }115 if err := validatePRQuarantine(ctx, stagePath, nonEmptyObjectIDs(expected)); err != nil {116 return err117 }118119 // Import into disposable refs. If a later refspec fails, clean them up and120 // leave the signed hosted refs untouched.121 cleanup := make([]string, 0, len(expected))122 defer func() {123 if len(cleanup) == 0 {124 return125 }126 var commands strings.Builder127 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 continue139 }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 nil160}161162// validatePRQuarantine checks the complete object graph after removing the163// shallow marker. rev-list reports missing commits, trees and blobs through164// 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 a200 // 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 nil209}210211func 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 ids219}220221func hasRepositoryCoordinate(pr event.Event, repo Repository) bool {222 want := "30617:" + repo.Owner + ":" + repo.Identifier223 for _, tag := range pr.Tags {224 if len(tag) > 1 && (tag[0] == "a" || tag[0] == "A") && tag[1] == want {225 return true226 }227 }228 return false229}230231func cloneURLs(e event.Event) []string {232 var out []string233 for _, tag := range e.Tags {234 if len(tag) > 1 && tag[0] == "clone" {235 out = append(out, tag[1:]...)236 }237 }238 return out239}240241func pullRequestTarget(raw event.Event, kind int) (Repository, error) {242 if err := event.Validate(raw); err != nil {243 return Repository{}, err244 }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 }, nil260}261262func validatePullRequestUpdate(root, update event.Event) error {263 if err := event.Validate(update); err != nil {264 return err265 }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 nil287}288289func 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 continue295 }296 seen[value] = struct{}{}297 out = append(out, value)298 }299 return out300}