| 1 | package tinygit |
| 2 | |
| 3 | import ( |
| 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 | |
| 17 | func (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 | } |
| 23 | func (g *GitRelay) statePath(r Repository) string { |
| 24 | return filepath.Join(g.repoPath(r), "tinyrelay.refs" ) |
| 25 | } |
| 26 | func (g *GitRelay) pendingStatePath(r Repository) string { |
| 27 | return filepath.Join(g.repoPath(r), "tinyrelay.pending" ) |
| 28 | } |
| 29 | |
| 30 | func (g *GitRelay) pendingHeadPath(r Repository) string { |
| 31 | return filepath.Join(g.repoPath(r), "tinyrelay.pending.head" ) |
| 32 | } |
| 33 | |
| 34 | func (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. |
| 47 | func (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 | } |
| 70 | func (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 | |
| 85 | func (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 | |
| 107 | func (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 ' |
| 113 | BEGIN { |
| 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 | } |
| 117 | NF != 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 | |
| 131 | func 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 | |
| 156 | func shellQuote(value string) string { |
| 157 | return "'" + strings.ReplaceAll(value, "'" , "'\\''" ) + "'" |
| 158 | } |
| 159 | |
| 160 | func (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 | |
| 179 | func (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 | |
| 233 | func (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 | |
| 249 | func 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 | |
| 259 | func internalRef(ref string) bool { return strings.HasPrefix(ref, "refs/tinyrelay/" ) } |
| 260 | |
| 261 | func 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 | |
| 276 | func (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 | |
| 289 | func (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 | |
| 312 | func (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 | } |