1package tinygit23import (4 "bufio"5 "bytes"6 "context"7 "errors"8 "fmt"9 "io"10 "os"11 "os/exec"12 "path"13 "sort"14 "strconv"15 "strings"16 "unicode/utf8"17)1819const previewBytes = 256 * 10242021type BrowseRequest struct {22 Owner string `json:"owner"`23 Repo string `json:"repo"`24 Ref string `json:"ref"`25 Path string `json:"path"`26 View string `json:"view"`27 Offset int `json:"offset"`28 Limit int `json:"limit"`29}3031type BrowseEntry struct {32 Name string `json:"name"`33 Path string `json:"path"`34 Mode string `json:"mode"`35 Type string `json:"type"`36 OID string `json:"oid"`37 Size int64 `json:"size"`38}3940type BrowseCommit struct {41 OID string `json:"oid"`42 Parents []string `json:"parents"`43 Author string `json:"author"`44 Time int64 `json:"time"`45 Subject string `json:"subject"`46}4748type BrowsePage struct {49 Owner string `json:"owner"`50 Identifier string `json:"identifier"`51 Head string `json:"head"`52 Private bool `json:"private"`53 Clone []string `json:"clone"`54 Relays []string `json:"relays"`55 // Maintainers is filled by the host, which knows about agent grants.56 Maintainers []Maintainer `json:"maintainers,omitempty"`57 Refs map[string]string `json:"refs"`58 Commit string `json:"commit"`59 Path string `json:"path"`60 View string `json:"view"`61 Entries []BrowseEntry `json:"entries"`62 Content string `json:"content"`63 Binary bool `json:"binary"`64 Truncated bool `json:"truncated"`65 Commits []BrowseCommit `json:"commits"`66 Diff string `json:"diff"`67 NextOffset int `json:"next_offset"`68}6970// Repositories returns detached snapshots. Callers must enforce tenant and71// repository read permissions before revealing these records.72func (g *GitRelay) Repositories() []Repository {73 g.mu.RLock()74 defer g.mu.RUnlock()75 rows := make([]Repository, 0, len(g.repos))76 for _, r := range g.repos {77 rows = append(rows, cloneBrowseRepo(r))78 }79 sort.Slice(rows, func(i, j int) bool {80 return key(rows[i].Owner, rows[i].Identifier) < key(rows[j].Owner, rows[j].Identifier)81 })82 return rows83}8485func cloneBrowseRepo(r Repository) Repository {86 refs := make(map[string]string, len(r.Refs))87 for name, oid := range r.Refs {88 refs[name] = oid89 }90 r.Refs = refs91 r.Clone = append([]string(nil), r.Clone...)92 r.Relays = append([]string(nil), r.Relays...)93 r.Maintainers = append([]string(nil), r.Maintainers...)94 return r95}9697func (g *GitRelay) BrowseRepository(owner, identifier string) (Repository, error) {98 g.mu.RLock()99 defer g.mu.RUnlock()100 r, ok := g.repos[key(owner, identifier)]101 if !ok {102 return Repository{}, os.ErrNotExist103 }104 return cloneBrowseRepo(r), nil105}106107func validBrowsePath(value string) bool {108 return value == "" || (!strings.ContainsRune(value, 0) && !strings.HasPrefix(value, "/") && path.Clean(value) == value && value != ".." && !strings.HasPrefix(value, "../"))109}110111func newBrowsePage(r Repository, q BrowseRequest) BrowsePage {112 return BrowsePage{Owner: r.Owner, Identifier: r.Identifier, Head: r.Head, Private: r.Private, Clone: r.Clone, Relays: r.Relays, Refs: r.Refs, Path: q.Path, View: q.View, NextOffset: -1}113}114115// Browse only reads objects reachable from materialized, signed public refs.116// It never checks out a working tree, invokes a shell, or follows a symlink.117func (g *GitRelay) Browse(ctx context.Context, q BrowseRequest) (BrowsePage, error) {118 if !validBrowsePath(q.Path) || q.Offset < 0 {119 return BrowsePage{}, errors.New("invalid: repository path or offset")120 }121 if q.View == "" {122 q.View = "tree"123 }124 if q.Limit <= 0 || q.Limit > 200 {125 q.Limit = 50126 }127 r, err := g.BrowseRepository(q.Owner, q.Repo)128 if err != nil {129 return BrowsePage{}, err130 }131 page := newBrowsePage(r, q)132 r.Refs, err = g.browseRefs(ctx, r)133 if err != nil {134 return page, err135 }136 page.Refs = r.Refs137 if len(r.Refs) == 0 {138 return page, nil139 }140 page.Commit, err = g.browseCommit(ctx, r, q.Ref)141 if err != nil {142 return page, err143 }144 switch q.View {145 case "tree":146 err = g.browseTree(ctx, r, q, &page)147 case "file":148 err = g.browseFile(ctx, r, q, &page)149 case "history":150 err = g.browseHistory(ctx, r, q, &page)151 case "commit":152 err = g.browseDiff(ctx, r, &page)153 case "activity":154 default:155 err = errors.New("invalid: unknown repository view")156 }157 return page, err158}159160func (g *GitRelay) browseCommand(ctx context.Context, r Repository, args ...string) *exec.Cmd {161 base := []string{"--no-pager", "--literal-pathspecs", "-c", "core.quotePath=false", "-c", "log.showSignature=false", "--git-dir", g.repoPath(r)}162 return exec.CommandContext(ctx, "git", append(base, args...)...)163}164165func (g *GitRelay) browseRefs(ctx context.Context, r Repository) (map[string]string, error) {166 b, err := g.browseCommand(ctx, r, "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads/", "refs/tags/", "refs/nostr/").Output()167 if err != nil {168 return nil, fmt.Errorf("read repository refs: %w", err)169 }170 refs := make(map[string]string)171 for _, line := range strings.Split(string(b), "\n") {172 fields := strings.Fields(line)173 if len(fields) == 2 && r.Refs[fields[0]] == fields[1] {174 refs[fields[0]] = fields[1]175 }176 }177 return refs, nil178}179180func (g *GitRelay) browseCommit(ctx context.Context, r Repository, ref string) (string, error) {181 if ref == "" || ref == "HEAD" {182 ref = strings.TrimPrefix(r.Head, "ref: ")183 }184 for _, candidate := range []string{ref, "refs/heads/" + ref, "refs/tags/" + ref} {185 if oid, ok := r.Refs[candidate]; ok {186 return g.peelBrowseCommit(ctx, r, oid)187 }188 }189 if !hexObject(ref) {190 return "", errors.New("not found: published ref")191 }192 oid, err := g.peelBrowseCommit(ctx, r, ref)193 if err != nil {194 return "", err195 }196 cmd := g.browseCommand(ctx, r, "rev-list", "--max-count=1", "--stdin")197 var input strings.Builder198 input.WriteString(oid + "\n")199 for _, tip := range r.Refs {200 input.WriteString("^" + tip + "\n")201 }202 cmd.Stdin = strings.NewReader(input.String())203 b, err := cmd.Output()204 if err != nil || len(bytes.TrimSpace(b)) != 0 {205 return "", errors.New("not found: published commit")206 }207 return oid, nil208}209210func hexObject(value string) bool {211 if len(value) != 40 && len(value) != 64 {212 return false213 }214 for _, ch := range value {215 if !(ch >= '0' && ch <= '9') && !(ch >= 'a' && ch <= 'f') {216 return false217 }218 }219 return true220}221222func (g *GitRelay) peelBrowseCommit(ctx context.Context, r Repository, oid string) (string, error) {223 if !hexObject(oid) {224 return "", errors.New("invalid: object id")225 }226 b, err := g.browseCommand(ctx, r, "rev-parse", "--verify", "--end-of-options", oid+"^{commit}").Output()227 if err != nil {228 return "", errors.New("not found: commit")229 }230 return strings.TrimSpace(string(b)), nil231}232233func readBrowseOutput(cmd *exec.Cmd, limit int64) ([]byte, bool, error) {234 out, err := cmd.StdoutPipe()235 if err != nil {236 return nil, false, err237 }238 if err = cmd.Start(); err != nil {239 return nil, false, err240 }241 b, readErr := io.ReadAll(io.LimitReader(out, limit+1))242 truncated := int64(len(b)) > limit243 if truncated || readErr != nil {244 _ = out.Close()245 _ = cmd.Process.Kill()246 }247 waitErr := cmd.Wait()248 if readErr != nil {249 return nil, false, readErr250 }251 if truncated {252 return b[:limit], true, nil253 }254 if waitErr != nil {255 return nil, false, fmt.Errorf("read Git object: %w", waitErr)256 }257 return b, false, nil258}259260func (g *GitRelay) browseTree(ctx context.Context, r Repository, q BrowseRequest, page *BrowsePage) error {261 tree := page.Commit + ":" + q.Path262 cmd := g.browseCommand(ctx, r, "ls-tree", "-z", "-l", tree)263 out, err := cmd.StdoutPipe()264 if err != nil {265 return err266 }267 if err := cmd.Start(); err != nil {268 return err269 }270 reader := bufio.NewReader(out)271 readErr := readBrowseTree(reader, q, page)272 if readErr != nil || page.NextOffset >= 0 {273 // Closing this page cancels only its read-only Git child.274 _ = out.Close()275 _ = cmd.Process.Kill()276 }277 waitErr := cmd.Wait()278 if readErr != nil {279 return readErr280 }281 if page.NextOffset >= 0 {282 return nil283 }284 return waitErr285}286287func readBrowseTree(reader *bufio.Reader, q BrowseRequest, page *BrowsePage) error {288 for index := 0; ; index++ {289 row, err := reader.ReadString(0)290 if errors.Is(err, io.EOF) && row == "" {291 return nil292 }293 if err != nil {294 return err295 }296 if index < q.Offset {297 continue298 }299 if len(page.Entries) >= q.Limit {300 page.NextOffset = index301 return nil302 }303 meta, name, ok := strings.Cut(strings.TrimSuffix(row, "\x00"), "\t")304 fields := strings.Fields(meta)305 if !ok || len(fields) != 4 {306 return errors.New("invalid Git tree entry")307 }308 var size int64309 if fields[3] != "-" {310 size, err = strconv.ParseInt(fields[3], 10, 64)311 if err != nil {312 return err313 }314 }315 page.Entries = append(page.Entries, BrowseEntry{Name: name, Path: path.Join(q.Path, name), Mode: fields[0], Type: fields[1], OID: fields[2], Size: size})316 }317}318319func (g *GitRelay) fileObject(ctx context.Context, r Repository, commit, file string) (string, error) {320 if file == "" || !validBrowsePath(file) {321 return "", errors.New("invalid: file path")322 }323 b, err := g.browseCommand(ctx, r, "rev-parse", "--verify", "--end-of-options", commit+":"+file).Output()324 if err != nil {325 return "", os.ErrNotExist326 }327 oid := strings.TrimSpace(string(b))328 if !hexObject(oid) {329 return "", os.ErrNotExist330 }331 kind, err := g.browseCommand(ctx, r, "cat-file", "-t", oid).Output()332 if err != nil || strings.TrimSpace(string(kind)) != "blob" {333 return "", errors.New("not found: source file")334 }335 return oid, nil336}337338func (g *GitRelay) browseFile(ctx context.Context, r Repository, q BrowseRequest, page *BrowsePage) error {339 oid, err := g.fileObject(ctx, r, page.Commit, q.Path)340 if err != nil {341 return err342 }343 b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, "cat-file", "blob", oid), previewBytes)344 if err != nil {345 return err346 }347 page.Binary = bytes.IndexByte(b, 0) >= 0 || (!utf8.Valid(b) && !truncated)348 page.Truncated = truncated349 if !page.Binary {350 page.Content = strings.ToValidUTF8(string(b), "�")351 }352 return nil353}354355func (g *GitRelay) browseHistory(ctx context.Context, r Repository, q BrowseRequest, page *BrowsePage) error {356 args := []string{"log", "--format=%H%x00%P%x00%an%x00%at%x00%s%x00", "--max-count=" + strconv.Itoa(q.Limit+1), "--skip=" + strconv.Itoa(q.Offset), page.Commit, "--"}357 if q.Path != "" {358 args = append(args, q.Path)359 }360 b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, args...), previewBytes)361 if err != nil {362 return err363 }364 if truncated {365 return errors.New("commit metadata exceeds preview capacity")366 }367 fields := strings.Split(string(b), "\x00")368 for i := 0; i+4 < len(fields); i += 5 {369 if len(page.Commits) >= q.Limit {370 page.NextOffset = q.Offset + q.Limit371 break372 }373 stamp, err := strconv.ParseInt(fields[i+3], 10, 64)374 if err != nil {375 return err376 }377 page.Commits = append(page.Commits, BrowseCommit{OID: strings.TrimSpace(fields[i]), Parents: strings.Fields(fields[i+1]), Author: fields[i+2], Time: stamp, Subject: fields[i+4]})378 }379 return nil380}381382func (g *GitRelay) browseDiff(ctx context.Context, r Repository, page *BrowsePage) error {383 if err := g.browseHistory(ctx, r, BrowseRequest{Limit: 1}, page); err != nil {384 return err385 }386 b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, "show", "--format=", "--no-ext-diff", "--no-textconv", "--no-renames", page.Commit, "--"), previewBytes)387 page.Diff, page.Truncated = strings.ToValidUTF8(string(b), "�"), truncated388 return err389}390391// WriteSource streams a published source blob directly to the destination.392func (g *GitRelay) WriteSource(ctx context.Context, q BrowseRequest, dst io.Writer) error {393 r, err := g.BrowseRepository(q.Owner, q.Repo)394 if err != nil {395 return err396 }397 r.Refs, err = g.browseRefs(ctx, r)398 if err != nil {399 return err400 }401 commit, err := g.browseCommit(ctx, r, q.Ref)402 if err != nil {403 return err404 }405 oid, err := g.fileObject(ctx, r, commit, q.Path)406 if err != nil {407 return err408 }409 cmd := g.browseCommand(ctx, r, "cat-file", "blob", oid)410 cmd.Stdout = dst411 if err := cmd.Run(); err != nil {412 return fmt.Errorf("stream source: %w", err)413 }414 return nil415}