larc r10

655 lines ยท 17.5 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 /* sort children */
244 names := make([]string, 0, len(node.Children))
245 for name := range node.Children {
246 names = append(names, name)
247 }
248 sort.Strings(names)
249
250 for _, name := range names {
251 child := node.Children[name]
252
253 if child.IsDir {
254 subTreeHash, err := createGitTreeRecursive(larcRepo, gitRepo, child, mapping)
255 if err != nil {
256 return plumbing.ZeroHash, err
257 }
258 entries = append(entries, object.TreeEntry{
259 Name: name,
260 Mode: filemode.Dir,
261 Hash: subTreeHash,
262 })
263 } else {
264 blobHash, err := convertBlobToGit(larcRepo, gitRepo, child.BlobSHA, mapping)
265 if err != nil {
266 return plumbing.ZeroHash, fmt.Errorf("convert blob %s: %w", name, err)
267 }
268
269 mode := filemode.Regular
270 if child.Mode&0111 != 0 {
271 mode = filemode.Executable
272 }
273
274 entries = append(entries, object.TreeEntry{
275 Name: name,
276 Mode: mode,
277 Hash: blobHash,
278 })
279 }
280 }
281
282 tree := &object.Tree{Entries: entries}
283 treeObj := gitRepo.Storer.NewEncodedObject()
284 treeObj.SetType(plumbing.TreeObject)
285
286 if err := tree.Encode(treeObj); err != nil {
287 return plumbing.ZeroHash, fmt.Errorf("encode tree: %w", err)
288 }
289
290 treeHash, err := gitRepo.Storer.SetEncodedObject(treeObj)
291 if err != nil {
292 return plumbing.ZeroHash, fmt.Errorf("store tree: %w", err)
293 }
294
295 return treeHash, nil
296 }
297
298 func convertBlobToGit(
299 larcRepo *repo.Repository,
300 gitRepo *git.Repository,
301 larcHash string,
302 mapping *convert.MappingStore,
303 ) (plumbing.Hash, error) {
304 /* check cache */
305 if gitSHA, ok := mapping.GetGitBlobSHA(larcHash); ok {
306 return plumbing.NewHash(gitSHA), nil
307 }
308
309 /* read larc blob */
310 data, err := larcRepo.Blobs.Read(larcHash)
311 if err != nil {
312 return plumbing.ZeroHash, fmt.Errorf("read larc blob: %w", err)
313 }
314
315 /* create git blob */
316 blobObj := gitRepo.Storer.NewEncodedObject()
317 blobObj.SetType(plumbing.BlobObject)
318 blobObj.SetSize(int64(len(data)))
319
320 writer, err := blobObj.Writer()
321 if err != nil {
322 return plumbing.ZeroHash, fmt.Errorf("blob writer: %w", err)
323 }
324
325 if _, err := writer.Write(data); err != nil {
326 writer.Close()
327 return plumbing.ZeroHash, fmt.Errorf("write blob: %w", err)
328 }
329 writer.Close()
330
331 blobHash, err := gitRepo.Storer.SetEncodedObject(blobObj)
332 if err != nil {
333 return plumbing.ZeroHash, fmt.Errorf("store blob: %w", err)
334 }
335
336 mapping.AddBlobMapping(larcHash, blobHash.String())
337 return blobHash, nil
338 }
339
340 // handleGitInfoRefs handles GET /:repo.git/info/refs
341 func (s *Server) handleGitInfoRefs(c *fiber.Ctx) error {
342 repoName := strings.TrimSuffix(c.Params("repo"), ".git")
343 service := c.Query("service")
344
345 slog.Debug("git info/refs", "repo", repoName, "service", service)
346
347 /* only allow git-upload-pack (clone/fetch), reject push */
348 if service == "git-receive-pack" {
349 c.Set("Content-Type", "text/plain")
350 return c.Status(fiber.StatusForbidden).SendString(
351 "git push is NOT supported.\n" +
352 "For contributing, please use larc CLI:\n" +
353 " larc clone " + c.BaseURL() + "/" + repoName + "\n" +
354 " larc push\n")
355 }
356
357 if service != "git-upload-pack" {
358 return fiber.NewError(fiber.StatusForbidden, "Service not allowed")
359 }
360
361 /* get larc repo */
362 repoCfg := s.config.GetRepoConfig(repoName)
363 if repoCfg == nil {
364 return fiber.NewError(fiber.StatusNotFound, "Repository not found")
365 }
366
367 larcRepo, ok := s.repos[repoName]
368 if !ok {
369 return fiber.NewError(fiber.StatusNotFound, "Repository not initialized")
370 }
371
372 /* check auth for push */
373 if service == "git-receive-pack" {
374 username, _ := c.Locals("username").(string)
375 if !repoCfg.IsUserAllowed(username, true) {
376 c.Set("WWW-Authenticate", `Basic realm="`+s.config.Auth.Realm+`"`)
377 return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
378 }
379 }
380
381 /* get or create git repo */
382 gitRepo, _, err := s.getOrCreateGitRepo(repoName, larcRepo)
383 if err != nil {
384 slog.Error("failed to create git repo", "error", err)
385 return fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare repository")
386 }
387
388 /* build refs response */
389 var buf bytes.Buffer
390 enc := pktline.NewEncoder(&buf)
391
392 /* service announcement */
393 enc.Encodef("# service=%s\n", service)
394 enc.Flush()
395
396 /* get refs */
397 refs, err := gitRepo.References()
398 if err != nil {
399 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get references")
400 }
401
402 var refLines []string
403 var headSHA string
404
405 err = refs.ForEach(func(ref *plumbing.Reference) error {
406 if ref.Type() == plumbing.SymbolicReference {
407 /* HEAD - resolve it */
408 resolved, err := gitRepo.Reference(ref.Target(), true)
409 if err == nil {
410 headSHA = resolved.Hash().String()
411 }
412 return nil
413 }
414
415 sha := ref.Hash().String()
416 name := ref.Name().String()
417
418 /* first ref includes capabilities */
419 if len(refLines) == 0 {
420 caps := "multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed"
421 if service == "git-receive-pack" {
422 caps = "report-status delete-refs side-band-64k quiet ofs-delta"
423 }
424 refLines = append(refLines, fmt.Sprintf("%s %s\x00%s\n", sha, name, caps))
425 } else {
426 refLines = append(refLines, fmt.Sprintf("%s %s\n", sha, name))
427 }
428 return nil
429 })
430 if err != nil {
431 return fiber.NewError(fiber.StatusInternalServerError, "Failed to iterate references")
432 }
433
434 /* add HEAD if we have refs */
435 if headSHA != "" && len(refLines) > 0 {
436 /* insert HEAD at the beginning with capabilities */
437 caps := "multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed"
438 if service == "git-receive-pack" {
439 caps = "report-status delete-refs side-band-64k quiet ofs-delta"
440 }
441 headLine := fmt.Sprintf("%s HEAD\x00%s\n", headSHA, caps)
442
443 /* rebuild refs without caps on first line */
444 var newRefLines []string
445 newRefLines = append(newRefLines, headLine)
446 for i, line := range refLines {
447 if i == 0 {
448 /* remove caps from first ref */
449 parts := strings.SplitN(line, "\x00", 2)
450 newRefLines = append(newRefLines, parts[0]+"\n")
451 } else {
452 newRefLines = append(newRefLines, line)
453 }
454 }
455 refLines = newRefLines
456 }
457
458 /* encode refs */
459 for _, line := range refLines {
460 enc.EncodeString(line)
461 }
462 enc.Flush()
463
464 c.Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
465 c.Set("Cache-Control", "no-cache")
466 return c.Send(buf.Bytes())
467 }
468
469 // handleGitUploadPack handles POST /:repo.git/git-upload-pack
470 func (s *Server) handleGitUploadPack(c *fiber.Ctx) error {
471 repoName := strings.TrimSuffix(c.Params("repo"), ".git")
472
473 slog.Debug("git upload-pack", "repo", repoName)
474
475 /* get larc repo */
476 repoCfg := s.config.GetRepoConfig(repoName)
477 if repoCfg == nil {
478 return fiber.NewError(fiber.StatusNotFound, "Repository not found")
479 }
480
481 larcRepo, ok := s.repos[repoName]
482 if !ok {
483 return fiber.NewError(fiber.StatusNotFound, "Repository not initialized")
484 }
485
486 /* get git repo */
487 gitRepo, _, err := s.getOrCreateGitRepo(repoName, larcRepo)
488 if err != nil {
489 return fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare repository")
490 }
491
492 /* parse upload-pack request */
493 body := bytes.NewReader(c.Body())
494 req := packp.NewUploadPackRequest()
495 if err := req.Decode(body); err != nil {
496 slog.Error("failed to decode upload-pack request", "error", err)
497 return fiber.NewError(fiber.StatusBadRequest, "Invalid request")
498 }
499
500 slog.Debug("upload-pack request",
501 "wants", len(req.Wants),
502 "haves", len(req.Haves),
503 "shallows", len(req.Shallows))
504
505 /* collect objects to send */
506 objectsToSend, err := collectObjectsForPack(gitRepo, req.Wants, req.Haves)
507 if err != nil {
508 slog.Error("failed to collect objects", "error", err)
509 return fiber.NewError(fiber.StatusInternalServerError, "Failed to collect objects")
510 }
511
512 slog.Debug("sending objects", "count", len(objectsToSend))
513
514 /* build pack file */
515 var packBuf bytes.Buffer
516 if err := buildPackfile(gitRepo, objectsToSend, &packBuf); err != nil {
517 slog.Error("failed to build packfile", "error", err)
518 return fiber.NewError(fiber.StatusInternalServerError, "Failed to build pack")
519 }
520
521 /* build response */
522 var respBuf bytes.Buffer
523 enc := pktline.NewEncoder(&respBuf)
524
525 /* NAK (we don't support multi_ack properly yet) */
526 enc.EncodeString("NAK\n")
527
528 /* send pack data via side-band */
529 packData := packBuf.Bytes()
530
531 /* side-band-64k: channel 1 = pack data */
532 for i := 0; i < len(packData); i += 65515 {
533 end := i + 65515
534 if end > len(packData) {
535 end = len(packData)
536 }
537 chunk := packData[i:end]
538
539 /* channel 1 prefix */
540 data := append([]byte{1}, chunk...)
541 enc.Encode(data)
542 }
543
544 /* flush */
545 enc.Flush()
546
547 c.Set("Content-Type", "application/x-git-upload-pack-result")
548 c.Set("Cache-Control", "no-cache")
549 return c.Send(respBuf.Bytes())
550 }
551
552 // collectObjectsForPack collects all objects needed for the pack
553 func collectObjectsForPack(gitRepo *git.Repository, wants []plumbing.Hash, haves []plumbing.Hash) ([]plumbing.Hash, error) {
554 haveSet := make(map[plumbing.Hash]bool)
555 for _, h := range haves {
556 haveSet[h] = true
557 }
558
559 visited := make(map[plumbing.Hash]bool)
560 var result []plumbing.Hash
561
562 var walkCommit func(hash plumbing.Hash) error
563 walkCommit = func(hash plumbing.Hash) error {
564 if visited[hash] || haveSet[hash] {
565 return nil
566 }
567 visited[hash] = true
568
569 commit, err := gitRepo.CommitObject(hash)
570 if err != nil {
571 return err
572 }
573
574 result = append(result, hash)
575
576 /* walk tree */
577 if err := walkTree(gitRepo, commit.TreeHash, visited, haveSet, &result); err != nil {
578 return err
579 }
580
581 /* walk parents */
582 for _, parent := range commit.ParentHashes {
583 if err := walkCommit(parent); err != nil {
584 return err
585 }
586 }
587
588 return nil
589 }
590
591 for _, want := range wants {
592 if err := walkCommit(want); err != nil {
593 return nil, err
594 }
595 }
596
597 return result, nil
598 }
599
600 func walkTree(gitRepo *git.Repository, hash plumbing.Hash, visited, haveSet map[plumbing.Hash]bool, result *[]plumbing.Hash) error {
601 if visited[hash] || haveSet[hash] {
602 return nil
603 }
604 visited[hash] = true
605
606 *result = append(*result, hash)
607
608 tree, err := gitRepo.TreeObject(hash)
609 if err != nil {
610 return err
611 }
612
613 for _, entry := range tree.Entries {
614 if entry.Mode == filemode.Dir {
615 if err := walkTree(gitRepo, entry.Hash, visited, haveSet, result); err != nil {
616 return err
617 }
618 } else {
619 if !visited[entry.Hash] && !haveSet[entry.Hash] {
620 visited[entry.Hash] = true
621 *result = append(*result, entry.Hash)
622 }
623 }
624 }
625
626 return nil
627 }
628
629 // buildPackfile builds a packfile from objects using go-git's encoder
630 func buildPackfile(gitRepo *git.Repository, objects []plumbing.Hash, w io.Writer) error {
631 stor := gitRepo.Storer.(storer.EncodedObjectStorer)
632
633 /* create packfile encoder */
634 encoder := packfile.NewEncoder(w, stor, false)
635
636 /* encode all objects */
637 _, err := encoder.Encode(objects, 10)
638 return err
639 }
640
641 // handleGitReceivePack handles POST /:repo.git/git-receive-pack
642 func (s *Server) handleGitReceivePack(c *fiber.Ctx) error {
643 repoName := strings.TrimSuffix(c.Params("repo"), ".git")
644
645 slog.Debug("git receive-pack rejected", "repo", repoName)
646
647 /* git push is not supported - return friendly error */
648 c.Set("Content-Type", "text/plain")
649 return c.Status(fiber.StatusForbidden).SendString(
650 "git push is NOT supported.\n" +
651 "For contributing, please use larc CLI:\n" +
652 " larc clone " + c.BaseURL() + "/" + repoName + "\n" +
653 " larc push\n")
654 }
655