aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Runxi Yu2026-06-08 12:47:13 +0000
committerGravatar Runxi Yu2026-06-08 12:47:13 +0000
commit6175c0f1445df26fbc5679f966546a449974584c (patch)
tree8f1bd59b85bd595a1b539be9cf2f3300610ebb2c
parentobject/store/loose: Add doc (diff)
signatureNo signature
object/store/loose: Add
-rw-r--r--object/store/loose/loose.go62
-rw-r--r--object/store/loose/parse.go61
-rw-r--r--object/store/loose/quarantine.go203
-rw-r--r--object/store/loose/reader.go239
-rw-r--r--object/store/loose/streamwriter.go277
-rw-r--r--object/store/loose/writer.go84
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
+}