diff options
| author | 2026-06-08 12:47:13 +0000 | |
|---|---|---|
| committer | 2026-06-08 12:47:13 +0000 | |
| commit | 6175c0f1445df26fbc5679f966546a449974584c (patch) | |
| tree | 8f1bd59b85bd595a1b539be9cf2f3300610ebb2c | |
| parent | object/store/loose: Add doc (diff) | |
| signature | No signature | |
object/store/loose: Add
| -rw-r--r-- | object/store/loose/loose.go | 62 | ||||
| -rw-r--r-- | object/store/loose/parse.go | 61 | ||||
| -rw-r--r-- | object/store/loose/quarantine.go | 203 | ||||
| -rw-r--r-- | object/store/loose/reader.go | 239 | ||||
| -rw-r--r-- | object/store/loose/streamwriter.go | 277 | ||||
| -rw-r--r-- | object/store/loose/writer.go | 84 |
6 files changed, 926 insertions, 0 deletions
diff --git a/object/store/loose/loose.go b/object/store/loose/loose.go new file mode 100644 index 00000000..02a63df1 --- /dev/null +++ b/object/store/loose/loose.go @@ -0,0 +1,62 @@ +package loose + +import ( + "fmt" + "os" + "path/filepath" + + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/store" +) + +// Loose reads loose Git objects from an objects directory root. +// +// Loose objects are zlib streams whose trailer uses Adler-32. +// Which reads consume enough of the stream +// to reach and verify that trailer +// is documented on the individual methods. +// +// Labels: Close-Caller. +type Loose struct { + // root is the objects directory capability used for all object file access. + // Object files are opened by relative paths like "<first2>/<rest>". + // Loose borrows this root. + root *os.Root + // objectFormat is the expected object format for lookups. + objectFormat id.ObjectFormat +} + +var ( + _ store.ObjectReader = (*Loose)(nil) + _ store.ObjectWriter = (*Loose)(nil) +) + +// New creates a loose-object store rooted at an objects directory for objectFormat. +// +// Labels: Deps-Borrowed, Life-Parent. +func New(root *os.Root, objectFormat id.ObjectFormat) (*Loose, error) { + if objectFormat.Size() == 0 { + return nil, id.ErrInvalidObjectFormat + } + + return &Loose{ + root: root, + objectFormat: objectFormat, + }, nil +} + +// Close releases resources associated with the backend. +// +// Labels: MT-Unsafe. +func (loose *Loose) Close() error { return nil } + +// objectPath returns the loose object path for objectID relative to the objects root. +func (loose *Loose) objectPath(objectID id.ObjectID) (string, error) { + if objectID.ObjectFormat() != loose.objectFormat { + return "", fmt.Errorf("%w: got %s want %s", id.ErrInvalidObjectFormat, objectID.ObjectFormat(), loose.objectFormat) + } + + hex := objectID.String() + + return filepath.Join(hex[:2], hex[2:]), nil +} diff --git a/object/store/loose/parse.go b/object/store/loose/parse.go new file mode 100644 index 00000000..c3b9275e --- /dev/null +++ b/object/store/loose/parse.go @@ -0,0 +1,61 @@ +package loose + +import ( + "bufio" + "fmt" + "io" + "os" + + "lindenii.org/go/furgit/internal/compress/zlib" + "lindenii.org/go/furgit/object/header" + "lindenii.org/go/furgit/object/store" + "lindenii.org/go/furgit/object/typ" +) + +// decodeAll inflates the full loose object payload from file. +func decodeAll(file *os.File) ([]byte, error) { + zr, err := zlib.NewReader(file) + if err != nil { + return nil, fmt.Errorf("object/store/loose: %w", err) + } + + defer func() { _ = zr.Close() }() + + data, err := io.ReadAll(zr) + if err != nil { + return nil, fmt.Errorf("object/store/loose: %w", err) + } + + return data, nil +} + +// parseRaw parses a loose object payload in "type size\0content" format. +func parseRaw(raw []byte) (typ.Type, []byte, error) { + ty, size, consumed, err := header.Parse(raw) + if err != nil { + return typ.TypeUnknown, nil, fmt.Errorf("%w: %w", store.ErrInvalidObject, err) + } + + content := raw[consumed:] + if uint64(len(content)) != size { + return typ.TypeUnknown, nil, fmt.Errorf("%w: header size/content mismatch", store.ErrInvalidObject) + } + + return ty, content, nil +} + +// readHeader reads and parses a loose object header from br, +// and returns the raw header bytes including the trailing NUL. +func readHeader(br *bufio.Reader) ([]byte, typ.Type, uint64, error) { + headerBytes, err := br.ReadSlice(0) + if err != nil { + return nil, typ.TypeUnknown, 0, fmt.Errorf("object/store/loose: %w", err) + } + + ty, size, _, err := header.Parse(headerBytes) + if err != nil { + return nil, typ.TypeUnknown, 0, fmt.Errorf("%w: %w", store.ErrInvalidObject, err) + } + + return headerBytes, ty, size, nil +} diff --git a/object/store/loose/quarantine.go b/object/store/loose/quarantine.go new file mode 100644 index 00000000..214f7219 --- /dev/null +++ b/object/store/loose/quarantine.go @@ -0,0 +1,203 @@ +package loose + +import ( + "crypto/rand" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "lindenii.org/go/furgit/object/store" +) + +var ( + _ store.ObjectQuarantiner = (*Loose)(nil) + _ store.ObjectQuarantine = (*objectQuarantine)(nil) +) + +// objectQuarantine is one quarantined loose store +// rooted privately beneath a destination loose root. +type objectQuarantine struct { + *Loose + + parent *Loose + tempName string + tempRoot *os.Root +} + +// BeginObjectQuarantine creates one quarantined loose store rooted privately +// beneath the destination loose root. +// +// Labels: Deps-Borrowed, Life-Parent, Close-No. +func (loose *Loose) BeginObjectQuarantine(_ store.ObjectQuarantineOptions) (store.ObjectQuarantine, error) { //nolint:ireturn + tempName, tempRoot, err := createLooseQuarantineRoot(loose.root) + if err != nil { + return nil, err + } + + quarantineStore, err := New(tempRoot, loose.objectFormat) + if err != nil { + _ = tempRoot.Close() + _ = loose.root.RemoveAll(tempName) + + return nil, err + } + + return &objectQuarantine{ + Loose: quarantineStore, + parent: loose, + tempName: tempName, + tempRoot: tempRoot, + }, nil +} + +// Discard removes the quarantine and invalidates the receiver. +func (quarantine *objectQuarantine) Discard() error { + closeErr := quarantine.Close() + tempRootErr := quarantine.tempRoot.Close() + removeErr := quarantine.parent.root.RemoveAll(quarantine.tempName) + + if closeErr != nil { + return closeErr + } + + if tempRootErr != nil { + return fmt.Errorf("object/store/loose: %w", tempRootErr) + } + + if removeErr != nil { + return fmt.Errorf("object/store/loose: %w", removeErr) + } + + return nil +} + +// Promote publishes all quarantined loose objects into the parent loose store +// and invalidates the receiver. +func (quarantine *objectQuarantine) Promote() error { + closeErr := quarantine.Close() + promoteErr := promoteLooseQuarantine(quarantine.parent, quarantine.tempName, quarantine.tempRoot) + tempRootErr := quarantine.tempRoot.Close() + removeErr := quarantine.parent.root.RemoveAll(quarantine.tempName) + + if closeErr != nil { + return closeErr + } + + if promoteErr != nil { + return promoteErr + } + + if tempRootErr != nil { + return fmt.Errorf("object/store/loose: %w", tempRootErr) + } + + if removeErr != nil { + return fmt.Errorf("object/store/loose: %w", removeErr) + } + + return nil +} + +func createLooseQuarantineRoot(parent *os.Root) (string, *os.Root, error) { + var lastErr error + + for range 32 { + name := "tmp_looseq_" + rand.Text() + + err := parent.Mkdir(name, 0o700) + if err == nil { + root, err := parent.OpenRoot(name) + if err == nil { + return name, root, nil + } + + _ = parent.RemoveAll(name) + + return "", nil, fmt.Errorf("object/store/loose: %w", err) + } + + lastErr = err + + if errors.Is(err, fs.ErrExist) { + continue + } + + return "", nil, fmt.Errorf("object/store/loose: %w", err) + } + + return "", nil, fmt.Errorf("object/store/loose: failed to create quarantine directory: %w", lastErr) +} + +func promoteLooseQuarantine(parent *Loose, tempName string, tempRoot *os.Root) error { + entries, err := fs.ReadDir(tempRoot.FS(), ".") + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("object/store/loose: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + return fmt.Errorf("%w: quarantine contains unexpected file %q", store.ErrInvalidObject, entry.Name()) + } + + err := promoteLooseQuarantineShard(parent, tempName, tempRoot, entry.Name()) + if err != nil { + return err + } + } + + return nil +} + +func promoteLooseQuarantineShard(parent *Loose, tempName string, tempRoot *os.Root, shard string) error { + entries, err := fs.ReadDir(tempRoot.FS(), shard) + if err != nil { + return fmt.Errorf("object/store/loose: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + return fmt.Errorf("%w: quarantine shard %q contains unexpected directory %q", store.ErrInvalidObject, shard, entry.Name()) + } + + objectID, err := parent.objectFormat.FromString(shard + entry.Name()) + if err != nil { + return fmt.Errorf("%w: quarantine shard %q contains invalid object %q: %w", store.ErrInvalidObject, shard, entry.Name(), err) + } + + dst, err := parent.objectPath(objectID) + if err != nil { + return err + } + + err = parent.root.MkdirAll(shard, 0o755) + if err != nil { + return fmt.Errorf("object/store/loose: %w", err) + } + + err = promoteLooseQuarantineObject(parent.root, filepath.Join(tempName, shard, entry.Name()), dst) + if err != nil { + return err + } + } + + return nil +} + +func promoteLooseQuarantineObject(root *os.Root, src, dst string) error { + err := root.Link(src, dst) + if err == nil { + _ = root.Remove(src) + + return nil + } + + if errors.Is(err, fs.ErrExist) { + _ = root.Remove(src) + + return nil + } + + return fmt.Errorf("object/store/loose: promote quarantine %q -> %q: %w", src, dst, err) +} diff --git a/object/store/loose/reader.go b/object/store/loose/reader.go new file mode 100644 index 00000000..45a0d325 --- /dev/null +++ b/object/store/loose/reader.go @@ -0,0 +1,239 @@ +package loose + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "os" + + "lindenii.org/go/furgit/internal/compress/zlib" + "lindenii.org/go/furgit/internal/iolimit" + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/store" + "lindenii.org/go/furgit/object/typ" +) + +// ReadBytesFull reads a full serialized object as "type size\0content". +// +// It inflates and parses the full loose object, +// including verifying the zlib Adler-32 trailer. +func (loose *Loose) ReadBytesFull(objectID id.ObjectID) ([]byte, error) { + raw, _, _, err := loose.readBytesParsed(objectID) + if err != nil { + return nil, err + } + + return raw, nil +} + +// ReadBytesContent reads an object's type and content bytes. +// +// Like ReadBytesFull, +// it inflates and parses the full loose object, +// including verifying the zlib Adler-32 trailer. +func (loose *Loose) ReadBytesContent(objectID id.ObjectID) (typ.Type, []byte, error) { + _, ty, content, err := loose.readBytesParsed(objectID) + if err != nil { + return typ.TypeUnknown, nil, err + } + + return ty, content, nil +} + +// ReadHeader reads an object's type and declared content length. +// +// It parses only enough of the zlib-decoded object to recover the object header. +// It does not verify that the remaining object content is readable +// and does not verify the zlib Adler-32 trailer. +func (loose *Loose) ReadHeader(objectID id.ObjectID) (typ.Type, uint64, error) { + file, err := loose.openObject(objectID) + if err != nil { + return typ.TypeUnknown, 0, err + } + + defer func() { _ = file.Close() }() + + zr, err := zlib.NewReader(file) + if err != nil { + return typ.TypeUnknown, 0, fmt.Errorf("object/store/loose: %w", err) + } + + defer func() { _ = zr.Close() }() + + _, ty, size, err := readHeader(bufio.NewReader(zr)) + if err != nil { + return typ.TypeUnknown, 0, err + } + + return ty, size, nil +} + +// ReadSize reads an object's declared content length. +// +// Like ReadHeader, +// it parses only enough of the zlib-decoded object to recover the header +// and does not verify the zlib Adler-32 trailer. +func (loose *Loose) ReadSize(objectID id.ObjectID) (uint64, error) { + _, size, err := loose.ReadHeader(objectID) + + return size, err +} + +// ReadReaderFull reads a full serialized object stream as "type size\0content". +// +// Close releases resources only. +// It does not drain unread data for additional validation. +// In particular, +// malformed trailing compressed data, +// trailing bytes past the declared object size, +// and the zlib Adler-32 trailer +// may go unverified unless the caller reads to io.EOF. +func (loose *Loose) ReadReaderFull(objectID id.ObjectID) (io.ReadCloser, error) { + file, zr, err := loose.openInflated(objectID) + if err != nil { + return nil, err + } + + br := bufio.NewReader(zr) + + headerBytes, _, size, err := readHeader(br) + if err != nil { + _ = zr.Close() + _ = file.Close() + + return nil, err + } + + return &objectReader{ + reader: io.MultiReader( + bytes.NewReader(headerBytes), + iolimit.ExpectLengthReader(br, size), + ), + file: file, + zr: zr, + }, nil +} + +// ReadReaderContent reads an object's type, declared content length, +// and content stream. +// +// Close releases resources only. +// It does not drain unread data for additional validation. +// In particular, +// malformed trailing compressed data, +// trailing bytes past the declared object size, +// and the zlib Adler-32 trailer +// may go unverified unless the caller reads to io.EOF. +func (loose *Loose) ReadReaderContent(objectID id.ObjectID) (typ.Type, uint64, io.ReadCloser, error) { + file, zr, err := loose.openInflated(objectID) + if err != nil { + return typ.TypeUnknown, 0, nil, err + } + + br := bufio.NewReader(zr) + + _, ty, size, err := readHeader(br) + if err != nil { + _ = zr.Close() + _ = file.Close() + + return typ.TypeUnknown, 0, nil, err + } + + return ty, size, &objectReader{ + reader: iolimit.ExpectLengthReader(br, size), + file: file, + zr: zr, + }, nil +} + +// Refresh is a no-op for loose object stores. +func (loose *Loose) Refresh() error { + return nil +} + +// openObject opens the loose object file for objectID. +// Missing files cause store.ErrObjectNotFound. +func (loose *Loose) openObject(objectID id.ObjectID) (*os.File, error) { + relPath, err := loose.objectPath(objectID) + if err != nil { + return nil, err + } + + file, err := loose.root.Open(relPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, store.ErrObjectNotFound + } + + return nil, fmt.Errorf("object/store/loose: %w", err) + } + + return file, nil +} + +// readBytesParsed reads, inflates, and parses a loose object in one pass. +// It returns the full raw payload and its parsed type and content. +func (loose *Loose) readBytesParsed(objectID id.ObjectID) ([]byte, typ.Type, []byte, error) { + file, err := loose.openObject(objectID) + if err != nil { + return nil, typ.TypeUnknown, nil, err + } + + defer func() { _ = file.Close() }() + + raw, err := decodeAll(file) + if err != nil { + return nil, typ.TypeUnknown, nil, err + } + + ty, content, err := parseRaw(raw) + if err != nil { + return nil, typ.TypeUnknown, nil, err + } + + return raw, ty, content, nil +} + +// openInflated opens and zlib-decodes a loose object file. +// The caller owns both returned closers and must close them. +func (loose *Loose) openInflated(objectID id.ObjectID) (*os.File, io.ReadCloser, error) { + file, err := loose.openObject(objectID) + if err != nil { + return nil, nil, err + } + + zr, err := zlib.NewReader(file) + if err != nil { + _ = file.Close() + + return nil, nil, fmt.Errorf("object/store/loose: %w", err) + } + + return file, zr, nil +} + +// objectReader streams one inflated loose object +// and owns the underlying file and zlib decoder. +type objectReader struct { + // reader is the stream exposed by Read. + reader io.Reader + // file is the underlying loose object file and is closed by Close. + file *os.File + // zr is the zlib decoder and is closed by Close. + zr io.ReadCloser +} + +func (reader *objectReader) Read(dst []byte) (int, error) { + return reader.reader.Read(dst) //nolint:wrapcheck +} + +func (reader *objectReader) Close() error { + errZlib := reader.zr.Close() + errFile := reader.file.Close() + + return errors.Join(errZlib, errFile) +} diff --git a/object/store/loose/streamwriter.go b/object/store/loose/streamwriter.go new file mode 100644 index 00000000..dcd581a6 --- /dev/null +++ b/object/store/loose/streamwriter.go @@ -0,0 +1,277 @@ +package loose + +import ( + "bytes" + "crypto/rand" + "errors" + "fmt" + "hash" + "io/fs" + "os" + "path/filepath" + + "lindenii.org/go/furgit/internal/compress/zlib" + "lindenii.org/go/furgit/object/header" + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/store" + "lindenii.org/go/lgo/intconv" +) + +const tempObjectFilePrefix = "tmp_obj_" + +// streamWriter incrementally hashes and deflates an object into a temp file. +// finalize validates size accounting and atomically renames the temp file. +type streamWriter struct { + // loose owns path and root operations used by this write session. + loose *Loose + // file is the temporary destination file under objects/. + file *os.File + // zw compresses raw object bytes into file. + zw *zlib.Writer + // hash receives the same raw bytes used to compute the resulting object ID. + hash hash.Hash + + // tmpRelPath is the relative path of file under the objects root. + tmpRelPath string + + // fullMode selects full-object input ("type size\0content") + // as opposed to content-only input. + fullMode bool + + // headerBuf accumulates header bytes while fullMode parses up to the first NUL. + headerBuf []byte + // headerDone reports whether the full-object header has been parsed. + headerDone bool + // expectedContentLeft tracks remaining declared content bytes. + expectedContentLeft uint64 + + closed bool + finalized bool +} + +// Write validates and writes raw bytes into the stream. +// In full mode, it parses and enforces the streamed header-declared content size. +func (writer *streamWriter) Write(src []byte) (int, error) { + if writer.finalized { + return 0, fmt.Errorf("%w: write after finalize", store.ErrInvalidObject) + } + + if writer.closed { + return 0, fmt.Errorf("%w: write after close", store.ErrInvalidObject) + } + + if writer.fullMode { + err := writer.acceptFull(src) + if err != nil { + return 0, err + } + } else { + n, err := intconv.IntToUint64(len(src)) + if err != nil { + return 0, fmt.Errorf("object/store/loose: %w", err) + } + + err = writer.acceptContent(n) + if err != nil { + return 0, err + } + } + + err := writer.writeRawChunk(src) + if err != nil { + return 0, err + } + + return len(src), nil +} + +// Close flushes and closes the underlying zlib stream and temp file. +func (writer *streamWriter) Close() error { + errZlib := writer.zw.Close() + errSync := writer.file.Sync() + errFile := writer.file.Close() + + writer.closed = true + writer.file = nil + + return errors.Join(errZlib, errSync, errFile) +} + +// acceptFull validates and accounts raw full-object input. +func (writer *streamWriter) acceptFull(src []byte) error { + if writer.headerDone { + n, err := intconv.IntToUint64(len(src)) + if err != nil { + return fmt.Errorf("object/store/loose: %w", err) + } + + return writer.acceptContent(n) + } + + nul := bytes.IndexByte(src, 0) + if nul < 0 { + writer.headerBuf = append(writer.headerBuf, src...) + + return nil + } + + headerChunkLen := nul + 1 + writer.headerBuf = append(writer.headerBuf, src[:headerChunkLen]...) + + _, size, _, err := header.Parse(writer.headerBuf) + if err != nil { + return fmt.Errorf("%w: %w", store.ErrInvalidObject, err) + } + + writer.headerDone = true + writer.expectedContentLeft = size + + rest, err := intconv.IntToUint64(len(src) - headerChunkLen) + if err != nil { + return fmt.Errorf("object/store/loose: %w", err) + } + + return writer.acceptContent(rest) +} + +// acceptContent validates and accounts content byte counts. +func (writer *streamWriter) acceptContent(n uint64) error { + if n > writer.expectedContentLeft { + return fmt.Errorf("%w: object content exceeds declared size", store.ErrInvalidObject) + } + + writer.expectedContentLeft -= n + + return nil +} + +// writeRawChunk forwards raw bytes to the hash and deflate pipeline. +func (writer *streamWriter) writeRawChunk(src []byte) error { + _, err := writer.hash.Write(src) + if err != nil { + return fmt.Errorf("object/store/loose: %w", err) + } + + _, err = writer.zw.Write(src) + if err != nil { + return fmt.Errorf("object/store/loose: %w", err) + } + + return nil +} + +// finalize validates write completeness and atomically publishes the object. +// Publication is no-clobber: it links tmpRelPath to the object path and treats +// existing destination objects as success. +func (writer *streamWriter) finalize() (id.ObjectID, error) { + writer.finalized = true + + var zero id.ObjectID + + if !writer.closed { + err := writer.Close() + if err != nil { + return zero, err + } + } + + if writer.fullMode && !writer.headerDone { + return zero, fmt.Errorf("%w: missing full object header", store.ErrInvalidObject) + } + + if writer.expectedContentLeft != 0 { + return zero, fmt.Errorf("%w: object content shorter than declared size", store.ErrInvalidObject) + } + + idBytes := writer.hash.Sum(nil) + + objectID, err := writer.loose.objectFormat.FromBytes(idBytes) + if err != nil { + return zero, fmt.Errorf("object/store/loose: %w", err) + } + + relPath, err := writer.loose.objectPath(objectID) + if err != nil { + return zero, err + } + + dir := filepath.Dir(relPath) + + err = writer.loose.root.MkdirAll(dir, 0o755) + if err != nil { + return zero, fmt.Errorf("object/store/loose: %w", err) + } + + cleanup := true + + defer func() { + if cleanup { + _ = writer.loose.root.Remove(writer.tmpRelPath) + } + }() + + err = writer.loose.root.Link(writer.tmpRelPath, relPath) + if err != nil { + if errors.Is(err, fs.ErrExist) { + cleanup = false + _ = writer.loose.root.Remove(writer.tmpRelPath) + + return objectID, nil + } + + return zero, fmt.Errorf("object/store/loose: %w", err) + } + + cleanup = false + _ = writer.loose.root.Remove(writer.tmpRelPath) + + return objectID, nil +} + +// newStreamWriter creates a stream writer with a temp file rooted in objects/. +func (loose *Loose) newStreamWriter(fullMode bool) (*streamWriter, error) { + hashFn, err := loose.objectFormat.New() + if err != nil { + return nil, fmt.Errorf("object/store/loose: %w", err) + } + + tmpRelPath, file, err := loose.createTempObjectFile(".") + if err != nil { + return nil, err + } + + return &streamWriter{ + loose: loose, + file: file, + zw: zlib.NewWriter(file), + hash: hashFn, + tmpRelPath: tmpRelPath, + fullMode: fullMode, + headerBuf: make([]byte, 0, 64), + }, nil +} + +// createTempObjectFile creates a unique temporary object file within dir. +// The returned path is relative to the objects root. +func (loose *Loose) createTempObjectFile(dir string) (string, *os.File, error) { + var lastErr error + + for range 16 { + relPath := filepath.Join(dir, tempObjectFilePrefix+rand.Text()) + + file, err := loose.root.OpenFile(relPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err == nil { + return relPath, file, nil + } + + lastErr = err + + if errors.Is(err, fs.ErrExist) { + continue + } + + return "", nil, fmt.Errorf("object/store/loose: %w", err) + } + + return "", nil, fmt.Errorf("object/store/loose: failed to create temporary object file: %w", lastErr) +} diff --git a/object/store/loose/writer.go b/object/store/loose/writer.go new file mode 100644 index 00000000..0adfb34c --- /dev/null +++ b/object/store/loose/writer.go @@ -0,0 +1,84 @@ +package loose + +import ( + "bytes" + "fmt" + "io" + + "lindenii.org/go/furgit/object/header" + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/typ" +) + +// WriteBytesFull writes a full serialized object as "type size\0content". +func (loose *Loose) WriteBytesFull(raw []byte) (id.ObjectID, error) { + return loose.WriteReaderFull(bytes.NewReader(raw)) +} + +// WriteBytesContent writes typed content bytes as a loose object. +func (loose *Loose) WriteBytesContent(ty typ.Type, content []byte) (id.ObjectID, error) { + return loose.WriteReaderContent(ty, uint64(len(content)), bytes.NewReader(content)) +} + +// WriteReaderContent writes one loose object from typed content bytes read from src. +// src must provide exactly size bytes. +// size is required because loose object headers are "type size\0content", +// so the header must be emitted before streaming content without buffering. +func (loose *Loose) WriteReaderContent(ty typ.Type, size uint64, src io.Reader) (id.ObjectID, error) { + headerBytes := header.Append(nil, ty, size) + + writer, err := loose.newStreamWriter(false) + if err != nil { + return id.ObjectID{}, err + } + + writer.headerDone = true + writer.expectedContentLeft = size + + err = writer.writeRawChunk(headerBytes) + if err != nil { + _ = writer.Close() + _ = loose.root.Remove(writer.tmpRelPath) + + return id.ObjectID{}, err + } + + return writeReaderIntoStreamWriter(writer, src) +} + +// WriteReaderFull writes one loose object from raw bytes "type size\0content" read from src. +func (loose *Loose) WriteReaderFull(src io.Reader) (id.ObjectID, error) { + writer, err := loose.newStreamWriter(true) + if err != nil { + return id.ObjectID{}, err + } + + return writeReaderIntoStreamWriter(writer, src) +} + +// writeReaderIntoStreamWriter copies src into writer and publishes the object. +func writeReaderIntoStreamWriter(writer *streamWriter, src io.Reader) (id.ObjectID, error) { + _, err := io.Copy(writer, src) + if err != nil { + _ = writer.Close() + _ = writer.loose.root.Remove(writer.tmpRelPath) + + return id.ObjectID{}, fmt.Errorf("object/store/loose: %w", err) + } + + err = writer.Close() + if err != nil { + _ = writer.loose.root.Remove(writer.tmpRelPath) + + return id.ObjectID{}, err + } + + objectID, err := writer.finalize() + if err != nil { + _ = writer.loose.root.Remove(writer.tmpRelPath) + + return id.ObjectID{}, err + } + + return objectID, nil +} |
