larc r26

658 lines ยท 17.7 KB Raw
1 package server
2
3 import (
4 "bytes"
5 "fmt"
6 "io"
7 "log/slog"
8 "sort"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/go-git/go-git/v5"
14 "github.com/go-git/go-git/v5/plumbing"
15 "github.com/go-git/go-git/v5/plumbing/filemode"
16 "github.com/go-git/go-git/v5/plumbing/format/packfile"
17 "github.com/go-git/go-git/v5/plumbing/format/pktline"
18 "github.com/go-git/go-git/v5/plumbing/object"
19 "github.com/go-git/go-git/v5/plumbing/protocol/packp"
20 "github.com/go-git/go-git/v5/plumbing/storer"
21 "github.com/go-git/go-git/v5/storage/memory"
22 "github.com/gofiber/fiber/v2"
23
24 "larc.wejust.rest/larc/internal/convert"
25 "larc.wejust.rest/larc/internal/core"
26 "larc.wejust.rest/larc/internal/repo"
27 )
28
29 /* Git Smart HTTP handlers for transparent git client support.
30 * Converts larc repositories to git format on-the-fly.
31 * READ-ONLY: git clone/fetch supported, push is rejected.
32 *
33 * Endpoints:
34 * - GET /:repo.git/info/refs?service=git-upload-pack (clone/fetch)
35 * - POST /:repo.git/git-upload-pack (clone/fetch data)
36 * - git push -> rejected! pls use larc CLI */
37
38 // gitRepoCache caches converted git repositories
39 type gitRepoCache struct {
40 mu sync.RWMutex
41 repos map[string]*gitCacheEntry
42 }
43
44 type gitCacheEntry struct {
45 gitRepo *git.Repository
46 mapping *convert.MappingStore
47 larcRev int64 // larc revision when cache was built
48 createdAt time.Time
49 }
50
51 var gitCache = &gitRepoCache{
52 repos: make(map[string]*gitCacheEntry),
53 }
54
55 const gitCacheTTL = 5 * time.Minute
56
57 // getOrCreateGitRepo gets or creates a git repository from larc
58 func (s *Server) getOrCreateGitRepo(repoName string, larcRepo *repo.Repository) (*git.Repository, *convert.MappingStore, error) {
59 /* check current larc revision */
60 currentRev, err := larcRepo.Meta.GetLatestRevision()
61 if err != nil {
62 return nil, nil, fmt.Errorf("get latest revision: %w", err)
63 }
64
65 /* check cache */
66 gitCache.mu.RLock()
67 entry, ok := gitCache.repos[repoName]
68 gitCache.mu.RUnlock()
69
70 if ok && entry.larcRev == currentRev && time.Since(entry.createdAt) < gitCacheTTL {
71 return entry.gitRepo, entry.mapping, nil
72 }
73
74 /* create new git repo in memory */
75 gitCache.mu.Lock()
76 defer gitCache.mu.Unlock()
77
78 /* double-check after acquiring write lock */
79 entry, ok = gitCache.repos[repoName]
80 if ok && entry.larcRev == currentRev && time.Since(entry.createdAt) < gitCacheTTL {
81 return entry.gitRepo, entry.mapping, nil
82 }
83
84 slog.Debug("building git cache for repo", "name", repoName, "rev", currentRev)
85
86 gitRepo, mapping, err := buildGitRepo(larcRepo)
87 if err != nil {
88 return nil, nil, fmt.Errorf("build git repo: %w", err)
89 }
90
91 gitCache.repos[repoName] = &gitCacheEntry{
92 gitRepo: gitRepo,
93 mapping: mapping,
94 larcRev: currentRev,
95 createdAt: time.Now(),
96 }
97
98 return gitRepo, mapping, nil
99 }
100
101 // buildGitRepo builds an in-memory git repository from larc
102 func buildGitRepo(larcRepo *repo.Repository) (*git.Repository, *convert.MappingStore, error) {
103 storage := memory.NewStorage()
104 gitRepo, err := git.Init(storage, nil)
105 if err != nil {
106 return nil, nil, fmt.Errorf("init git repo: %w", err)
107 }
108
109 mapping := convert.NewMappingStore()
110
111 /* get all revisions */
112 latestRev, err := larcRepo.Meta.GetLatestRevision()
113 if err != nil {
114 return nil, nil, fmt.Errorf("get latest revision: %w", err)
115 }
116
117 if latestRev == 0 {
118 /* empty repo */
119 return gitRepo, mapping, nil
120 }
121
122 allRevs, err := larcRepo.Meta.ListRevisions("", int(latestRev), 0)
123 if err != nil {
124 return nil, nil, fmt.Errorf("list revisions: %w", err)
125 }
126
127 /* sort oldest first */
128 sort.Slice(allRevs, func(i, j int) bool {
129 return allRevs[i].Number < allRevs[j].Number
130 })
131
132 /* convert each revision */
133 for _, rev := range allRevs {
134 sha, err := convertRevisionToGit(larcRepo, gitRepo, rev, mapping)
135 if err != nil {
136 return nil, nil, fmt.Errorf("convert r%d: %w", rev.Number, err)
137 }
138 mapping.AddRevisionMapping(rev.Number, sha)
139 }
140
141 /* create branch refs */
142 branches, err := larcRepo.Meta.ListBranches()
143 if err != nil {
144 return nil, nil, fmt.Errorf("list branches: %w", err)
145 }
146
147 for _, branch := range branches {
148 commitSHA, ok := mapping.GetGitSHA(branch.HeadRev)
149 if !ok {
150 continue
151 }
152
153 gitBranchName := convert.ConvertBranchName(branch.Name)
154 refName := plumbing.NewBranchReferenceName(gitBranchName)
155 ref := plumbing.NewHashReference(refName, plumbing.NewHash(commitSHA))
156 if err := storage.SetReference(ref); err != nil {
157 slog.Warn("failed to set branch ref", "branch", gitBranchName, "error", err)
158 }
159 }
160
161 /* set HEAD */
162 headRef := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main"))
163 storage.SetReference(headRef)
164
165 return gitRepo, mapping, nil
166 }
167
168 // convertRevisionToGit converts a single larc revision to git commit
169 func convertRevisionToGit(
170 larcRepo *repo.Repository,
171 gitRepo *git.Repository,
172 rev *core.Revision,
173 mapping *convert.MappingStore,
174 ) (string, error) {
175 /* get tree */
176 tree, err := larcRepo.GetTree(rev.TreeHash)
177 if err != nil {
178 return "", fmt.Errorf("get tree: %w", err)
179 }
180
181 /* build hierarchical tree */
182 hierarchical := convert.FlatTreeToHierarchical(tree.Entries)
183
184 /* create git tree */
185 rootTreeHash, err := createGitTreeRecursive(larcRepo, gitRepo, hierarchical, mapping)
186 if err != nil {
187 return "", fmt.Errorf("create git tree: %w", err)
188 }
189
190 /* determine parents */
191 var parents []plumbing.Hash
192 if rev.Parent > 0 {
193 if parentSHA, ok := mapping.GetGitSHA(rev.Parent); ok {
194 parents = append(parents, plumbing.NewHash(parentSHA))
195 }
196 }
197 if rev.MergeParent > 0 {
198 if mergeParentSHA, ok := mapping.GetGitSHA(rev.MergeParent); ok {
199 parents = append(parents, plumbing.NewHash(mergeParentSHA))
200 }
201 }
202
203 /* create commit */
204 commit := &object.Commit{
205 Author: object.Signature{
206 Name: rev.Author,
207 Email: fmt.Sprintf("%s@larc", rev.Author),
208 When: time.Unix(rev.Timestamp, 0),
209 },
210 Committer: object.Signature{
211 Name: rev.Author,
212 Email: fmt.Sprintf("%s@larc", rev.Author),
213 When: time.Unix(rev.Timestamp, 0),
214 },
215 Message: convert.FormatRevisionMessage(rev.Message, rev.Number),
216 TreeHash: rootTreeHash,
217 ParentHashes: parents,
218 }
219
220 commitObj := gitRepo.Storer.NewEncodedObject()
221 commitObj.SetType(plumbing.CommitObject)
222
223 if err := commit.Encode(commitObj); err != nil {
224 return "", fmt.Errorf("encode commit: %w", err)
225 }
226
227 commitHash, err := gitRepo.Storer.SetEncodedObject(commitObj)
228 if err != nil {
229 return "", fmt.Errorf("store commit: %w", err)
230 }
231
232 return commitHash.String(), nil
233 }
234
235 func createGitTreeRecursive(
236 larcRepo *repo.Repository,
237 gitRepo *git.Repository,
238 node *convert.TreeNode,
239 mapping *convert.MappingStore,
240 ) (plumbing.Hash, error) {
241 var entries []object.TreeEntry
242
243 for name, child := range node.Children {
244 if child.IsDir {
245 subTreeHash, err := createGitTreeRecursive(larcRepo, gitRepo, child, mapping)
246 if err != nil {
247 return plumbing.ZeroHash, err
248 }
249 entries = append(entries, object.TreeEntry{
250 Name: name,
251 Mode: filemode.Dir,
252 Hash: subTreeHash,
253 })
254 } else {
255 blobHash, err := convertBlobToGit(larcRepo, gitRepo, child.BlobSHA, mapping)
256 if err != nil {
257 return plumbing.ZeroHash, fmt.Errorf("convert blob %s: %w", name, err)
258 }
259
260 mode := filemode.Regular
261 if child.Mode&0111 != 0 {
262 mode = filemode.Executable
263 }
264
265 entries = append(entries, object.TreeEntry{
266 Name: name,
267 Mode: mode,
268 Hash: blobHash,
269 })
270 }
271 }
272
273 /* git requires specific sorting: dirs sorted as if they have trailing "/" */
274 sort.Slice(entries, func(i, j int) bool {
275 ni, nj := entries[i].Name, entries[j].Name
276 if entries[i].Mode == filemode.Dir {
277 ni += "/"
278 }
279 if entries[j].Mode == filemode.Dir {
280 nj += "/"
281 }
282 return ni < nj
283 })
284
285 tree := &object.Tree{Entries: entries}
286 treeObj := gitRepo.Storer.NewEncodedObject()
287 treeObj.SetType(plumbing.TreeObject)
288
289 if err := tree.Encode(treeObj); err != nil {
290 return plumbing.ZeroHash, fmt.Errorf("encode tree: %w", err)
291 }
292
293 treeHash, err := gitRepo.Storer.SetEncodedObject(treeObj)
294 if err != nil {
295 return plumbing.ZeroHash, fmt.Errorf("store tree: %w", err)
296 }
297
298 return treeHash, nil
299 }
300
301 func convertBlobToGit(
302 larcRepo *repo.Repository,
303 gitRepo *git.Repository,
304 larcHash string,
305 mapping *convert.MappingStore,
306 ) (plumbing.Hash, error) {
307 /* check cache */
308 if gitSHA, ok := mapping.GetGitBlobSHA(larcHash); ok {
309 return plumbing.NewHash(gitSHA), nil
310 }
311
312 /* read larc blob */
313 data, err := larcRepo.Blobs.Read(larcHash)
314 if err != nil {
315 return plumbing.ZeroHash, fmt.Errorf("read larc blob: %w", err)
316 }
317
318 /* create git blob */
319 blobObj := gitRepo.Storer.NewEncodedObject()
320 blobObj.SetType(plumbing.BlobObject)
321 blobObj.SetSize(int64(len(data)))
322
323 writer, err := blobObj.Writer()
324 if err != nil {
325 return plumbing.ZeroHash, fmt.Errorf("blob writer: %w", err)
326 }
327
328 if _, err := writer.Write(data); err != nil {
329 writer.Close()
330 return plumbing.ZeroHash, fmt.Errorf("write blob: %w", err)
331 }
332 writer.Close()
333
334 blobHash, err := gitRepo.Storer.SetEncodedObject(blobObj)
335 if err != nil {
336 return plumbing.ZeroHash, fmt.Errorf("store blob: %w", err)
337 }
338
339 mapping.AddBlobMapping(larcHash, blobHash.String())
340 return blobHash, nil
341 }
342
343 // handleGitInfoRefs handles GET /:repo.git/info/refs
344 func (s *Server) handleGitInfoRefs(c *fiber.Ctx) error {
345 repoName := strings.TrimSuffix(c.Params("repo"), ".git")
346 service := c.Query("service")
347
348 slog.Debug("git info/refs", "repo", repoName, "service", service)
349
350 /* only allow git-upload-pack (clone/fetch), reject push */
351 if service == "git-receive-pack" {
352 c.Set("Content-Type", "text/plain")
353 return c.Status(fiber.StatusForbidden).SendString(
354 "git push is NOT supported.\n" +
355 "For contributing, please use larc CLI:\n" +
356 " larc clone " + c.BaseURL() + "/" + repoName + "\n" +
357 " larc push\n")
358 }
359
360 if service != "git-upload-pack" {
361 return fiber.NewError(fiber.StatusForbidden, "Service not allowed")
362 }
363
364 /* get larc repo */
365 repoCfg := s.config.GetRepoConfig(repoName)
366 if repoCfg == nil {
367 return fiber.NewError(fiber.StatusNotFound, "Repository not found")
368 }
369
370 larcRepo, ok := s.repos[repoName]
371 if !ok {
372 return fiber.NewError(fiber.StatusNotFound, "Repository not initialized")
373 }
374
375 /* check auth for push */
376 if service == "git-receive-pack" {
377 username, _ := c.Locals("username").(string)
378 if !repoCfg.IsUserAllowed(username, true) {
379 c.Set("WWW-Authenticate", `Basic realm="`+s.config.Auth.Realm+`"`)
380 return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
381 }
382 }
383
384 /* get or create git repo */
385 gitRepo, _, err := s.getOrCreateGitRepo(repoName, larcRepo)
386 if err != nil {
387 slog.Error("failed to create git repo", "error", err)
388 return fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare repository")
389 }
390
391 /* build refs response */
392 var buf bytes.Buffer
393 enc := pktline.NewEncoder(&buf)
394
395 /* service announcement */
396 enc.Encodef("# service=%s\n", service)
397 enc.Flush()
398
399 /* get refs */
400 refs, err := gitRepo.References()
401 if err != nil {
402 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get references")
403 }
404
405 var refLines []string
406 var headSHA string
407
408 err = refs.ForEach(func(ref *plumbing.Reference) error {
409 if ref.Type() == plumbing.SymbolicReference {
410 /* HEAD - resolve it */
411 resolved, err := gitRepo.Reference(ref.Target(), true)
412 if err == nil {
413 headSHA = resolved.Hash().String()
414 }
415 return nil
416 }
417
418 sha := ref.Hash().String()
419 name := ref.Name().String()
420
421 /* first ref includes capabilities */
422 if len(refLines) == 0 {
423 caps := "multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed"
424 if service == "git-receive-pack" {
425 caps = "report-status delete-refs side-band-64k quiet ofs-delta"
426 }
427 refLines = append(refLines, fmt.Sprintf("%s %s\x00%s\n", sha, name, caps))
428 } else {
429 refLines = append(refLines, fmt.Sprintf("%s %s\n", sha, name))
430 }
431 return nil
432 })
433 if err != nil {
434 return fiber.NewError(fiber.StatusInternalServerError, "Failed to iterate references")
435 }
436
437 /* add HEAD if we have refs */
438 if headSHA != "" && len(refLines) > 0 {
439 /* insert HEAD at the beginning with capabilities */
440 caps := "multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed"
441 if service == "git-receive-pack" {
442 caps = "report-status delete-refs side-band-64k quiet ofs-delta"
443 }
444 headLine := fmt.Sprintf("%s HEAD\x00%s\n", headSHA, caps)
445
446 /* rebuild refs without caps on first line */
447 var newRefLines []string
448 newRefLines = append(newRefLines, headLine)
449 for i, line := range refLines {
450 if i == 0 {
451 /* remove caps from first ref */
452 parts := strings.SplitN(line, "\x00", 2)
453 newRefLines = append(newRefLines, parts[0]+"\n")
454 } else {
455 newRefLines = append(newRefLines, line)
456 }
457 }
458 refLines = newRefLines
459 }
460
461 /* encode refs */
462 for _, line := range refLines {
463 enc.EncodeString(line)
464 }
465 enc.Flush()
466
467 c.Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
468 c.Set("Cache-Control", "no-cache")
469 return c.Send(buf.Bytes())
470 }
471
472 // handleGitUploadPack handles POST /:repo.git/git-upload-pack
473 func (s *Server) handleGitUploadPack(c *fiber.Ctx) error {
474 repoName := strings.TrimSuffix(c.Params("repo"), ".git")
475
476 slog.Debug("git upload-pack", "repo", repoName)
477
478 /* get larc repo */
479 repoCfg := s.config.GetRepoConfig(repoName)
480 if repoCfg == nil {
481 return fiber.NewError(fiber.StatusNotFound, "Repository not found")
482 }
483
484 larcRepo, ok := s.repos[repoName]
485 if !ok {
486 return fiber.NewError(fiber.StatusNotFound, "Repository not initialized")
487 }
488
489 /* get git repo */
490 gitRepo, _, err := s.getOrCreateGitRepo(repoName, larcRepo)
491 if err != nil {
492 return fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare repository")
493 }
494
495 /* parse upload-pack request */
496 body := bytes.NewReader(c.Body())
497 req := packp.NewUploadPackRequest()
498 if err := req.Decode(body); err != nil {
499 slog.Error("failed to decode upload-pack request", "error", err)
500 return fiber.NewError(fiber.StatusBadRequest, "Invalid request")
501 }
502
503 slog.Debug("upload-pack request",
504 "wants", len(req.Wants),
505 "haves", len(req.Haves),
506 "shallows", len(req.Shallows))
507
508 /* collect objects to send */
509 objectsToSend, err := collectObjectsForPack(gitRepo, req.Wants, req.Haves)
510 if err != nil {
511 slog.Error("failed to collect objects", "error", err)
512 return fiber.NewError(fiber.StatusInternalServerError, "Failed to collect objects")
513 }
514
515 slog.Debug("sending objects", "count", len(objectsToSend))
516
517 /* build pack file */
518 var packBuf bytes.Buffer
519 if err := buildPackfile(gitRepo, objectsToSend, &packBuf); err != nil {
520 slog.Error("failed to build packfile", "error", err)
521 return fiber.NewError(fiber.StatusInternalServerError, "Failed to build pack")
522 }
523
524 /* build response */
525 var respBuf bytes.Buffer
526 enc := pktline.NewEncoder(&respBuf)
527
528 /* NAK (we don't support multi_ack properly yet) */
529 enc.EncodeString("NAK\n")
530
531 /* send pack data via side-band */
532 packData := packBuf.Bytes()
533
534 /* side-band-64k: channel 1 = pack data */
535 for i := 0; i < len(packData); i += 65515 {
536 end := i + 65515
537 if end > len(packData) {
538 end = len(packData)
539 }
540 chunk := packData[i:end]
541
542 /* channel 1 prefix */
543 data := append([]byte{1}, chunk...)
544 enc.Encode(data)
545 }
546
547 /* flush */
548 enc.Flush()
549
550 c.Set("Content-Type", "application/x-git-upload-pack-result")
551 c.Set("Cache-Control", "no-cache")
552 return c.Send(respBuf.Bytes())
553 }
554
555 // collectObjectsForPack collects all objects needed for the pack
556 func collectObjectsForPack(gitRepo *git.Repository, wants []plumbing.Hash, haves []plumbing.Hash) ([]plumbing.Hash, error) {
557 haveSet := make(map[plumbing.Hash]bool)
558 for _, h := range haves {
559 haveSet[h] = true
560 }
561
562 visited := make(map[plumbing.Hash]bool)
563 var result []plumbing.Hash
564
565 var walkCommit func(hash plumbing.Hash) error
566 walkCommit = func(hash plumbing.Hash) error {
567 if visited[hash] || haveSet[hash] {
568 return nil
569 }
570 visited[hash] = true
571
572 commit, err := gitRepo.CommitObject(hash)
573 if err != nil {
574 return err
575 }
576
577 result = append(result, hash)
578
579 /* walk tree */
580 if err := walkTree(gitRepo, commit.TreeHash, visited, haveSet, &result); err != nil {
581 return err
582 }
583
584 /* walk parents */
585 for _, parent := range commit.ParentHashes {
586 if err := walkCommit(parent); err != nil {
587 return err
588 }
589 }
590
591 return nil
592 }
593
594 for _, want := range wants {
595 if err := walkCommit(want); err != nil {
596 return nil, err
597 }
598 }
599
600 return result, nil
601 }
602
603 func walkTree(gitRepo *git.Repository, hash plumbing.Hash, visited, haveSet map[plumbing.Hash]bool, result *[]plumbing.Hash) error {
604 if visited[hash] || haveSet[hash] {
605 return nil
606 }
607 visited[hash] = true
608
609 *result = append(*result, hash)
610
611 tree, err := gitRepo.TreeObject(hash)
612 if err != nil {
613 return err
614 }
615
616 for _, entry := range tree.Entries {
617 if entry.Mode == filemode.Dir {
618 if err := walkTree(gitRepo, entry.Hash, visited, haveSet, result); err != nil {
619 return err
620 }
621 } else {
622 if !visited[entry.Hash] && !haveSet[entry.Hash] {
623 visited[entry.Hash] = true
624 *result = append(*result, entry.Hash)
625 }
626 }
627 }
628
629 return nil
630 }
631
632 // buildPackfile builds a packfile from objects using go-git's encoder
633 func buildPackfile(gitRepo *git.Repository, objects []plumbing.Hash, w io.Writer) error {
634 stor := gitRepo.Storer.(storer.EncodedObjectStorer)
635
636 /* create packfile encoder */
637 encoder := packfile.NewEncoder(w, stor, false)
638
639 /* encode all objects */
640 _, err := encoder.Encode(objects, 10)
641 return err
642 }
643
644 // handleGitReceivePack handles POST /:repo.git/git-receive-pack
645 func (s *Server) handleGitReceivePack(c *fiber.Ctx) error {
646 repoName := strings.TrimSuffix(c.Params("repo"), ".git")
647
648 slog.Debug("git receive-pack rejected", "repo", repoName)
649
650 /* git push is not supported - return friendly error */
651 c.Set("Content-Type", "text/plain")
652 return c.Status(fiber.StatusForbidden).SendString(
653 "git push is NOT supported.\n" +
654 "For contributing, please use larc CLI:\n" +
655 " larc clone " + c.BaseURL() + "/" + repoName + "\n" +
656 " larc push\n")
657 }
658