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  }