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 }