github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/kbfs/libgit/browser.go (about) 1 // Copyright 2018 Keybase Inc. All rights reserved. 2 // Use of this source code is governed by a BSD 3 // license that can be found in the LICENSE file. 4 5 package libgit 6 7 import ( 8 "context" 9 "io" 10 "os" 11 "path" 12 "strings" 13 "time" 14 15 "github.com/keybase/client/go/kbfs/libfs" 16 "github.com/keybase/client/go/kbfs/libkbfs" 17 "github.com/pkg/errors" 18 billy "gopkg.in/src-d/go-billy.v4" 19 gogit "gopkg.in/src-d/go-git.v4" 20 "gopkg.in/src-d/go-git.v4/plumbing" 21 "gopkg.in/src-d/go-git.v4/plumbing/object" 22 "gopkg.in/src-d/go-git.v4/storage" 23 ) 24 25 const ( 26 // LFSSubdir is the prefix for the LFS directory under .kbfs_git 27 LFSSubdir = "kbfs_lfs" 28 lfsEntryMinSize = 120 29 lfsEntryMaxSize = 150 30 ) 31 32 func translateGitError(err *error) { 33 if *err == nil { 34 return 35 } 36 switch errors.Cause(*err) { 37 case object.ErrEntryNotFound: 38 *err = os.ErrNotExist 39 default: 40 return 41 } 42 } 43 44 // Browser presents the contents of a git repo as a read-only file 45 // system, using only the dotgit directory of the repo. 46 type Browser struct { 47 repo *gogit.Repository 48 tree *object.Tree 49 root string 50 mtime time.Time 51 commitHash plumbing.Hash 52 lfsFS billy.Filesystem 53 54 sharedCache sharedInBrowserCache 55 } 56 57 var _ billy.Filesystem = (*Browser)(nil) 58 59 // NewBrowser makes a new Browser instance, browsing the given branch 60 // of the given repo. If `gitBranchName` is empty, 61 // "refs/heads/master" is used. If `gitBranchName` is not empty, but 62 // it doesn't begin with "refs/", then "refs/heads/" is prepended to 63 // it. 64 func NewBrowser( 65 repoFS *libfs.FS, clock libkbfs.Clock, 66 gitBranchName plumbing.ReferenceName, 67 sharedCache sharedInBrowserCache) (*Browser, error) { 68 var storage storage.Storer 69 storage, err := NewGitConfigWithoutRemotesStorer(repoFS) 70 if err != nil { 71 return nil, err 72 } 73 74 const masterBranch = "refs/heads/master" 75 if gitBranchName == "" { 76 gitBranchName = masterBranch 77 } else if !strings.HasPrefix(string(gitBranchName), "refs/") { 78 gitBranchName = "refs/heads/" + gitBranchName 79 } 80 81 repo, err := gogit.Open(storage, nil) 82 if errors.Cause(err) == gogit.ErrWorktreeNotProvided { 83 // This is not a bare repo (it might be for a test). So we 84 // need to pass in a working tree, but since `Browser` is 85 // read-only and doesn't even use the worktree, it doesn't 86 // matter what we pass in. 87 repo, err = gogit.Open(storage, repoFS) 88 } 89 90 if err == gogit.ErrRepositoryNotExists && gitBranchName == masterBranch { 91 // This repo is not initialized yet, so pretend it's empty. 92 return &Browser{ 93 root: string(gitBranchName), 94 sharedCache: sharedCache, 95 }, nil 96 } else if err != nil { 97 return nil, err 98 } 99 100 ref, err := repo.Reference(gitBranchName, true) 101 if err == plumbing.ErrReferenceNotFound && gitBranchName == masterBranch { 102 // This branch has no commits, so pretend it's empty. 103 return &Browser{ 104 root: string(gitBranchName), 105 sharedCache: sharedCache, 106 }, nil 107 } else if err != nil { 108 return nil, err 109 } 110 111 if ref.Type() != plumbing.HashReference { 112 return nil, errors.Errorf("can't browse reference type %s", ref.Type()) 113 } 114 115 c, err := repo.CommitObject(ref.Hash()) 116 if err != nil { 117 return nil, err 118 } 119 tree, err := c.Tree() 120 if err != nil { 121 return nil, err 122 } 123 124 lfsFS, err := repoFS.Chroot(LFSSubdir) 125 if os.IsNotExist(err) { 126 lfsFS = nil 127 } else if err != nil { 128 return nil, err 129 } 130 131 return &Browser{ 132 repo: repo, 133 tree: tree, 134 root: string(gitBranchName), 135 mtime: c.Author.When, 136 commitHash: c.Hash, 137 lfsFS: lfsFS, 138 sharedCache: sharedCache, 139 }, nil 140 } 141 142 func (b *Browser) getCommitFile( 143 ctx context.Context, hash plumbing.Hash) (*diffFile, error) { 144 if b.repo == nil { 145 return nil, errors.New("Empty repo") 146 } 147 148 commit, err := b.repo.CommitObject(hash) 149 if err != nil { 150 return nil, err 151 } 152 return newCommitFile(ctx, commit) 153 } 154 155 ///// Read-only functions: 156 157 const ( 158 maxSymlinkLevels = 40 // same as Linux 159 ) 160 161 func (b *Browser) readLink(filename string) (string, error) { 162 f, err := b.tree.File(filename) 163 if err != nil { 164 return "", err 165 } 166 r, err := f.Reader() 167 if err != nil { 168 return "", err 169 } 170 defer r.Close() 171 data, err := io.ReadAll(r) 172 if err != nil { 173 return "", err 174 } 175 return string(data), nil 176 } 177 178 func (b *Browser) followSymlink(filename string) (string, error) { 179 // Otherwise, resolve the symlink and return the underlying FileInfo. 180 link, err := b.readLink(filename) 181 if err != nil { 182 return "", err 183 } 184 if path.IsAbs(link) { 185 return "", errors.Errorf("can't follow absolute link: %s", link) 186 } 187 188 parts := strings.Split(filename, "/") 189 var parentPath string 190 if len(parts) > 0 { 191 parentPath = path.Join(parts[:len(parts)-1]...) 192 } 193 newPath := path.Clean(path.Join(parentPath, link)) 194 if strings.HasPrefix(newPath, "..") { 195 return "", errors.Errorf( 196 "cannot follow symlink out of chroot: %s", newPath) 197 } 198 return newPath, nil 199 } 200 201 // Open implements the billy.Filesystem interface for Browser. 202 func (b *Browser) Open(filename string) (f billy.File, err error) { 203 if b.tree == nil { 204 return nil, errors.New("Empty repo") 205 } 206 207 defer translateGitError(&err) 208 for i := 0; i < maxSymlinkLevels; i++ { 209 fi, err := b.Lstat(filename) 210 if err != nil { 211 return nil, err 212 } 213 214 // Check if this is a submodule. 215 if sfi, ok := fi.(*submoduleFileInfo); ok { 216 return sfi.sf, nil 217 } 218 219 // Check if this is LFS. 220 if lfsFI, ok := fi.(*lfsFileInfo); ok { 221 return b.lfsFS.Open(lfsFI.oid) 222 } 223 224 // If it's not a symlink, we can return right away. 225 if fi.Mode()&os.ModeSymlink == 0 { 226 f, err := b.tree.File(filename) 227 if err != nil { 228 return nil, err 229 } 230 return newBrowserFile(f) 231 } 232 233 filename, err = b.followSymlink(filename) 234 if err != nil { 235 return nil, err 236 } 237 } 238 return nil, errors.New("cannot resolve deep symlink chain") 239 } 240 241 // OpenFile implements the billy.Filesystem interface for Browser. 242 func (b *Browser) OpenFile(filename string, flag int, _ os.FileMode) ( 243 f billy.File, err error) { 244 if b.tree == nil { 245 return nil, errors.New("Empty repo") 246 } 247 248 if flag&os.O_CREATE != 0 { 249 return nil, errors.New("browser can't create files") 250 } 251 252 return b.Open(filename) 253 } 254 255 func (b *Browser) fileInfoForLFS( 256 filename string, oidLine string, fi os.FileInfo) ( 257 newFi os.FileInfo, err error) { 258 fields := strings.Fields(oidLine) 259 // An OID line looks like: 260 // oid sha256:588b3683... 261 if len(fields) < 2 || fields[0] != "oid" { 262 return fi, nil 263 } 264 265 s := strings.Split(fields[1], ":") 266 if len(s) < 2 { 267 return fi, nil 268 } 269 270 oid := s[1] 271 // Now look that OID up and make sure it exists. 272 lfsFI, err := b.lfsFS.Stat(oid) 273 if err != nil { 274 return nil, err 275 } 276 return &lfsFileInfo{ 277 filename, oid, lfsFI.Size(), b.mtime}, nil 278 } 279 280 // Lstat implements the billy.Filesystem interface for Browser. 281 func (b *Browser) Lstat(filename string) (fi os.FileInfo, err error) { 282 if b.tree == nil { 283 return nil, errors.New("Empty repo") 284 } 285 286 if strings.HasPrefix(filename, AutogitCommitPrefix) { 287 commit := strings.TrimPrefix(filename, AutogitCommitPrefix) 288 hash := plumbing.NewHash(commit) 289 f, err := b.getCommitFile(context.Background(), hash) 290 if err != nil { 291 return nil, err 292 } 293 return f.GetInfo(), nil 294 } 295 296 cachePath := path.Join(b.root, filename) 297 if fi, ok := b.sharedCache.getFileInfo(b.commitHash, cachePath); ok { 298 return fi, nil 299 } 300 defer translateGitError(&err) 301 entry, err := b.tree.FindEntry(filename) 302 if err != nil { 303 return nil, errors.WithStack(err) 304 } 305 306 size, err := b.tree.Size(filename) 307 switch errors.Cause(err) { 308 case nil: 309 // Git doesn't keep track of the mtime of individual files 310 // anywhere, so just use the timestamp from the commit. 311 fi = &browserFileInfo{entry, size, b.mtime} 312 case plumbing.ErrObjectNotFound: 313 // This is likely a git submodule. 314 sf := newSubmoduleFile(entry.Hash, filename, b.mtime) 315 fi = sf.GetInfo() 316 default: 317 return nil, errors.WithStack(err) 318 } 319 320 // If this repo has an LFS subdirectory, check and see if the size 321 // of this file is within the size bounds for an LFS object. If 322 // so, read the object and see if it points to LFS or not. 323 if b.lfsFS != nil && size >= lfsEntryMinSize && size <= lfsEntryMaxSize { 324 f, err := b.tree.File(filename) 325 if err != nil { 326 return nil, err 327 } 328 lines, err := f.Lines() 329 if err != nil { 330 return nil, err 331 } 332 if len(lines) >= 2 { 333 fi, err = b.fileInfoForLFS(filename, lines[1], fi) 334 if err != nil { 335 return nil, err 336 } 337 } 338 } 339 340 b.sharedCache.setFileInfo(b.commitHash, cachePath, fi) 341 return fi, nil 342 } 343 344 // Stat implements the billy.Filesystem interface for Browser. 345 func (b *Browser) Stat(filename string) (fi os.FileInfo, err error) { 346 defer translateGitError(&err) 347 for i := 0; i < maxSymlinkLevels; i++ { 348 fi, err := b.Lstat(filename) 349 if err != nil { 350 return nil, err 351 } 352 // If it's not a symlink, we can return right away. 353 if fi.Mode()&os.ModeSymlink == 0 { 354 return fi, nil 355 } 356 357 filename, err = b.followSymlink(filename) 358 if err != nil { 359 return nil, err 360 } 361 } 362 return nil, errors.New("cannot resolve deep symlink chain") 363 } 364 365 // Join implements the billy.Filesystem interface for Browser. 366 func (b *Browser) Join(elem ...string) string { 367 return path.Clean(path.Join(elem...)) 368 } 369 370 // ReadDir implements the billy.Filesystem interface for Browser. 371 func (b *Browser) ReadDir(p string) (fis []os.FileInfo, err error) { 372 if p == "" { 373 p = "." 374 } 375 376 if b.tree == nil { 377 if p == "." { 378 // Branch with no commits. 379 return nil, nil 380 } 381 return nil, errors.New("Empty repo") 382 } 383 384 cachePath := path.Join(b.root, p) 385 386 if fis, ok := b.sharedCache.getChildrenFileInfos( 387 b.commitHash, cachePath); ok { 388 return fis, nil 389 } 390 391 defer translateGitError(&err) 392 var dirTree *object.Tree 393 if p == "." { 394 dirTree = b.tree 395 } else { 396 dirTree, err = b.tree.Tree(p) 397 if err != nil { 398 return nil, err 399 } 400 } 401 402 childrenPathsToCache := make([]string, 0, len(dirTree.Entries)) 403 for _, e := range dirTree.Entries { 404 fi, err := b.Lstat(path.Join(p, e.Name)) 405 if err != nil { 406 return nil, err 407 } 408 fis = append(fis, fi) 409 childrenPathsToCache = append(childrenPathsToCache, path.Join(cachePath, e.Name)) 410 } 411 b.sharedCache.setChildrenPaths( 412 b.commitHash, cachePath, childrenPathsToCache) 413 414 return fis, nil 415 } 416 417 // Readlink implements the billy.Filesystem interface for Browser. 418 func (b *Browser) Readlink(link string) (target string, err error) { 419 defer translateGitError(&err) 420 fi, err := b.Lstat(link) 421 if err != nil { 422 return "", err 423 } 424 // If it's not a symlink, error right away. 425 if fi.Mode()&os.ModeSymlink == 0 { 426 return "", errors.New("not a symlink") 427 } 428 429 return b.readLink(link) 430 } 431 432 // Chroot implements the billy.Filesystem interface for Browser. 433 func (b *Browser) Chroot(p string) (newFS billy.Filesystem, err error) { 434 if b.tree == nil { 435 return nil, errors.New("Empty repo") 436 } 437 438 defer translateGitError(&err) 439 newTree, err := b.tree.Tree(p) 440 if err != nil { 441 return nil, err 442 } 443 return &Browser{ 444 tree: newTree, 445 root: b.Join(b.root, p), 446 mtime: b.mtime, 447 commitHash: b.commitHash, 448 sharedCache: b.sharedCache, 449 }, nil 450 } 451 452 // Root implements the billy.Filesystem interface for Browser. 453 func (b *Browser) Root() string { 454 return b.root 455 } 456 457 ///// Modifying functions (not supported): 458 459 // Create implements the billy.Filesystem interface for Browser. 460 func (b *Browser) Create(_ string) (billy.File, error) { 461 return nil, errors.New("browser cannot create files") 462 } 463 464 // Rename implements the billy.Filesystem interface for Browser. 465 func (b *Browser) Rename(_, _ string) (err error) { 466 return errors.New("browser cannot rename files") 467 } 468 469 // Remove implements the billy.Filesystem interface for Browser. 470 func (b *Browser) Remove(_ string) (err error) { 471 return errors.New("browser cannot remove files") 472 } 473 474 // TempFile implements the billy.Filesystem interface for Browser. 475 func (b *Browser) TempFile(_, _ string) (billy.File, error) { 476 return nil, errors.New("browser cannot make temp files") 477 } 478 479 // MkdirAll implements the billy.Filesystem interface for Browser. 480 func (b *Browser) MkdirAll(_ string, _ os.FileMode) (err error) { 481 return errors.New("browser cannot mkdir") 482 } 483 484 // Symlink implements the billy.Filesystem interface for Browser. 485 func (b *Browser) Symlink(_, _ string) (err error) { 486 return errors.New("browser cannot make symlinks") 487 }