diff options
Diffstat (limited to 'object/fetch/treefs.go')
| -rw-r--r-- | object/fetch/treefs.go | 451 |
1 files changed, 451 insertions, 0 deletions
diff --git a/object/fetch/treefs.go b/object/fetch/treefs.go new file mode 100644 index 00000000..d12e3dd6 --- /dev/null +++ b/object/fetch/treefs.go @@ -0,0 +1,451 @@ +package fetch + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "time" + + oid "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/tree" + "lindenii.org/go/furgit/object/tree/mode" +) + +// TreeFS exposes one Git tree as an fs.FS view backed by a Fetcher. +// +// TreeFS interprets names using io/fs path rules. +// Those rules do not match raw Git tree entry naming exactly: +// names are UTF-8, slash-separated, and must be valid fs.FS paths. +// Tree entries that cannot be represented under those rules +// are not addressable through this API. +// +// Labels: MT-Safe. +type TreeFS struct { + fetcher *Fetcher + rootTree oid.ObjectID + rootEntry *tree.Entry +} + +var ( + _ fs.FS = (*TreeFS)(nil) + _ fs.ReadFileFS = (*TreeFS)(nil) + _ fs.ReadDirFS = (*TreeFS)(nil) + _ fs.StatFS = (*TreeFS)(nil) + _ fs.SubFS = (*TreeFS)(nil) +) + +// ErrGitlinkNotFile reports that +// a gitlink (submodule) entry was opened or read as a file. +// It wraps [fs.ErrInvalid] so that +// generic fs consumers classify it correctly. +var ErrGitlinkNotFile = fmt.Errorf("%w: object/fetch: gitlink entries are not readable as files", fs.ErrInvalid) + +// ErrIsDirectory reports that +// a directory entry was read as a file. +// It wraps [fs.ErrInvalid] so that +// generic fs consumers classify it correctly. +var ErrIsDirectory = fmt.Errorf("%w: object/fetch: is a directory", fs.ErrInvalid) + +func splitPath(path string) [][]byte { + if len(path) == 0 { + return nil + } + + return bytes.Split([]byte(path), []byte("/")) +} + +type treeEntryValue struct { + name string + mode mode.Mode + objectID oid.ObjectID + treeID oid.ObjectID + treeEntry *tree.Entry +} + +func (entry treeEntryValue) isDir() bool { + return entry.mode == mode.Directory +} + +func (entry treeEntryValue) blobSize(fetcher *Fetcher) (int, error) { + return fetcher.Size(entry.objectID) +} + +func (entry treeEntryValue) subtreeID() (oid.ObjectID, error) { + if entry.name == "." { + return entry.treeID, nil + } + + if entry.mode != mode.Directory { + return oid.ObjectID{}, fmt.Errorf("object/fetch: path %q is not a tree", entry.name) //nolint:err113 // Never user-visible. + } + + return entry.objectID, nil +} + +type treeFSInfo struct { + name string + mode fs.FileMode + size int64 + sys any + isDir bool +} + +var ( + _ fs.FileInfo = (*treeFSInfo)(nil) + _ fs.DirEntry = (*treeFSInfo)(nil) +) + +func (info *treeFSInfo) Name() string { return info.name } +func (info *treeFSInfo) Size() int64 { return info.size } +func (info *treeFSInfo) Mode() fs.FileMode { return info.mode } +func (info *treeFSInfo) Type() fs.FileMode { return info.mode.Type() } +func (info *treeFSInfo) IsDir() bool { return info.isDir } +func (info *treeFSInfo) ModTime() time.Time { return time.Time{} } +func (info *treeFSInfo) Sys() any { return info.sys } +func (info *treeFSInfo) Info() (fs.FileInfo, error) { + return info, nil +} + +func treeFSEntryMode(mod mode.Mode) fs.FileMode { + switch mod { + case mode.Directory: + return fs.ModeDir | 0o555 + case mode.Regular: + return 0o444 + case mode.Executable: + return 0o555 + case mode.Symlink: + return fs.ModeSymlink | 0o444 + case mode.Gitlink: + return fs.ModeIrregular + default: + return fs.ModeIrregular + } +} + +// TreeFS returns a new filesystem view rooted at root, which may be any +// tree-ish object accepted by PeelToTreeID. +// +// Labels: Deps-Borrowed, Life-Parent. +func (fetcher *Fetcher) TreeFS(root oid.ObjectID) (*TreeFS, error) { + rootTree, err := fetcher.PeelToTreeID(root) + if err != nil { + return nil, err + } + + return &TreeFS{ + fetcher: fetcher, + rootTree: rootTree, + }, nil +} + +type treeFSOp uint8 + +const ( + treeFSOpOpen treeFSOp = iota + treeFSOpReadFile + treeFSOpReadDir + treeFSOpStat + treeFSOpSub +) + +func (op treeFSOp) pathErrorOp() string { + switch op { + case treeFSOpOpen: + return "open" + case treeFSOpReadFile: + return "readfile" + case treeFSOpReadDir: + return "readdir" + case treeFSOpStat: + return "stat" + case treeFSOpSub: + return "sub" + default: + return "treefs" + } +} + +// Open opens name for reading. +// +// Directories are returned as fs.ReadDirFile values. Gitlink entries are not +// readable through TreeFS. +func (treeFS *TreeFS) Open(name string) (fs.File, error) { + entry, err := treeFS.resolvePath(treeFSOpOpen, name) + if err != nil { + return nil, err + } + + info, err := treeFS.statEntry(entry) + if err != nil { + return nil, treeFSPathError(treeFSOpOpen, name, err) + } + + if entry.isDir() { + treeID, err := entry.subtreeID() + if err != nil { + return nil, treeFSPathError(treeFSOpOpen, name, err) + } + + tree, err := treeFS.fetcher.ExactTree(treeID) + if err != nil { + return nil, treeFSPathError(treeFSOpOpen, name, err) + } + + entries := make([]fs.DirEntry, 0, len(tree.Object().Entries())) + for _, child := range tree.Object().Entries() { + childEntry := treeEntryValue{ + name: string(child.Name), + mode: child.Mode, + objectID: child.ID, + treeEntry: &child, + } + + childInfo, err := treeFS.statEntry(childEntry) + if err != nil { + return nil, treeFSPathError(treeFSOpOpen, name, err) + } + + entries = append(entries, childInfo) + } + + return &treeFSDir{ + info: info, + entries: entries, + }, nil + } + + if entry.mode == mode.Gitlink { + return nil, treeFSPathError(treeFSOpOpen, name, ErrGitlinkNotFile) + } + + reader, _, err := treeFS.fetcher.ExactBlobReader(entry.objectID) + if err != nil { + return nil, treeFSPathError(treeFSOpOpen, name, err) + } + + return &treeFSBlob{ + info: info, + reader: reader, + }, nil +} + +type treeFSBlob struct { + info *treeFSInfo + reader io.ReadCloser +} + +var _ fs.File = (*treeFSBlob)(nil) + +func (file *treeFSBlob) Stat() (fs.FileInfo, error) { return file.info, nil } +func (file *treeFSBlob) Read(p []byte) (int, error) { return file.reader.Read(p) } +func (file *treeFSBlob) Close() error { return file.reader.Close() } + +type treeFSDir struct { + info *treeFSInfo + entries []fs.DirEntry + offset int +} + +var ( + _ fs.File = (*treeFSDir)(nil) + _ fs.ReadDirFile = (*treeFSDir)(nil) +) + +func (dir *treeFSDir) Stat() (fs.FileInfo, error) { return dir.info, nil } +func (dir *treeFSDir) Close() error { return nil } + +func (dir *treeFSDir) Read(_ []byte) (int, error) { + return 0, fs.ErrInvalid +} + +func (dir *treeFSDir) ReadDir(n int) ([]fs.DirEntry, error) { + if dir.offset >= len(dir.entries) && n > 0 { + return nil, io.EOF + } + + if n <= 0 { + out := append([]fs.DirEntry(nil), dir.entries[dir.offset:]...) + dir.offset = len(dir.entries) + + return out, nil + } + + end := min(dir.offset+n, len(dir.entries)) + + out := append([]fs.DirEntry(nil), dir.entries[dir.offset:end]...) + dir.offset = end + + return out, nil +} + +func treeFSValidPath(name string) bool { + return name == "." || fs.ValidPath(name) +} + +func treeFSPathError(op treeFSOp, path string, err error) error { + return &fs.PathError{Op: op.pathErrorOp(), Path: path, Err: err} +} + +// ReadDir reads and returns all directory entries for name. +func (treeFS *TreeFS) ReadDir(name string) ([]fs.DirEntry, error) { + file, err := treeFS.Open(name) + if err != nil { + return nil, err + } + + defer func() { _ = file.Close() }() + + readDirFile, ok := file.(fs.ReadDirFile) + if !ok { + return nil, treeFSPathError(treeFSOpReadDir, name, fs.ErrInvalid) + } + + entries, err := readDirFile.ReadDir(-1) + if err != nil { + return nil, treeFSPathError(treeFSOpReadDir, name, err) + } + + return entries, nil +} + +// ReadFile reads the blob contents at name. +// +// Directories and gitlink entries are not readable through TreeFS. +func (treeFS *TreeFS) ReadFile(name string) ([]byte, error) { + entry, err := treeFS.resolvePath(treeFSOpReadFile, name) + if err != nil { + return nil, err + } + + if entry.isDir() { + return nil, treeFSPathError(treeFSOpReadFile, name, ErrIsDirectory) + } + + if entry.mode == mode.Gitlink { + return nil, treeFSPathError(treeFSOpReadFile, name, ErrGitlinkNotFile) + } + + reader, _, err := treeFS.fetcher.ExactBlobReader(entry.objectID) + if err != nil { + return nil, treeFSPathError(treeFSOpReadFile, name, err) + } + + defer func() { _ = reader.Close() }() + + data, err := io.ReadAll(reader) + if err != nil { + return nil, treeFSPathError(treeFSOpReadFile, name, err) + } + + return data, nil +} + +// Stat returns synthetic file metadata for name. +// +// TreeFS metadata reflects Git tree entry mode and blob size where applicable. +// It does not represent filesystem stat metadata: ModTime is zero, ownership is +// unavailable, and Sys returns the underlying tree.Entry when one exists. +func (treeFS *TreeFS) Stat(name string) (fs.FileInfo, error) { + entry, err := treeFS.resolvePath(treeFSOpStat, name) + if err != nil { + return nil, err + } + + info, err := treeFS.statEntry(entry) + if err != nil { + return nil, treeFSPathError(treeFSOpStat, name, err) + } + + return info, nil +} + +// Sub returns a new TreeFS rooted at dir. +func (treeFS *TreeFS) Sub(dir string) (fs.FS, error) { + entry, err := treeFS.resolvePath(treeFSOpSub, dir) + if err != nil { + return nil, err + } + + treeID, err := entry.subtreeID() + if err != nil { + return nil, treeFSPathError(treeFSOpSub, dir, fs.ErrInvalid) + } + + return &TreeFS{ + fetcher: treeFS.fetcher, + rootTree: treeID, + rootEntry: entry.treeEntry, + }, nil +} + +func (treeFS *TreeFS) resolvePath(op treeFSOp, name string) (treeEntryValue, error) { + if !treeFSValidPath(name) { + return treeEntryValue{}, treeFSPathError(op, name, fs.ErrInvalid) + } + + if name == "." { + return treeEntryValue{ + name: ".", + mode: mode.Directory, + treeID: treeFS.rootTree, + treeEntry: treeFS.rootEntry, + }, nil + } + + entry, err := treeFS.fetcher.Path(treeFS.rootTree, splitPath(name)) + if err != nil { + return treeEntryValue{}, treeFS.pathResolveError(op, name, err) + } + + return treeEntryValue{ + name: string(entry.Name), + mode: entry.Mode, + objectID: entry.ID, + treeEntry: &entry, + }, nil +} + +func (treeFS *TreeFS) pathResolveError(op treeFSOp, name string, err error) error { + if _, ok := errors.AsType[*PathNotFoundError](err); ok { + return treeFSPathError(op, name, fs.ErrNotExist) + } + + if _, ok := errors.AsType[*PathNotTreeError](err); ok { + return treeFSPathError(op, name, fs.ErrInvalid) + } + + if errors.Is(err, ErrPathInvalid) { + return treeFSPathError(op, name, fs.ErrInvalid) + } + + return treeFSPathError(op, name, err) +} + +func (treeFS *TreeFS) statEntry(entry treeEntryValue) (*treeFSInfo, error) { + size := int64(0) + + if entry.mode == mode.Regular || entry.mode == mode.Executable || entry.mode == mode.Symlink { + sz, err := entry.blobSize(treeFS.fetcher) + if err != nil { + return nil, err + } + + size = int64(sz) + } + + var sys any + if entry.treeEntry != nil { + sys = *entry.treeEntry + } + + return &treeFSInfo{ + name: entry.name, + mode: treeFSEntryMode(entry.mode), + size: size, + sys: sys, + isDir: entry.isDir(), + }, nil +} |
