aboutsummaryrefslogtreecommitdiff
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
}