diff options
| -rw-r--r-- | REFACTOR | 6 | ||||
| -rw-r--r-- | TODO | 2 | ||||
| -rw-r--r-- | errs/doc.go | 2 | ||||
| -rw-r--r-- | errs/missing.go | 25 | ||||
| -rw-r--r-- | errs/type.go | 24 | ||||
| -rw-r--r-- | internal/iolimit/capped_capture_writer.go | 70 | ||||
| -rw-r--r-- | internal/iolimit/capped_capture_writer_test.go | 45 | ||||
| -rw-r--r-- | internal/iolimit/doc.go | 6 | ||||
| -rw-r--r-- | internal/iolimit/expect_length_reader.go | 91 | ||||
| -rw-r--r-- | internal/iolimit/expect_length_reader_test.go | 78 | ||||
| -rw-r--r-- | object/fetch/blob.go | 97 | ||||
| -rw-r--r-- | object/fetch/commit.go | 74 | ||||
| -rw-r--r-- | object/fetch/doc.go | 11 | ||||
| -rw-r--r-- | object/fetch/errors.go | 19 | ||||
| -rw-r--r-- | object/fetch/fetcher.go | 20 | ||||
| -rw-r--r-- | object/fetch/header.go | 30 | ||||
| -rw-r--r-- | object/fetch/object.go | 36 | ||||
| -rw-r--r-- | object/fetch/path.go | 91 | ||||
| -rw-r--r-- | object/fetch/reader.go | 26 | ||||
| -rw-r--r-- | object/fetch/tag.go | 26 | ||||
| -rw-r--r-- | object/fetch/tree.go | 86 | ||||
| -rw-r--r-- | object/fetch/treefs.go | 438 | ||||
| -rw-r--r-- | object/fetch/treefs_test.go | 172 | ||||
| -rw-r--r-- | object/stored/doc.go | 8 | ||||
| -rw-r--r-- | object/stored/stored.go | 28 |
25 files changed, 1505 insertions, 6 deletions
@@ -2,7 +2,6 @@ commitquery diff diff/lines diff/trees -errors format format/commitgraph format/commitgraph/bloom @@ -10,11 +9,9 @@ format/commitgraph/read format/packfile format/packfile/delta format/packfile/delta/apply -internal internal/bufpool internal/iolimit internal/lru -internal/progress network network/protocol network/protocol/pktline @@ -25,15 +22,12 @@ network/protocol/v0v1/server/receivepack network/receivepack network/receivepack/hooks network/receivepack/service -object -object/fetch object/store/dual object/store/loose object/store/packed object/store/packed/internal object/store/packed/internal/ingest object/store/packed/internal/reading -object/stored reachability ref/store ref/store/chain @@ -8,3 +8,5 @@ * Are refname.Tag really the same as tag names? * Interactions with hash-function-transition + +* Check error wrapping in object/fetch diff --git a/errs/doc.go b/errs/doc.go new file mode 100644 index 00000000..f9891334 --- /dev/null +++ b/errs/doc.go @@ -0,0 +1,2 @@ +// Package errs defines error types shared across furgit. +package errs diff --git a/errs/missing.go b/errs/missing.go new file mode 100644 index 00000000..0630846c --- /dev/null +++ b/errs/missing.go @@ -0,0 +1,25 @@ +package errs + +import ( + "fmt" + + "lindenii.org/go/furgit/object/id" +) + +// ObjectMissingError indicates that +// a referenced object is absent from the repository object store. +// +// This should only be used +// in situations where objects are being queried recursively +// or otherwise by some chain that the caller may not be aware of. +// +// Failures on direct object access +// should instead use [lindenii.org/go/furgit/object/store.ErrObjectNotFound]. +type ObjectMissingError struct { + OID id.ObjectID +} + +// Error implements error. +func (e *ObjectMissingError) Error() string { + return fmt.Sprintf("missing object %s", e.OID) +} diff --git a/errs/type.go b/errs/type.go new file mode 100644 index 00000000..f726e89b --- /dev/null +++ b/errs/type.go @@ -0,0 +1,24 @@ +package errs + +import ( + "fmt" + + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/typ" +) + +// ObjectTypeError indicates that a referenced object +// has a different type than what the operation expected. +type ObjectTypeError struct { + OID id.ObjectID + Got typ.Type + Want typ.Type +} + +// Error implements error. +func (e *ObjectTypeError) Error() string { + return fmt.Sprintf( + "object %s has type %s, want %s", + e.OID, e.Got.Name(), e.Want.Name(), + ) +} diff --git a/internal/iolimit/capped_capture_writer.go b/internal/iolimit/capped_capture_writer.go new file mode 100644 index 00000000..e4a64590 --- /dev/null +++ b/internal/iolimit/capped_capture_writer.go @@ -0,0 +1,70 @@ +package iolimit + +import ( + "bytes" + "fmt" + + "lindenii.org/go/lgo/intconv" +) + +// CappedCaptureWriter captures written bytes up to a fixed limit. +// +// Once the total written bytes would exceed the limit, +// capture is disabled and Bytes returns nil. +// Write still reports success for the full input length. +type CappedCaptureWriter struct { + limit uint64 + buf bytes.Buffer + full bool +} + +// NewCappedCaptureWriter constructs one capped capture writer. +func NewCappedCaptureWriter(limit uint64) *CappedCaptureWriter { + return &CappedCaptureWriter{limit: limit} +} + +// Write captures up to the configured limit +// and always reports len(src) bytes written. +func (writer *CappedCaptureWriter) Write(src []byte) (int, error) { + if writer.full { + return len(src), nil + } + + used, err := intconv.IntToUint64(writer.buf.Len()) + if err != nil { + return 0, fmt.Errorf("iolimit: %w", err) + } + + if used >= writer.limit { + writer.full = true + + return len(src), nil + } + + room := writer.limit - used + if uint64(len(src)) > room { + take, err := intconv.Uint64ToInt(room) + if err != nil { + return 0, fmt.Errorf("iolimit: %w", err) + } + + _, _ = writer.buf.Write(src[:take]) + writer.full = true + + return len(src), nil + } + + _, _ = writer.buf.Write(src) + + return len(src), nil +} + +// Bytes returns captured bytes, +// or nil when capture exceeded the limit. +func (writer *CappedCaptureWriter) Bytes() []byte { + if writer.full { + return nil + } + + return writer.buf.Bytes() +} diff --git a/internal/iolimit/capped_capture_writer_test.go b/internal/iolimit/capped_capture_writer_test.go new file mode 100644 index 00000000..2793f8cb --- /dev/null +++ b/internal/iolimit/capped_capture_writer_test.go @@ -0,0 +1,45 @@ +package iolimit_test + +import ( + "bytes" + "testing" + + "lindenii.org/go/furgit/internal/iolimit" +) + +func TestCappedCaptureWriterWithinLimit(t *testing.T) { + t.Parallel() + + writer := iolimit.NewCappedCaptureWriter(8) + + _, _ = writer.Write([]byte("hello")) + _, _ = writer.Write([]byte("!")) + + if got := writer.Bytes(); !bytes.Equal(got, []byte("hello!")) { + t.Fatalf("Bytes() = %q, want %q", got, "hello!") + } +} + +func TestCappedCaptureWriterExceededLimit(t *testing.T) { + t.Parallel() + + writer := iolimit.NewCappedCaptureWriter(4) + + _, _ = writer.Write([]byte("abcd")) + _, _ = writer.Write([]byte("x")) + + if got := writer.Bytes(); got != nil { + t.Fatalf("Bytes() = %q, want nil after overflow", got) + } +} + +func TestCappedCaptureWriterZeroLimit(t *testing.T) { + t.Parallel() + + writer := iolimit.NewCappedCaptureWriter(0) + + _, _ = writer.Write([]byte("x")) + if got := writer.Bytes(); got != nil { + t.Fatalf("Bytes() = %q, want nil at zero limit", got) + } +} diff --git a/internal/iolimit/doc.go b/internal/iolimit/doc.go new file mode 100644 index 00000000..5eb72b2b --- /dev/null +++ b/internal/iolimit/doc.go @@ -0,0 +1,6 @@ +// Package iolimit provides small internal I/O wrappers with bounded behavior. +// +// It includes helpers for both readers and writers +// that enforce configured limits +// (length checks, capped capture, etc.). +package iolimit diff --git a/internal/iolimit/expect_length_reader.go b/internal/iolimit/expect_length_reader.go new file mode 100644 index 00000000..1efe2b75 --- /dev/null +++ b/internal/iolimit/expect_length_reader.go @@ -0,0 +1,91 @@ +package iolimit + +import ( + "errors" + "fmt" + "io" + + "lindenii.org/go/lgo/intconv" +) + +// ErrExpectedLengthExceeded reports that a stream +// produced bytes beyond the expected length. +var ErrExpectedLengthExceeded = errors.New("iolimit: stream exceeded expected length") + +// ExpectLengthReader wraps src and enforces an expected byte length. +// +// It returns io.ErrUnexpectedEOF +// if src ends before expected bytes are read. +// It returns ErrExpectedLengthExceeded +// if reads continue beyond the expected boundary +// and src still produces bytes. +// +// This reader does not drain src on close or at the expected boundary. +// As a result, +// overlength streams are detected only +// when a caller reads at or past the boundary. +func ExpectLengthReader(src io.Reader, expected uint64) io.Reader { + return &expectLengthReader{ + src: src, + remaining: expected, + } +} + +type expectLengthReader struct { + src io.Reader + remaining uint64 +} + +func (reader *expectLengthReader) Read(dst []byte) (int, error) { + if len(dst) == 0 { + return 0, nil + } + + if reader.remaining == 0 { + var probe [1]byte + + n, err := reader.src.Read(probe[:]) + if n > 0 { + return 0, ErrExpectedLengthExceeded + } + + if err == nil { + return 0, nil + } + + return 0, err //nolint:wrapcheck + } + + if uint64(len(dst)) > reader.remaining { + limit, err := intconv.Uint64ToInt(reader.remaining) + if err != nil { + return 0, fmt.Errorf("iolimit: %w", err) + } + + dst = dst[:limit] + } + + n, err := reader.src.Read(dst) + if n > 0 { + read, convErr := intconv.IntToUint64(n) + if convErr != nil { + return n, fmt.Errorf("iolimit: %w", convErr) + } + + reader.remaining -= read + } + + if errors.Is(err, io.EOF) { + if reader.remaining > 0 { + return n, io.ErrUnexpectedEOF + } + + if n > 0 { + return n, nil + } + + return 0, io.EOF + } + + return n, err //nolint:wrapcheck +} diff --git a/internal/iolimit/expect_length_reader_test.go b/internal/iolimit/expect_length_reader_test.go new file mode 100644 index 00000000..6508e5eb --- /dev/null +++ b/internal/iolimit/expect_length_reader_test.go @@ -0,0 +1,78 @@ +package iolimit_test + +import ( + "bytes" + "errors" + "io" + "testing" + + "lindenii.org/go/furgit/internal/iolimit" +) + +func TestExpectLengthReaderExact(t *testing.T) { + t.Parallel() + + r := iolimit.ExpectLengthReader(bytes.NewReader([]byte("hello")), 5) + + got, err := io.ReadAll(r) + if err != nil { + t.Fatalf("ReadAll error: %v", err) + } + + if !bytes.Equal(got, []byte("hello")) { + t.Fatalf("ReadAll = %q, want %q", got, "hello") + } + + buf := make([]byte, 1) + + n, err := r.Read(buf) + if n != 0 || !errors.Is(err, io.EOF) { + t.Fatalf("post-boundary Read = (%d,%v), want (0,EOF)", n, err) + } +} + +func TestExpectLengthReaderShort(t *testing.T) { + t.Parallel() + + r := iolimit.ExpectLengthReader(bytes.NewReader([]byte("hey")), 5) + + _, err := io.ReadAll(r) + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("ReadAll error = %v, want ErrUnexpectedEOF", err) + } +} + +func TestExpectLengthReaderLongDetectedOnNextRead(t *testing.T) { + t.Parallel() + + r := iolimit.ExpectLengthReader(bytes.NewReader([]byte("hello!")), 5) + buf := make([]byte, 5) + + n, err := io.ReadFull(r, buf) + if err != nil { + t.Fatalf("ReadFull error: %v", err) + } + + if n != 5 || !bytes.Equal(buf, []byte("hello")) { + t.Fatalf("ReadFull = (%d,%q), want (5,hello)", n, buf) + } + + probe := make([]byte, 1) + + n, err = r.Read(probe) + if n != 0 || !errors.Is(err, iolimit.ErrExpectedLengthExceeded) { + t.Fatalf("overflow Read = (%d,%v), want (0,ErrExpectedLengthExceeded)", n, err) + } +} + +func TestExpectLengthReaderEmptyExpected(t *testing.T) { + t.Parallel() + + r := iolimit.ExpectLengthReader(bytes.NewReader(nil), 0) + buf := make([]byte, 1) + + n, err := r.Read(buf) + if n != 0 || !errors.Is(err, io.EOF) { + t.Fatalf("Read = (%d,%v), want (0,EOF)", n, err) + } +} diff --git a/object/fetch/blob.go b/object/fetch/blob.go new file mode 100644 index 00000000..581fff90 --- /dev/null +++ b/object/fetch/blob.go @@ -0,0 +1,97 @@ +package fetch + +import ( + "io" + + "lindenii.org/go/furgit/errs" + "lindenii.org/go/furgit/object/blob" + oid "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/stored" + "lindenii.org/go/furgit/object/tag" + "lindenii.org/go/furgit/object/typ" +) + +// ExactBlob reads, parses, and wraps the blob at id. +// +// Labels: Life-Parent. +func (fetcher *Fetcher) ExactBlob(id oid.ObjectID) (*stored.Stored[*blob.Blob], error) { + parsed, err := fetcher.parseObject(id) + if err != nil { + return nil, err + } + + blob, ok := parsed.(*blob.Blob) + if !ok { + return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeBlob} + } + + return stored.New(id, blob), nil +} + +// ExactBlobReader returns a reader for the content of the blob at id, +// together with its content size in bytes. +// +// Labels: Life-Parent, Close-Caller. +func (fetcher *Fetcher) ExactBlobReader(id oid.ObjectID) (io.ReadCloser, uint64, error) { + return fetcher.exactReader(id, typ.TypeBlob) +} + +// PeelToBlob peels tags until it reaches a blob. +// +// Labels: Life-Parent. +func (fetcher *Fetcher) PeelToBlob(id oid.ObjectID) (*stored.Stored[*blob.Blob], error) { + for { + obj, err := fetcher.ExactObject(id) + if err != nil { + return nil, err + } + + switch parsed := obj.Object().(type) { + case *blob.Blob: + return stored.New(id, parsed), nil + case *tag.Tag: + id = parsed.TargetID + default: + return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeBlob} + } + } +} + +// PeelToBlobID peels tags until it reaches a blob object ID. +func (fetcher *Fetcher) PeelToBlobID(id oid.ObjectID) (oid.ObjectID, error) { + for { + ty, _, err := fetcher.Header(id) + if err != nil { + return oid.ObjectID{}, err + } + + switch ty { + case typ.TypeBlob: + return id, nil + case typ.TypeTag: + tag, err := fetcher.ExactTag(id) + if err != nil { + return oid.ObjectID{}, err + } + + id = tag.Object().TargetID + case typ.TypeUnknown, typ.TypeCommit, typ.TypeTree: + return oid.ObjectID{}, &errs.ObjectTypeError{OID: id, Got: ty, Want: typ.TypeBlob} + default: + return oid.ObjectID{}, &errs.ObjectTypeError{OID: id, Got: ty, Want: typ.TypeBlob} + } + } +} + +// PeelToBlobReader returns a reader for the content of the peeled blob at id, +// together with its content size in bytes. +// +// Labels: Life-Parent, Close-Caller. +func (fetcher *Fetcher) PeelToBlobReader(id oid.ObjectID) (io.ReadCloser, uint64, error) { + blobID, err := fetcher.PeelToBlobID(id) + if err != nil { + return nil, 0, err + } + + return fetcher.ExactBlobReader(blobID) +} diff --git a/object/fetch/commit.go b/object/fetch/commit.go new file mode 100644 index 00000000..5d5af892 --- /dev/null +++ b/object/fetch/commit.go @@ -0,0 +1,74 @@ +package fetch + +import ( + "lindenii.org/go/furgit/errs" + "lindenii.org/go/furgit/object/commit" + oid "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/stored" + "lindenii.org/go/furgit/object/tag" + "lindenii.org/go/furgit/object/typ" +) + +// ExactCommit reads, parses, and wraps the commit at id. +// +// Labels: Life-Parent. +func (fetcher *Fetcher) ExactCommit(id oid.ObjectID) (*stored.Stored[*commit.Commit], error) { + parsed, err := fetcher.parseObject(id) + if err != nil { + return nil, err + } + + commit, ok := parsed.(*commit.Commit) + if !ok { + return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeCommit} + } + + return stored.New(id, commit), nil +} + +// PeelToCommit peels tags until it reaches a commit. +// +// Labels: Life-Parent. +func (fetcher *Fetcher) PeelToCommit(id oid.ObjectID) (*stored.Stored[*commit.Commit], error) { + for { + obj, err := fetcher.ExactObject(id) + if err != nil { + return nil, err + } + + switch parsed := obj.Object().(type) { + case *commit.Commit: + return stored.New(id, parsed), nil + case *tag.Tag: + id = parsed.TargetID + default: + return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeCommit} + } + } +} + +// PeelToCommitID peels tags until it reaches a commit object ID. +func (fetcher *Fetcher) PeelToCommitID(id oid.ObjectID) (oid.ObjectID, error) { + for { + ty, _, err := fetcher.Header(id) + if err != nil { + return oid.ObjectID{}, err + } + + switch ty { + case typ.TypeCommit: + return id, nil + case typ.TypeTag: + tag, err := fetcher.ExactTag(id) + if err != nil { + return oid.ObjectID{}, err + } + + id = tag.Object().TargetID + case typ.TypeUnknown, typ.TypeTree, typ.TypeBlob: + return oid.ObjectID{}, &errs.ObjectTypeError{OID: id, Got: ty, Want: typ.TypeCommit} + default: + return oid.ObjectID{}, &errs.ObjectTypeError{OID: id, Got: ty, Want: typ.TypeCommit} + } + } +} diff --git a/object/fetch/doc.go b/object/fetch/doc.go new file mode 100644 index 00000000..b5c66f50 --- /dev/null +++ b/object/fetch/doc.go @@ -0,0 +1,11 @@ +// Package fetch loads typed Git objects from object storage +// and provides higher-level object queries. +// +// Fetching is above [objectstore]: +// it parses stored objects +// into blobs, trees, commits, and tags, +// exposes object metadata, +// peels tree-ish or commit-ish objects, +// resolves paths within trees, +// and can expose one tree as an [io/fs]. +package fetch diff --git a/object/fetch/errors.go b/object/fetch/errors.go new file mode 100644 index 00000000..84a21bbb --- /dev/null +++ b/object/fetch/errors.go @@ -0,0 +1,19 @@ +package fetch + +import ( + "errors" + + "lindenii.org/go/furgit/errs" + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/store" +) + +// wrapObjectReadError maps raw object-store lookup failures to fetcher-level +// object lookup errors. +func wrapObjectReadError(id id.ObjectID, err error) error { + if errors.Is(err, store.ErrObjectNotFound) { + return &errs.ObjectMissingError{OID: id} + } + + return err +} diff --git a/object/fetch/fetcher.go b/object/fetch/fetcher.go new file mode 100644 index 00000000..653baf22 --- /dev/null +++ b/object/fetch/fetcher.go @@ -0,0 +1,20 @@ +package fetch + +import "lindenii.org/go/furgit/object/store" + +// Fetcher provides ordinary object access above an object store. +// +// It exposes object metadata, typed object loading, tree-ish and commit-ish +// peeling, path resolution, one-tree fs views, and blob content streaming. +// +// Labels: MT-Safe. +type Fetcher struct { + store store.ObjectReader +} + +// New returns a Fetcher that reads objects from store. +// +// Labels: Deps-Borrowed, Life-Parent. +func New(store store.ObjectReader) *Fetcher { + return &Fetcher{store: store} +} diff --git a/object/fetch/header.go b/object/fetch/header.go new file mode 100644 index 00000000..d8cc7644 --- /dev/null +++ b/object/fetch/header.go @@ -0,0 +1,30 @@ +package fetch + +import ( + oid "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/typ" +) + +// Header returns the object type and content size at id. +// +// Labels: Life-Parent. +func (fetcher *Fetcher) Header(id oid.ObjectID) (typ.Type, uint64, error) { + ty, size, err := fetcher.store.ReadHeader(id) + if err != nil { + return typ.TypeUnknown, 0, wrapObjectReadError(id, err) + } + + return ty, size, nil +} + +// Size returns the object content size at id. +// +// Labels: Life-Parent. +func (fetcher *Fetcher) Size(id oid.ObjectID) (uint64, error) { + size, err := fetcher.store.ReadSize(id) + if err != nil { + return 0, wrapObjectReadError(id, err) + } + + return size, nil +} diff --git a/object/fetch/object.go b/object/fetch/object.go new file mode 100644 index 00000000..fdb46634 --- /dev/null +++ b/object/fetch/object.go @@ -0,0 +1,36 @@ +package fetch + +import ( + "fmt" + + "lindenii.org/go/furgit/object" + oid "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/stored" +) + +// ExactObject reads, parses, and wraps the object at id without constraining +// its concrete object kind. +// +// Labels: Life-Parent. +func (fetcher *Fetcher) ExactObject(id oid.ObjectID) (*stored.Stored[object.Object], error) { + parsed, err := fetcher.parseObject(id) + if err != nil { + return nil, err + } + + return stored.New(id, parsed), nil +} + +func (fetcher *Fetcher) parseObject(id oid.ObjectID) (object.Object, error) { //nolint:ireturn + ty, content, err := fetcher.store.ReadBytesContent(id) + if err != nil { + return nil, wrapObjectReadError(id, err) + } + + parsed, err := object.ParseWithoutHeader(ty, content, id.ObjectFormat()) + if err != nil { + return nil, fmt.Errorf("object/fetch: parse object %s (%s): %w", id, ty.Name(), err) + } + + return parsed, nil +} diff --git a/object/fetch/path.go b/object/fetch/path.go new file mode 100644 index 00000000..f8eca507 --- /dev/null +++ b/object/fetch/path.go @@ -0,0 +1,91 @@ +package fetch + +import ( + "errors" + "fmt" + + oid "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/tree" + "lindenii.org/go/furgit/object/tree/mode" +) + +var ErrPathInvalid = errors.New("object/fetch: invalid tree path") + +// PathNotFoundError indicates that one tree path segment was not found. +type PathNotFoundError struct { + Index int + Name []byte +} + +func (err *PathNotFoundError) Error() string { + return fmt.Sprintf("object/fetch: tree entry %q not found at index %d", err.Name, err.Index) +} + +// PathNotTreeError indicates that one intermediate path segment was not a tree. +type PathNotTreeError struct { + Index int + Name []byte +} + +func (err *PathNotTreeError) Error() string { + return fmt.Sprintf("object/fetch: path segment %q at index %d is not a tree", err.Name, err.Index) +} + +// Path resolves parts within the tree identified by root +// and returns the final tree entry. +// +// The root object may be any tree-ish object accepted by PeelToTree. +// +// parts must contain at least one path segment. +// Intermediate path segments must resolve to tree entries. +// The final entry is returned without loading its object. +// Path segments may not contain \x00. +// +// If your entry names are valid UTF-8 +// and uses / solely as segment separators, +// it may be convenient to use TreeFS +// for an io/fs.FS-like interface. +// +// Labels: Life-Parent. +func (fetcher *Fetcher) Path(root oid.ObjectID, parts []string) (tree.Entry, error) { + if len(parts) == 0 { + return tree.Entry{}, ErrPathInvalid + } + + current, err := fetcher.PeelToTree(root) + if err != nil { + return tree.Entry{}, err + } + + for i, part := range parts { + if len(part) == 0 { + return tree.Entry{}, ErrPathInvalid + } + + entry, ok := current.Object().Find(part) + if !ok { + return tree.Entry{}, &PathNotFoundError{ + Index: i, + Name: append([]byte(nil), part...), + } + } + + if i == len(parts)-1 { + return entry, nil + } + + if entry.Mode != mode.Directory { + return tree.Entry{}, &PathNotTreeError{ + Index: i, + Name: append([]byte(nil), part...), + } + } + + current, err = fetcher.ExactTree(entry.ID) + if err != nil { + return tree.Entry{}, err + } + } + + return tree.Entry{}, &PathNotFoundError{Index: len(parts) - 1} +} diff --git a/object/fetch/reader.go b/object/fetch/reader.go new file mode 100644 index 00000000..8baf1119 --- /dev/null +++ b/object/fetch/reader.go @@ -0,0 +1,26 @@ +package fetch + +import ( + "io" + + "lindenii.org/go/furgit/errs" + oid "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/typ" +) + +// exactReader reads one object's content stream +// and verifies that its header type matches wantType. +func (fetcher *Fetcher) exactReader(id oid.ObjectID, wantType typ.Type) (io.ReadCloser, uint64, error) { + gotType, size, rc, err := fetcher.store.ReadReaderContent(id) + if err != nil { + return nil, 0, wrapObjectReadError(id, err) + } + + if gotType != wantType { + _ = rc.Close() + + return nil, 0, &errs.ObjectTypeError{OID: id, Got: gotType, Want: wantType} + } + + return rc, size, nil +} diff --git a/object/fetch/tag.go b/object/fetch/tag.go new file mode 100644 index 00000000..326244d8 --- /dev/null +++ b/object/fetch/tag.go @@ -0,0 +1,26 @@ +package fetch + +import ( + "lindenii.org/go/furgit/errs" + oid "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/stored" + "lindenii.org/go/furgit/object/tag" + "lindenii.org/go/furgit/object/typ" +) + +// ExactTag reads, parses, and wraps the tag at id. +// +// Labels: Life-Parent. +func (fetcher *Fetcher) ExactTag(id oid.ObjectID) (*stored.Stored[*tag.Tag], error) { + parsed, err := fetcher.parseObject(id) + if err != nil { + return nil, err + } + + tag, ok := parsed.(*tag.Tag) + if !ok { + return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeTag} + } + + return stored.New(id, tag), nil +} diff --git a/object/fetch/tree.go b/object/fetch/tree.go new file mode 100644 index 00000000..add41274 --- /dev/null +++ b/object/fetch/tree.go @@ -0,0 +1,86 @@ +package fetch + +import ( + "lindenii.org/go/furgit/errs" + "lindenii.org/go/furgit/object/commit" + oid "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/stored" + "lindenii.org/go/furgit/object/tag" + "lindenii.org/go/furgit/object/tree" + "lindenii.org/go/furgit/object/typ" +) + +// ExactTree reads, parses, and wraps the tree at id. +// +// Labels: Life-Parent. +func (fetcher *Fetcher) ExactTree(id oid.ObjectID) (*stored.Stored[*tree.Tree], error) { + parsed, err := fetcher.parseObject(id) + if err != nil { + return nil, err + } + + tree, ok := parsed.(*tree.Tree) + if !ok { + return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeTree} + } + + return stored.New(id, tree), nil +} + +// PeelToTree peels tags until it reaches a tree or commit. If it reaches a +// commit, it returns the commit's root tree. +// +// Labels: Life-Parent. +func (fetcher *Fetcher) PeelToTree(id oid.ObjectID) (*stored.Stored[*tree.Tree], error) { + for { + obj, err := fetcher.ExactObject(id) + if err != nil { + return nil, err + } + + switch parsed := obj.Object().(type) { + case *tree.Tree: + return stored.New(id, parsed), nil + case *commit.Commit: + return fetcher.ExactTree(parsed.Tree) + case *tag.Tag: + id = parsed.TargetID + default: + return nil, &errs.ObjectTypeError{OID: id, Got: parsed.ObjectType(), Want: typ.TypeTree} + } + } +} + +// PeelToTreeID peels tags until it reaches a tree object ID, or a commit whose +// root tree object ID is then returned. +func (fetcher *Fetcher) PeelToTreeID(id oid.ObjectID) (oid.ObjectID, error) { + for { + ty, _, err := fetcher.Header(id) + if err != nil { + return oid.ObjectID{}, err + } + + switch ty { + case typ.TypeTree: + return id, nil + case typ.TypeCommit: + commit, err := fetcher.ExactCommit(id) + if err != nil { + return oid.ObjectID{}, err + } + + return commit.Object().Tree, nil + case typ.TypeTag: + tag, err := fetcher.ExactTag(id) + if err != nil { + return oid.ObjectID{}, err + } + + id = tag.Object().TargetID + case typ.TypeUnknown, typ.TypeBlob: + return oid.ObjectID{}, &errs.ObjectTypeError{OID: id, Got: ty, Want: typ.TypeTree} + default: + return oid.ObjectID{}, &errs.ObjectTypeError{OID: id, Got: ty, Want: typ.TypeTree} + } + } +} diff --git a/object/fetch/treefs.go b/object/fetch/treefs.go new file mode 100644 index 00000000..6a70f98f --- /dev/null +++ b/object/fetch/treefs.go @@ -0,0 +1,438 @@ +package fetch + +import ( + "errors" + "fmt" + "io" + "io/fs" + "strings" + "time" + + oid "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/tree" + "lindenii.org/go/furgit/object/tree/mode" + "lindenii.org/go/lgo/intconv" +) + +// 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) +) + +func splitPath(path string) []string { + if len(path) == 0 { + return nil + } + + return strings.Split(path, "/") +} + +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) (uint64, 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) + } + + 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: 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, fmt.Errorf("object/fetch: gitlink entries are not readable as files")) + } + + 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) + } + + return readDirFile.ReadDir(-1) +} + +// 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, fmt.Errorf("is a directory")) + } + + if entry.mode == mode.Gitlink { + return nil, treeFSPathError(treeFSOpReadFile, name, fmt.Errorf("object/fetch: gitlink entries are not readable as files")) + } + + 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: 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, err = intconv.Uint64ToInt64(sz) + if err != nil { + return nil, err + } + } + + 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 +} diff --git a/object/fetch/treefs_test.go b/object/fetch/treefs_test.go new file mode 100644 index 00000000..73f1c591 --- /dev/null +++ b/object/fetch/treefs_test.go @@ -0,0 +1,172 @@ +package fetch_test + +import ( + "errors" + "io/fs" + "testing" + + "lindenii.org/go/furgit/object/commit" + "lindenii.org/go/furgit/object/fetch" + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/signature" + "lindenii.org/go/furgit/object/store/memory" + "lindenii.org/go/furgit/object/tree" + "lindenii.org/go/furgit/object/tree/mode" + "lindenii.org/go/furgit/object/typ" +) + +func TestTreeFS(t *testing.T) { + t.Parallel() + + for _, objectFormat := range id.SupportedObjectFormats() { + t.Run(objectFormat.String(), func(t *testing.T) { + t.Parallel() + + store := memory.New(objectFormat) + + plainID, err := store.WriteBytesContent(typ.TypeBlob, []byte("plain\n")) + if err != nil { + t.Fatalf("WriteBytesContent(plain.txt): %v", err) + } + + execID, err := store.WriteBytesContent(typ.TypeBlob, []byte("#!/bin/sh\nexit 0\n")) + if err != nil { + t.Fatalf("WriteBytesContent(exec.sh): %v", err) + } + + subTreeID := writeTree(t, store, []tree.Entry{ + {Mode: mode.Executable, Name: "exec.sh", ID: execID}, + }) + + rootTreeID := writeTree(t, store, []tree.Entry{ + {Mode: mode.Regular, Name: "plain.txt", ID: plainID}, + {Mode: mode.Directory, Name: "dir", ID: subTreeID}, + }) + + commitID := writeCommit(t, store, rootTreeID) + + fetcher := fetch.New(store) + + treeFS, err := fetcher.TreeFS(commitID) + if err != nil { + t.Fatalf("fetcher.TreeFS: %v", err) + } + + content, err := treeFS.ReadFile("plain.txt") + if err != nil { + t.Fatalf("ReadFile(plain.txt): %v", err) + } + + if string(content) != "plain\n" { + t.Fatalf("ReadFile(plain.txt) = %q, want %q", string(content), "plain\n") + } + + entries, err := treeFS.ReadDir(".") + if err != nil { + t.Fatalf("ReadDir(.): %v", err) + } + + if len(entries) != 2 { + t.Fatalf("len(ReadDir(.)) = %d, want 2", len(entries)) + } + + info, err := treeFS.Stat("plain.txt") + if err != nil { + t.Fatalf("Stat(plain.txt): %v", err) + } + + entry, ok := info.Sys().(tree.Entry) + if !ok { + t.Fatalf("Stat(plain.txt).Sys() type = %T, want tree.Entry", info.Sys()) + } + + if entry.Mode != mode.Regular { + t.Fatalf("Stat(plain.txt).Sys().Mode = %o, want %o", entry.Mode, mode.Regular) + } + + subFS, err := treeFS.Sub("dir") + if err != nil { + t.Fatalf("Sub(dir): %v", err) + } + + subReadFileFS, ok := subFS.(fs.ReadFileFS) + if !ok { + t.Fatalf("Sub(dir) type does not implement fs.ReadFileFS") + } + + subContent, err := subReadFileFS.ReadFile("exec.sh") + if err != nil { + t.Fatalf("Sub(dir).ReadFile(exec.sh): %v", err) + } + + if string(subContent) != "#!/bin/sh\nexit 0\n" { + t.Fatalf("Sub(dir).ReadFile(exec.sh) = %q", string(subContent)) + } + + _, err = treeFS.ReadFile("dir") + if err == nil { + t.Fatal("ReadFile(dir) unexpectedly succeeded") + } + + if _, ok := errors.AsType[*fs.PathError](err); !ok { + t.Fatalf("ReadFile(dir) err type = %T, want *fs.PathError", err) + } + }) + } +} + +// writeTree builds a tree object from entries, writes it to store, +// and returns its object ID. +func writeTree(t *testing.T, store *memory.Memory, entries []tree.Entry) id.ObjectID { + t.Helper() + + tr := new(tree.Tree) + for _, entry := range entries { + err := tr.Insert(entry) + if err != nil { + t.Fatalf("tree.Insert(%q): %v", entry.Name, err) + } + } + + body, err := tr.AppendWithoutHeader(nil) + if err != nil { + t.Fatalf("tree.AppendWithoutHeader: %v", err) + } + + treeID, err := store.WriteBytesContent(typ.TypeTree, body) + if err != nil { + t.Fatalf("WriteBytesContent(tree): %v", err) + } + + return treeID +} + +// writeCommit builds a commit object pointing at tree, writes it to store, +// and returns its object ID. +func writeCommit(t *testing.T, store *memory.Memory, tree id.ObjectID) id.ObjectID { + t.Helper() + + who := signature.Signature{ + Name: []byte("Test Author"), + Email: []byte("author@example.org"), + WhenUnix: 1234567890, + OffsetMinutes: 0, + } + + body, err := (&commit.Commit{ + Tree: tree, + Author: who, + Committer: who, + Message: []byte("treefs\n"), + }).AppendWithoutHeader(nil) + if err != nil { + t.Fatalf("commit.AppendWithoutHeader: %v", err) + } + + commitID, err := store.WriteBytesContent(typ.TypeCommit, body) + if err != nil { + t.Fatalf("WriteBytesContent(commit): %v", err) + } + + return commitID +} diff --git a/object/stored/doc.go b/object/stored/doc.go new file mode 100644 index 00000000..93a6021b --- /dev/null +++ b/object/stored/doc.go @@ -0,0 +1,8 @@ +// Package stored wraps parsed objects +// with the object IDs they were loaded under. +// +// Parsed git object values do not carry storage identity on their own. +// This package provides a small generic wrapper +// for the common case where callers need +// both the parsed object value and the object ID it was read from. +package stored diff --git a/object/stored/stored.go b/object/stored/stored.go new file mode 100644 index 00000000..68a7bfd0 --- /dev/null +++ b/object/stored/stored.go @@ -0,0 +1,28 @@ +package stored + +import ( + "lindenii.org/go/furgit/object" + "lindenii.org/go/furgit/object/id" +) + +// Stored represents a stored object, +// i.e., an object along with its object ID. +type Stored[T object.Object] struct { + id id.ObjectID + obj T +} + +// New creates one stored object wrapper. +func New[T object.Object](id id.ObjectID, obj T) *Stored[T] { + return &Stored[T]{id: id, obj: obj} +} + +// Object returns the wrapped object as itself. +func (stored *Stored[T]) Object() T { //nolint:ireturn + return stored.obj +} + +// ID returns the object ID. +func (stored *Stored[T]) ID() id.ObjectID { + return stored.id +} |
