385 lines
12 KiB
Go
385 lines
12 KiB
Go
package gitfs
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
goGit "github.com/go-git/go-git/v5"
|
|
goGitPlumbing "github.com/go-git/go-git/v5/plumbing"
|
|
goGitPlumbingFileMode "github.com/go-git/go-git/v5/plumbing/filemode"
|
|
goGitPlumbingObject "github.com/go-git/go-git/v5/plumbing/object"
|
|
goGitPlumbingStorer "github.com/go-git/go-git/v5/plumbing/storer"
|
|
)
|
|
|
|
// https://github.com/go-git/go-git/issues/296
|
|
|
|
func CommitTime(commit *goGitPlumbingObject.Commit) time.Time {
|
|
if commit.Committer.When.After(commit.Author.When) {
|
|
return commit.Committer.When
|
|
} else {
|
|
return commit.Author.When
|
|
}
|
|
}
|
|
|
|
func CommitHash(repo *goGit.Repository, commit string) (fs.FS, error) {
|
|
gitCommit, err := repo.CommitObject(goGitPlumbing.NewHash(commit))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tree, err := gitCommit.Tree()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f := &gitFS{
|
|
storer: repo.Storer,
|
|
tree: tree,
|
|
name: ".",
|
|
Mod: CommitTime(gitCommit),
|
|
}
|
|
f.root = f
|
|
return gitFSFS{
|
|
gitFS: f,
|
|
}, nil
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#FS
|
|
// This exists *only* because we cannot create a single object that concurrently implements *both* fs.FS *and* fs.File (Stat(string) vs Stat()).
|
|
type gitFSFS struct {
|
|
*gitFS
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#File
|
|
// https://pkg.go.dev/io/fs#FileInfo
|
|
// https://pkg.go.dev/io/fs#DirEntry
|
|
type gitFS struct {
|
|
root *gitFS // used so we can rewind back to the root if we need to (see symlink handling code; should *only* be set in CommitHash / constructors)
|
|
|
|
storer goGitPlumbingStorer.EncodedObjectStorer
|
|
tree *goGitPlumbingObject.Tree
|
|
entry *goGitPlumbingObject.TreeEntry // might be nil ("." at the top-level of the repo)
|
|
|
|
Mod time.Time
|
|
|
|
// cached values
|
|
name string // full path from the repository root
|
|
size int64 // Tree.Size value for non-directories (more efficient than opening/reading the blob)
|
|
|
|
// state for "Open" objects
|
|
reader io.ReadCloser // only set for an "Open" file
|
|
walker *goGitPlumbingObject.TreeWalker // only set for an "Open" directory
|
|
}
|
|
|
|
// clones just the load-bearing bits (basically clearing anything that's "state"
|
|
func (f gitFS) clone() *gitFS {
|
|
f.reader = nil
|
|
f.walker = nil
|
|
return &f
|
|
}
|
|
|
|
// if our entry is a symlink, this returns the target of it
|
|
func (f gitFS) readLink() (bool, string, error) {
|
|
if f.entry == nil || f.entry.Mode != goGitPlumbingFileMode.Symlink {
|
|
return false, "", nil
|
|
}
|
|
|
|
file, err := f.tree.TreeEntryFile(f.entry)
|
|
if err != nil {
|
|
return true, "", fmt.Errorf("TreeEntryFile(%q): %w", f.name, err)
|
|
}
|
|
|
|
target, err := file.Contents()
|
|
return true, target, err
|
|
}
|
|
|
|
// symlinks in "io/fs" are still a big TODO (https://github.com/golang/go/issues/49580, https://github.com/golang/go/issues/45470, etc related issues); all the existing interfaces mostly assume symlinks don't exist (fs.DirEntry.Info() and fs.WalkDir(...) as notable exceptions 🤷)
|
|
//
|
|
// if the object we're pointing at represents a symlink, this returns the (resolved) path that should be looked up instead; only relative symlinks are supported (and attempts to escape the repository with too many "../" *should* result in an error -- this is a convenience/sanity check, not a security boundary; subset of https://pkg.go.dev/io/fs#ValidPath)
|
|
//
|
|
// otherwise, it will return the empty string and nil
|
|
func (f gitFS) resolveLink() (string, error) {
|
|
isLink, target, err := f.readLink()
|
|
if !isLink || err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if target == "" {
|
|
return "", fmt.Errorf("unexpected: empty symlink %q", f.name)
|
|
}
|
|
|
|
// we *could* implement this as absolute symlinks being relative to the root of the Git repository, but that wouldn't match the behavior of a normal repository that's been "git clone"'d on disk, so I think that would be a mistake and erroring out is saner here
|
|
if path.IsAbs(target) {
|
|
return "", fmt.Errorf("unsupported: %q is an absolute symlink (%q)", f.name, target)
|
|
}
|
|
|
|
// symlinks are relative to the path they're in, so we need to prepend that
|
|
target = path.Join(path.Dir(f.name), target)
|
|
|
|
// now let's use path.Clean to get rid of any excess ".." or "." entries in our end result
|
|
target = path.Clean(target)
|
|
|
|
// once we're cleaned, we should have a full path that's relative to the root of the Git repository, so if it still starts with "../", that's a problem that will error later when we try to read it, so let's error out now to bail earlier
|
|
if strings.HasPrefix(target, "../") {
|
|
return "", fmt.Errorf("unsupported: %q is a relative symlink outside the tree (%q)", f.name, target)
|
|
}
|
|
|
|
return target, nil
|
|
}
|
|
|
|
// a helper shared between FS.Stat(...) and FS.Open(...); also the primary entrypoint to creating new gitFS objects besides gitfs.CommitHash(...)
|
|
func (f gitFS) stat(name string, followSymlinks bool) (*gitFS, error) {
|
|
if !f.IsDir() {
|
|
return nil, fmt.Errorf("cannot stat a child (%q) of non-directory %q", name, f.name)
|
|
}
|
|
if path.Join(f.name, name) == f.name { // path.Join implies path.Clean too
|
|
// (this is to defensively special-case handling of ".", which FindEntry doesn't like)
|
|
return &f, nil
|
|
}
|
|
entry, err := f.tree.FindEntry(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Tree(%q).FindEntry(%q): %w", f.name, name, err)
|
|
}
|
|
return f.statEntry(name, entry, followSymlinks)
|
|
}
|
|
|
|
// dual-use by gitFS.stat and ReadDir (hence "followSymlinks" -- ReadDir needs to not resolve symlinks when creating sub-FS objects)
|
|
func (f gitFS) statEntry(name string, entry *goGitPlumbingObject.TreeEntry, followSymlinks bool) (*gitFS, error) {
|
|
if entry == nil {
|
|
return nil, fmt.Errorf("(%q).statEntry cannot accept a nil entry; perhaps you intended .stat(%q) instead?", f.name, name)
|
|
}
|
|
|
|
var (
|
|
fi = f.clone()
|
|
err error
|
|
)
|
|
fi.entry = entry
|
|
fi.name = path.Join(fi.name, name)
|
|
|
|
if fi.IsDir() {
|
|
fi.tree, err = goGitPlumbingObject.GetTree(f.storer, entry.Hash) // see https://github.com/go-git/go-git/blob/v5.11.0/plumbing/object/tree.go#L103
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Tree(%q): %w", fi.name, err)
|
|
}
|
|
return fi, nil
|
|
}
|
|
|
|
fi.size, err = f.storer.EncodedObjectSize(entry.Hash) // https://github.com/go-git/go-git/blob/v5.11.0/plumbing/object/tree.go#L92
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Size(%q): %w", fi.name, err)
|
|
}
|
|
|
|
if followSymlinks {
|
|
// TODO this should probably be an explicit loop (instead of implicit recursion) with some upper nesting limit? (symlink to symlink to symlink to ...; possibly even in an infinite cycle because symlinks)
|
|
if target, err := fi.resolveLink(); err != nil {
|
|
return nil, err
|
|
} else if target != "" {
|
|
// the value from resolveLink is relative to the root
|
|
return f.root.stat(target, followSymlinks)
|
|
// ideally this would "just" use "path.Rel" to make "target" relative to "f.name" instead, but "path.Rel" does not exist and only "filepath.Rel" does which would break this code on Windows, so instead we added a "root" pointer that we pass around forever that links us back to the root of our "Tree"
|
|
// we could technically solve this by judicious use of "../" (with enough "../" to catch all the "/" in "f.name"), but it seems simpler and more obvious (and less error prone) to just pass around a pointer to the root
|
|
}
|
|
}
|
|
|
|
return fi, nil
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#FS
|
|
func (f gitFSFS) Open(name string) (fs.File, error) {
|
|
pathErr := &fs.PathError{
|
|
Op: "open",
|
|
Path: name,
|
|
}
|
|
if !fs.ValidPath(name) {
|
|
pathErr.Err = fs.ErrInvalid
|
|
return nil, pathErr
|
|
}
|
|
|
|
var fi *gitFS
|
|
fi, pathErr.Err = f.stat(name, true)
|
|
if pathErr.Err != nil {
|
|
return nil, pathErr
|
|
}
|
|
|
|
if fi.IsDir() {
|
|
fi.walker = goGitPlumbingObject.NewTreeWalker(fi.tree, false, nil)
|
|
return fi, nil
|
|
}
|
|
|
|
var file *goGitPlumbingObject.File
|
|
file, err := fi.tree.TreeEntryFile(fi.entry)
|
|
if err != nil {
|
|
pathErr.Err = fmt.Errorf("Tree(%q).TreeEntryFile(%q): %w", f.name, fi.name, err)
|
|
return nil, pathErr
|
|
}
|
|
|
|
fi.reader, err = file.Reader()
|
|
if err != nil {
|
|
pathErr.Err = fmt.Errorf("File(%q).Reader(): %w", fi.name, err)
|
|
return nil, pathErr
|
|
}
|
|
|
|
return fi, nil
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#StatFS
|
|
func (f gitFSFS) Stat(name string) (fs.FileInfo, error) {
|
|
fi, err := f.stat(name, true)
|
|
if err != nil {
|
|
return nil, &fs.PathError{
|
|
Op: "stat",
|
|
Path: name,
|
|
Err: err,
|
|
}
|
|
}
|
|
return fi, nil
|
|
}
|
|
|
|
// https://github.com/golang/go/issues/49580 ("type ReadLinkFS interface")
|
|
func (f gitFSFS) ReadLink(name string) (string, error) {
|
|
fi, err := f.stat(name, false)
|
|
if err != nil {
|
|
return "", &fs.PathError{
|
|
Op: "readlink",
|
|
Path: name,
|
|
Err: err,
|
|
}
|
|
}
|
|
isLink, target, err := fi.readLink()
|
|
if err != nil {
|
|
return "", &fs.PathError{
|
|
Op: "readlink",
|
|
Path: name,
|
|
Err: err,
|
|
}
|
|
}
|
|
if !isLink {
|
|
return "", &fs.PathError{
|
|
Op: "readlink",
|
|
Path: name,
|
|
Err: fmt.Errorf("not a symlink"),
|
|
}
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#SubFS
|
|
func (f gitFS) Sub(dir string) (fs.FS, error) {
|
|
fi, err := f.stat(dir, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !fi.IsDir() {
|
|
return nil, fmt.Errorf("%q is not a directory", fi.name)
|
|
}
|
|
return gitFSFS{gitFS: fi}, nil
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#File
|
|
func (f gitFS) Stat() (fs.FileInfo, error) {
|
|
return f, nil
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#File
|
|
func (f gitFS) Read(b []byte) (int, error) {
|
|
if f.reader == nil {
|
|
return 0, fmt.Errorf("%q not open (or not a file)", f.name)
|
|
}
|
|
return f.reader.Read(b)
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#File
|
|
func (f gitFS) Close() error {
|
|
if f.reader != nil {
|
|
if err := f.reader.Close(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if f.walker != nil {
|
|
f.walker.Close() // returns no error, nothing 🤔
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#ReadDirFile
|
|
func (f gitFS) ReadDir(n int) ([]fs.DirEntry, error) {
|
|
if f.walker == nil {
|
|
return nil, fmt.Errorf("%q not open (or not a directory)", f.name)
|
|
}
|
|
ret := []fs.DirEntry{}
|
|
for i := 0; n <= 0 || i < n; i++ {
|
|
name, entry, err := f.walker.Next()
|
|
if err != nil {
|
|
if err == io.EOF && n <= 0 {
|
|
// "In this case, if ReadDir succeeds (reads all the way to the end of the directory), it returns the slice and a nil error."
|
|
break
|
|
}
|
|
return ret, err
|
|
}
|
|
fi, err := f.statEntry(name, &entry, false)
|
|
if err != nil {
|
|
return ret, err
|
|
}
|
|
ret = append(ret, fi)
|
|
}
|
|
return ret, nil
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#FileInfo: base name of the file
|
|
func (f gitFS) Name() string {
|
|
return path.Base(f.name) // this should be the same as f.entry.Name (except in the case of the top-level / root)
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#FileInfo: length in bytes for regular files; system-dependent for others
|
|
func (f gitFS) Size() int64 {
|
|
return f.size
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#FileInfo: file mode bits
|
|
func (f gitFS) Mode() fs.FileMode {
|
|
// https://pkg.go.dev/github.com/go-git/go-git/v5@v5.4.2/plumbing/filemode#FileMode
|
|
// https://pkg.go.dev/io/fs#FileMode
|
|
if f.entry == nil {
|
|
// "." at the top-level of the repository is a directory
|
|
return 0775 | fs.ModeDir
|
|
}
|
|
switch f.entry.Mode {
|
|
case goGitPlumbingFileMode.Regular:
|
|
return 0664
|
|
case goGitPlumbingFileMode.Symlink:
|
|
return 0777 | fs.ModeSymlink
|
|
case goGitPlumbingFileMode.Executable:
|
|
return 0775
|
|
case goGitPlumbingFileMode.Dir:
|
|
return 0775 | fs.ModeDir
|
|
}
|
|
return 0 | fs.ModeIrregular // TODO what to do for files whose types we don't support? 😬
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#FileInfo: modification time
|
|
func (f gitFS) ModTime() time.Time {
|
|
return f.Mod
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#FileInfo: abbreviation for Mode().IsDir()
|
|
func (f gitFS) IsDir() bool {
|
|
return f.Mode().IsDir()
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#FileInfo: underlying data source (can return nil)
|
|
func (f gitFS) Sys() interface{} {
|
|
return nil
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#DirEntry
|
|
func (f gitFS) Type() fs.FileMode {
|
|
return f.Mode().Type()
|
|
}
|
|
|
|
// https://pkg.go.dev/io/fs#DirEntry
|
|
func (f gitFS) Info() (fs.FileInfo, error) {
|
|
return f, nil
|
|
}
|