detached 8fd2b19

feat(blobs): blob store port with disk and provider-agnostic S3 adapters

commit
8fd2b19ab02372a07e8e1bffb784717dde79a0e3
author
brice · · 2026-10-21 14:22 UTC
committer
brice · · 2026-10-21 17:53 UTC
parent
c57f9ec
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +2342 -0
M go.mod +17 -0
@@ -20,11 +20,15 @@ require (
2020 github.com/ProtonMail/go-crypto v1.4.1
2121 github.com/a-h/templ v0.3.1020
2222 github.com/alecthomas/chroma/v2 v2.27.0
23+ github.com/aws/aws-sdk-go-v2 v1.47.3
24+ github.com/aws/aws-sdk-go-v2/credentials v1.20.9
25+ github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3
2326 github.com/bnema/kvconf v0.1.2
2427 github.com/bnema/zerowrap v1.4.1
2528 github.com/descope/virtualwebauthn v1.0.5
2629 github.com/go-webauthn/webauthn v0.18.2
2730 github.com/golang-jwt/jwt/v5 v5.3.1
31+ github.com/johannesboyne/gofakes3 v1.2.0
2832 github.com/spf13/cobra v1.10.2
2933 github.com/stretchr/testify v1.12.1
3034 github.com/yuin/goldmark v1.8.6
@@ -61,6 +65,15 @@ require (
6165 github.com/air-verse/air v1.67.4 // indirect
6266 github.com/andybalholm/brotli v1.2.0 // indirect
6367 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
68+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect
69+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect
70+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect
71+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect
72+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect
73+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect
74+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect
75+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect
76+ github.com/aws/smithy-go v1.28.5 // indirect
6477 github.com/bep/godartsass/v2 v2.5.0 // indirect
6578 github.com/bep/golibsass v1.2.0 // indirect
6679 github.com/bits-and-blooms/bitset v1.24.5 // indirect
@@ -134,6 +147,7 @@ require (
134147 github.com/rs/cors v1.11.1 // indirect
135148 github.com/rs/zerolog v1.35.1 // indirect
136149 github.com/russross/blackfriday/v2 v2.1.0 // indirect
150+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
137151 github.com/segmentio/asm v1.2.1 // indirect
138152 github.com/segmentio/encoding v0.5.4 // indirect
139153 github.com/sirupsen/logrus v1.10.2 // indirect
@@ -159,6 +173,7 @@ require (
159173 go.opentelemetry.io/otel v1.46.0 // indirect
160174 go.opentelemetry.io/otel/metric v1.46.0 // indirect
161175 go.opentelemetry.io/otel/trace v1.46.0 // indirect
176+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
162177 go.uber.org/multierr v1.11.0 // indirect
163178 go.uber.org/zap v1.28.0 // indirect
164179 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
@@ -172,3 +187,5 @@ require (
172187 mvdan.cc/xurls/v2 v2.6.0 // indirect
173188 pluginrpc.com/pluginrpc v0.5.0 // indirect
174189 )
190+
191+replace bnema.dev/ermite-api => /home/brice/dev/projects/ermite-workspace/ermite-api/.worktrees/releases
@@ -20,11 +20,15 @@ require (
20 github.com/ProtonMail/go-crypto v1.4.120 github.com/ProtonMail/go-crypto v1.4.1
21 github.com/a-h/templ v0.3.102021 github.com/a-h/templ v0.3.1020
22 github.com/alecthomas/chroma/v2 v2.27.022 github.com/alecthomas/chroma/v2 v2.27.0
23+ github.com/aws/aws-sdk-go-v2 v1.47.3
24+ github.com/aws/aws-sdk-go-v2/credentials v1.20.9
25+ github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3
23 github.com/bnema/kvconf v0.1.226 github.com/bnema/kvconf v0.1.2
24 github.com/bnema/zerowrap v1.4.127 github.com/bnema/zerowrap v1.4.1
25 github.com/descope/virtualwebauthn v1.0.528 github.com/descope/virtualwebauthn v1.0.5
26 github.com/go-webauthn/webauthn v0.18.229 github.com/go-webauthn/webauthn v0.18.2
27 github.com/golang-jwt/jwt/v5 v5.3.130 github.com/golang-jwt/jwt/v5 v5.3.1
31+ github.com/johannesboyne/gofakes3 v1.2.0
28 github.com/spf13/cobra v1.10.232 github.com/spf13/cobra v1.10.2
29 github.com/stretchr/testify v1.12.133 github.com/stretchr/testify v1.12.1
30 github.com/yuin/goldmark v1.8.634 github.com/yuin/goldmark v1.8.6
@@ -61,6 +65,15 @@ require (
61 github.com/air-verse/air v1.67.4 // indirect65 github.com/air-verse/air v1.67.4 // indirect
62 github.com/andybalholm/brotli v1.2.0 // indirect66 github.com/andybalholm/brotli v1.2.0 // indirect
63 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect67 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
68+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect
69+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect
70+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect
71+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect
72+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect
73+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect
74+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect
75+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect
76+ github.com/aws/smithy-go v1.28.5 // indirect
64 github.com/bep/godartsass/v2 v2.5.0 // indirect77 github.com/bep/godartsass/v2 v2.5.0 // indirect
65 github.com/bep/golibsass v1.2.0 // indirect78 github.com/bep/golibsass v1.2.0 // indirect
66 github.com/bits-and-blooms/bitset v1.24.5 // indirect79 github.com/bits-and-blooms/bitset v1.24.5 // indirect
@@ -134,6 +147,7 @@ require (
134 github.com/rs/cors v1.11.1 // indirect147 github.com/rs/cors v1.11.1 // indirect
135 github.com/rs/zerolog v1.35.1 // indirect148 github.com/rs/zerolog v1.35.1 // indirect
136 github.com/russross/blackfriday/v2 v2.1.0 // indirect149 github.com/russross/blackfriday/v2 v2.1.0 // indirect
150+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
137 github.com/segmentio/asm v1.2.1 // indirect151 github.com/segmentio/asm v1.2.1 // indirect
138 github.com/segmentio/encoding v0.5.4 // indirect152 github.com/segmentio/encoding v0.5.4 // indirect
139 github.com/sirupsen/logrus v1.10.2 // indirect153 github.com/sirupsen/logrus v1.10.2 // indirect
@@ -159,6 +173,7 @@ require (
159 go.opentelemetry.io/otel v1.46.0 // indirect173 go.opentelemetry.io/otel v1.46.0 // indirect
160 go.opentelemetry.io/otel/metric v1.46.0 // indirect174 go.opentelemetry.io/otel/metric v1.46.0 // indirect
161 go.opentelemetry.io/otel/trace v1.46.0 // indirect175 go.opentelemetry.io/otel/trace v1.46.0 // indirect
176+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
162 go.uber.org/multierr v1.11.0 // indirect177 go.uber.org/multierr v1.11.0 // indirect
163 go.uber.org/zap v1.28.0 // indirect178 go.uber.org/zap v1.28.0 // indirect
164 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect179 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
@@ -172,3 +187,5 @@ require (
172 mvdan.cc/xurls/v2 v2.6.0 // indirect187 mvdan.cc/xurls/v2 v2.6.0 // indirect
173 pluginrpc.com/pluginrpc v0.5.0 // indirect188 pluginrpc.com/pluginrpc v0.5.0 // indirect
174 )189 )
190+
191+replace bnema.dev/ermite-api => /home/brice/dev/projects/ermite-workspace/ermite-api/.worktrees/releases
A internal/adapters/blobdisk/blobdisk.go +456 -0
@@ -0,0 +1,456 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobdisk stores objects under root/objects, temporary writes under
4+// root/tmp, and unfinished multipart uploads under root/multipart. Files are
5+// 0600 in 0700 directories; completed objects appear by synced atomic rename.
6+package blobdisk
7+
8+import (
9+ "context"
10+ "crypto/rand"
11+ "crypto/sha256"
12+ "encoding/hex"
13+ "encoding/json"
14+ "errors"
15+ "fmt"
16+ "io"
17+ "io/fs"
18+ "os"
19+ "path/filepath"
20+ "sort"
21+ "strconv"
22+ "strings"
23+ "time"
24+
25+ "bnema.dev/ermite/internal/domain"
26+ "bnema.dev/ermite/internal/ports"
27+ "github.com/bnema/zerowrap"
28+)
29+
30+// Store is the disk implementation of ports.BlobStore.
31+type Store struct{ root string }
32+
33+var _ ports.BlobStore = (*Store)(nil)
34+
35+// Dir returns the blob root under dataDir.
36+func Dir(dataDir string) string { return filepath.Join(dataDir, "blobs") }
37+
38+// New returns a disk store without performing I/O.
39+func New(root string) *Store { return &Store{root: root} }
40+func (s *Store) object(key string) string {
41+ return filepath.Join(s.root, "objects", filepath.FromSlash(key))
42+}
43+func valid(key string) error {
44+ if !ports.ValidBlobKey(key) {
45+ return domain.ErrInvalid
46+ }
47+ return nil
48+}
49+func translate(err error) error {
50+ if errors.Is(err, fs.ErrNotExist) {
51+ return domain.ErrNotFound
52+ }
53+ return err
54+}
55+func syncDir(path string) error {
56+ f, e := os.Open(path)
57+ if e != nil {
58+ return e
59+ }
60+ return errors.Join(f.Sync(), f.Close())
61+}
62+
63+type ctxReader struct {
64+ ctx context.Context
65+ r io.Reader
66+}
67+
68+func (r ctxReader) Read(p []byte) (int, error) {
69+ if e := r.ctx.Err(); e != nil {
70+ return 0, e
71+ }
72+ return r.r.Read(p)
73+}
74+func (s *Store) init(ctx context.Context) error {
75+ if e := ctx.Err(); e != nil {
76+ return e
77+ }
78+ for _, d := range []string{"objects", "tmp", "multipart"} {
79+ if e := os.MkdirAll(filepath.Join(s.root, d), 0700); e != nil {
80+ return e
81+ }
82+ }
83+ return nil
84+}
85+
86+// Check verifies that the root is writable.
87+func (s *Store) Check(ctx context.Context) error {
88+ logEntry := zerowrap.FromCtx(ctx)
89+ logEntry.Debug().Msg("blobdisk: Check")
90+ if e := s.init(ctx); e != nil {
91+ return e
92+ }
93+ f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".probe-*")
94+ if e != nil {
95+ return e
96+ }
97+ return errors.Join(f.Close(), os.Remove(f.Name()))
98+}
99+
100+// Put atomically replaces key with exactly size bytes.
101+func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
102+ logEntry := zerowrap.FromCtx(ctx)
103+ logEntry.Debug().Msg("blobdisk: Put")
104+ if e := valid(key); e != nil {
105+ return ports.BlobInfo{}, e
106+ }
107+ if size < 0 {
108+ return ports.BlobInfo{}, domain.ErrInvalid
109+ }
110+ if e := s.init(ctx); e != nil {
111+ return ports.BlobInfo{}, e
112+ }
113+ f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".tmp-*")
114+ if e != nil {
115+ return ports.BlobInfo{}, e
116+ }
117+ defer os.Remove(f.Name())
118+ h := sha256.New()
119+ n, e := io.Copy(io.MultiWriter(f, h), ctxReader{ctx, r})
120+ if e == nil && n != size {
121+ e = domain.ErrInvalid
122+ }
123+ if e == nil {
124+ e = ctx.Err()
125+ }
126+ if e == nil {
127+ e = f.Sync()
128+ }
129+ e = errors.Join(e, f.Close())
130+ if e != nil {
131+ return ports.BlobInfo{}, e
132+ }
133+ path := s.object(key)
134+ if e = os.MkdirAll(filepath.Dir(path), 0700); e == nil {
135+ e = os.Rename(f.Name(), path)
136+ }
137+ if e == nil {
138+ e = syncDir(filepath.Dir(path))
139+ }
140+ if e != nil {
141+ return ports.BlobInfo{}, e
142+ }
143+ info, e := s.Stat(ctx, key)
144+ info.Digest = "sha256:" + hex.EncodeToString(h.Sum(nil))
145+ return info, e
146+}
147+
148+// Stat returns object metadata.
149+func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
150+ if e := valid(key); e != nil {
151+ return ports.BlobInfo{}, e
152+ }
153+ if e := ctx.Err(); e != nil {
154+ return ports.BlobInfo{}, e
155+ }
156+ f, e := os.Stat(s.object(key))
157+ if e != nil {
158+ return ports.BlobInfo{}, translate(e)
159+ }
160+ if !f.Mode().IsRegular() {
161+ return ports.BlobInfo{}, domain.ErrNotFound
162+ }
163+ return ports.BlobInfo{Key: key, Size: f.Size(), ModTime: f.ModTime()}, nil
164+}
165+
166+type limitedCloser struct {
167+ io.Reader
168+ io.Closer
169+}
170+
171+// Open reads an object range.
172+//
173+//hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into a file seek; range rules belong to the port
174+func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) {
175+ info, e := s.Stat(ctx, key)
176+ if e != nil {
177+ return nil, info, e
178+ }
179+ if offset < 0 {
180+ return nil, info, domain.ErrInvalid
181+ }
182+ if offset >= info.Size {
183+ return nil, info, domain.ErrOutOfRange
184+ }
185+ f, e := os.Open(s.object(key))
186+ if e != nil {
187+ return nil, info, translate(e)
188+ }
189+ if _, e = f.Seek(offset, io.SeekStart); e != nil {
190+ f.Close()
191+ return nil, info, e
192+ }
193+ var r io.Reader = ctxReader{ctx, f}
194+ if length >= 0 {
195+ r = io.LimitReader(r, length)
196+ }
197+ return limitedCloser{r, f}, info, nil
198+}
199+
200+// Delete removes an object, treating absence as success.
201+func (s *Store) Delete(ctx context.Context, key string) error {
202+ logEntry := zerowrap.FromCtx(ctx)
203+ logEntry.Debug().Msg("blobdisk: Delete")
204+ if e := valid(key); e != nil {
205+ return e
206+ }
207+ if e := ctx.Err(); e != nil {
208+ return e
209+ }
210+ e := os.Remove(s.object(key))
211+ if errors.Is(e, fs.ErrNotExist) {
212+ return nil
213+ }
214+ return e
215+}
216+
217+// List visits matching objects in lexical order.
218+func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error {
219+ logEntry := zerowrap.FromCtx(ctx)
220+ logEntry.Debug().Msg("blobdisk: List")
221+ root := filepath.Join(s.root, "objects")
222+ var infos []ports.BlobInfo
223+ e := filepath.WalkDir(root, func(path string, d fs.DirEntry, e error) error {
224+ if e != nil {
225+ return e
226+ }
227+ if e = ctx.Err(); e != nil {
228+ return e
229+ }
230+ if !d.Type().IsRegular() {
231+ return nil
232+ }
233+ key, e := filepath.Rel(root, path)
234+ if e != nil {
235+ return e
236+ }
237+ key = filepath.ToSlash(key)
238+ if !strings.HasPrefix(key, prefix) {
239+ return nil
240+ }
241+ i, e := d.Info()
242+ if e != nil {
243+ return e
244+ }
245+ infos = append(infos, ports.BlobInfo{Key: key, Size: i.Size(), ModTime: i.ModTime()})
246+ return nil
247+ })
248+ if errors.Is(e, fs.ErrNotExist) {
249+ return nil
250+ }
251+ if e != nil {
252+ return e
253+ }
254+ sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key })
255+ for _, i := range infos {
256+ if e = ctx.Err(); e != nil {
257+ return e
258+ }
259+ if e = fn(i); e != nil {
260+ return e
261+ }
262+ }
263+ return nil
264+}
265+
266+type upload struct {
267+ s *Store
268+ key, id string
269+ done bool
270+}
271+type metadata struct {
272+ Key string
273+ Started time.Time
274+}
275+
276+func (u *upload) dir() string { return filepath.Join(u.s.root, "multipart", u.id) }
277+
278+// NewMultipart starts an isolated staging upload.
279+func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
280+ logEntry := zerowrap.FromCtx(ctx)
281+ logEntry.Debug().Msg("blobdisk: NewMultipart")
282+ if e := valid(key); e != nil {
283+ return nil, e
284+ }
285+ if e := s.init(ctx); e != nil {
286+ return nil, e
287+ }
288+ b := make([]byte, 16)
289+ if _, e := rand.Read(b); e != nil {
290+ return nil, e
291+ }
292+ u := &upload{s: s, key: key, id: hex.EncodeToString(b)}
293+ if e := os.Mkdir(u.dir(), 0700); e != nil {
294+ return nil, e
295+ }
296+ data, _ := json.Marshal(metadata{key, time.Now().UTC()})
297+ if e := os.WriteFile(filepath.Join(u.dir(), "metadata"), data, 0600); e != nil {
298+ os.RemoveAll(u.dir())
299+ return nil, e
300+ }
301+ return u, nil
302+}
303+func (u *upload) ID() string { return u.id }
304+func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error {
305+ logEntry := zerowrap.FromCtx(ctx)
306+ logEntry.Debug().Msg("blobdisk: UploadPart")
307+ if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 {
308+ return domain.ErrInvalid
309+ }
310+ f, e := os.CreateTemp(u.dir(), ".tmp-*")
311+ if e != nil {
312+ return e
313+ }
314+ defer os.Remove(f.Name())
315+ count, e := io.Copy(f, ctxReader{ctx, r})
316+ if e == nil && count != size {
317+ e = domain.ErrInvalid
318+ }
319+ if e == nil {
320+ e = f.Sync()
321+ }
322+ e = errors.Join(e, f.Close())
323+ if e != nil {
324+ return e
325+ }
326+ return os.Rename(f.Name(), filepath.Join(u.dir(), fmt.Sprintf("%05d", n)))
327+}
328+func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) {
329+ logEntry := zerowrap.FromCtx(ctx)
330+ logEntry.Debug().Msg("blobdisk: Complete")
331+ if u.done {
332+ return ports.BlobInfo{}, domain.ErrInvalid
333+ }
334+ u.done = true
335+ defer os.RemoveAll(u.dir())
336+ entries, e := os.ReadDir(u.dir())
337+ if e != nil {
338+ return ports.BlobInfo{}, e
339+ }
340+ var names []string
341+ for _, d := range entries {
342+ if n, e := strconv.Atoi(d.Name()); e == nil && n > 0 {
343+ names = append(names, d.Name())
344+ }
345+ }
346+ sort.Strings(names)
347+ if len(names) == 0 {
348+ return ports.BlobInfo{}, domain.ErrInvalid
349+ }
350+ var size int64
351+ var readers []io.Reader
352+ var files []*os.File
353+ defer func() {
354+ for _, f := range files {
355+ f.Close()
356+ }
357+ }()
358+ for i, n := range names {
359+ f, e := os.Open(filepath.Join(u.dir(), n))
360+ if e != nil {
361+ return ports.BlobInfo{}, e
362+ }
363+ files = append(files, f)
364+ info, e := f.Stat()
365+ if e != nil {
366+ return ports.BlobInfo{}, e
367+ }
368+ if i < len(names)-1 && info.Size() < ports.BlobMinPartSize {
369+ return ports.BlobInfo{}, domain.ErrInvalid
370+ }
371+ size += info.Size()
372+ readers = append(readers, f)
373+ }
374+ info, e := u.s.Put(ctx, u.key, io.MultiReader(readers...), size)
375+ info.Digest = ""
376+ return info, e
377+}
378+func (u *upload) Abort(ctx context.Context) error {
379+ logEntry := zerowrap.FromCtx(ctx)
380+ logEntry.Debug().Msg("blobdisk: Abort")
381+ if u.done {
382+ return domain.ErrInvalid
383+ }
384+ u.done = true
385+ return u.s.AbortMultipart(ctx, u.key, u.id)
386+}
387+func validID(id string) bool {
388+ b, e := hex.DecodeString(id)
389+ return e == nil && len(b) == 16 && id == strings.ToLower(id)
390+}
391+
392+// AbortMultipart removes a listed upload by identity.
393+func (s *Store) AbortMultipart(ctx context.Context, key, id string) error {
394+ logEntry := zerowrap.FromCtx(ctx)
395+ logEntry.Debug().Msg("blobdisk: AbortMultipart")
396+ if e := valid(key); e != nil {
397+ return e
398+ }
399+ if !validID(id) {
400+ return domain.ErrInvalid
401+ }
402+ if e := ctx.Err(); e != nil {
403+ return e
404+ }
405+ dir := filepath.Join(s.root, "multipart", id)
406+ b, e := os.ReadFile(filepath.Join(dir, "metadata"))
407+ if errors.Is(e, fs.ErrNotExist) {
408+ return nil
409+ }
410+ if e != nil {
411+ return e
412+ }
413+ var m metadata
414+ if e = json.Unmarshal(b, &m); e != nil {
415+ return e
416+ }
417+ if m.Key != key {
418+ return domain.ErrInvalid
419+ }
420+ return os.RemoveAll(dir)
421+}
422+
423+// ListMultipart visits unfinished uploads matching prefix.
424+func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
425+ logEntry := zerowrap.FromCtx(ctx)
426+ logEntry.Debug().Msg("blobdisk: ListMultipart")
427+ entries, e := os.ReadDir(filepath.Join(s.root, "multipart"))
428+ if errors.Is(e, fs.ErrNotExist) {
429+ return nil
430+ }
431+ if e != nil {
432+ return e
433+ }
434+ for _, d := range entries {
435+ if e = ctx.Err(); e != nil {
436+ return e
437+ }
438+ if !d.IsDir() || !validID(d.Name()) {
439+ continue
440+ }
441+ b, e := os.ReadFile(filepath.Join(s.root, "multipart", d.Name(), "metadata"))
442+ if e != nil {
443+ return e
444+ }
445+ var m metadata
446+ if e = json.Unmarshal(b, &m); e != nil {
447+ return e
448+ }
449+ if strings.HasPrefix(m.Key, prefix) {
450+ if e = fn(ports.MultipartInfo{Key: m.Key, ID: d.Name(), Started: m.Started}); e != nil {
451+ return e
452+ }
453+ }
454+ }
455+ return nil
456+}
@@ -0,0 +1,456 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobdisk stores objects under root/objects, temporary writes under
4+// root/tmp, and unfinished multipart uploads under root/multipart. Files are
5+// 0600 in 0700 directories; completed objects appear by synced atomic rename.
6+package blobdisk
7+
8+import (
9+ "context"
10+ "crypto/rand"
11+ "crypto/sha256"
12+ "encoding/hex"
13+ "encoding/json"
14+ "errors"
15+ "fmt"
16+ "io"
17+ "io/fs"
18+ "os"
19+ "path/filepath"
20+ "sort"
21+ "strconv"
22+ "strings"
23+ "time"
24+
25+ "bnema.dev/ermite/internal/domain"
26+ "bnema.dev/ermite/internal/ports"
27+ "github.com/bnema/zerowrap"
28+)
29+
30+// Store is the disk implementation of ports.BlobStore.
31+type Store struct{ root string }
32+
33+var _ ports.BlobStore = (*Store)(nil)
34+
35+// Dir returns the blob root under dataDir.
36+func Dir(dataDir string) string { return filepath.Join(dataDir, "blobs") }
37+
38+// New returns a disk store without performing I/O.
39+func New(root string) *Store { return &Store{root: root} }
40+func (s *Store) object(key string) string {
41+ return filepath.Join(s.root, "objects", filepath.FromSlash(key))
42+}
43+func valid(key string) error {
44+ if !ports.ValidBlobKey(key) {
45+ return domain.ErrInvalid
46+ }
47+ return nil
48+}
49+func translate(err error) error {
50+ if errors.Is(err, fs.ErrNotExist) {
51+ return domain.ErrNotFound
52+ }
53+ return err
54+}
55+func syncDir(path string) error {
56+ f, e := os.Open(path)
57+ if e != nil {
58+ return e
59+ }
60+ return errors.Join(f.Sync(), f.Close())
61+}
62+
63+type ctxReader struct {
64+ ctx context.Context
65+ r io.Reader
66+}
67+
68+func (r ctxReader) Read(p []byte) (int, error) {
69+ if e := r.ctx.Err(); e != nil {
70+ return 0, e
71+ }
72+ return r.r.Read(p)
73+}
74+func (s *Store) init(ctx context.Context) error {
75+ if e := ctx.Err(); e != nil {
76+ return e
77+ }
78+ for _, d := range []string{"objects", "tmp", "multipart"} {
79+ if e := os.MkdirAll(filepath.Join(s.root, d), 0700); e != nil {
80+ return e
81+ }
82+ }
83+ return nil
84+}
85+
86+// Check verifies that the root is writable.
87+func (s *Store) Check(ctx context.Context) error {
88+ logEntry := zerowrap.FromCtx(ctx)
89+ logEntry.Debug().Msg("blobdisk: Check")
90+ if e := s.init(ctx); e != nil {
91+ return e
92+ }
93+ f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".probe-*")
94+ if e != nil {
95+ return e
96+ }
97+ return errors.Join(f.Close(), os.Remove(f.Name()))
98+}
99+
100+// Put atomically replaces key with exactly size bytes.
101+func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
102+ logEntry := zerowrap.FromCtx(ctx)
103+ logEntry.Debug().Msg("blobdisk: Put")
104+ if e := valid(key); e != nil {
105+ return ports.BlobInfo{}, e
106+ }
107+ if size < 0 {
108+ return ports.BlobInfo{}, domain.ErrInvalid
109+ }
110+ if e := s.init(ctx); e != nil {
111+ return ports.BlobInfo{}, e
112+ }
113+ f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".tmp-*")
114+ if e != nil {
115+ return ports.BlobInfo{}, e
116+ }
117+ defer os.Remove(f.Name())
118+ h := sha256.New()
119+ n, e := io.Copy(io.MultiWriter(f, h), ctxReader{ctx, r})
120+ if e == nil && n != size {
121+ e = domain.ErrInvalid
122+ }
123+ if e == nil {
124+ e = ctx.Err()
125+ }
126+ if e == nil {
127+ e = f.Sync()
128+ }
129+ e = errors.Join(e, f.Close())
130+ if e != nil {
131+ return ports.BlobInfo{}, e
132+ }
133+ path := s.object(key)
134+ if e = os.MkdirAll(filepath.Dir(path), 0700); e == nil {
135+ e = os.Rename(f.Name(), path)
136+ }
137+ if e == nil {
138+ e = syncDir(filepath.Dir(path))
139+ }
140+ if e != nil {
141+ return ports.BlobInfo{}, e
142+ }
143+ info, e := s.Stat(ctx, key)
144+ info.Digest = "sha256:" + hex.EncodeToString(h.Sum(nil))
145+ return info, e
146+}
147+
148+// Stat returns object metadata.
149+func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
150+ if e := valid(key); e != nil {
151+ return ports.BlobInfo{}, e
152+ }
153+ if e := ctx.Err(); e != nil {
154+ return ports.BlobInfo{}, e
155+ }
156+ f, e := os.Stat(s.object(key))
157+ if e != nil {
158+ return ports.BlobInfo{}, translate(e)
159+ }
160+ if !f.Mode().IsRegular() {
161+ return ports.BlobInfo{}, domain.ErrNotFound
162+ }
163+ return ports.BlobInfo{Key: key, Size: f.Size(), ModTime: f.ModTime()}, nil
164+}
165+
166+type limitedCloser struct {
167+ io.Reader
168+ io.Closer
169+}
170+
171+// Open reads an object range.
172+//
173+//hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into a file seek; range rules belong to the port
174+func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) {
175+ info, e := s.Stat(ctx, key)
176+ if e != nil {
177+ return nil, info, e
178+ }
179+ if offset < 0 {
180+ return nil, info, domain.ErrInvalid
181+ }
182+ if offset >= info.Size {
183+ return nil, info, domain.ErrOutOfRange
184+ }
185+ f, e := os.Open(s.object(key))
186+ if e != nil {
187+ return nil, info, translate(e)
188+ }
189+ if _, e = f.Seek(offset, io.SeekStart); e != nil {
190+ f.Close()
191+ return nil, info, e
192+ }
193+ var r io.Reader = ctxReader{ctx, f}
194+ if length >= 0 {
195+ r = io.LimitReader(r, length)
196+ }
197+ return limitedCloser{r, f}, info, nil
198+}
199+
200+// Delete removes an object, treating absence as success.
201+func (s *Store) Delete(ctx context.Context, key string) error {
202+ logEntry := zerowrap.FromCtx(ctx)
203+ logEntry.Debug().Msg("blobdisk: Delete")
204+ if e := valid(key); e != nil {
205+ return e
206+ }
207+ if e := ctx.Err(); e != nil {
208+ return e
209+ }
210+ e := os.Remove(s.object(key))
211+ if errors.Is(e, fs.ErrNotExist) {
212+ return nil
213+ }
214+ return e
215+}
216+
217+// List visits matching objects in lexical order.
218+func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error {
219+ logEntry := zerowrap.FromCtx(ctx)
220+ logEntry.Debug().Msg("blobdisk: List")
221+ root := filepath.Join(s.root, "objects")
222+ var infos []ports.BlobInfo
223+ e := filepath.WalkDir(root, func(path string, d fs.DirEntry, e error) error {
224+ if e != nil {
225+ return e
226+ }
227+ if e = ctx.Err(); e != nil {
228+ return e
229+ }
230+ if !d.Type().IsRegular() {
231+ return nil
232+ }
233+ key, e := filepath.Rel(root, path)
234+ if e != nil {
235+ return e
236+ }
237+ key = filepath.ToSlash(key)
238+ if !strings.HasPrefix(key, prefix) {
239+ return nil
240+ }
241+ i, e := d.Info()
242+ if e != nil {
243+ return e
244+ }
245+ infos = append(infos, ports.BlobInfo{Key: key, Size: i.Size(), ModTime: i.ModTime()})
246+ return nil
247+ })
248+ if errors.Is(e, fs.ErrNotExist) {
249+ return nil
250+ }
251+ if e != nil {
252+ return e
253+ }
254+ sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key })
255+ for _, i := range infos {
256+ if e = ctx.Err(); e != nil {
257+ return e
258+ }
259+ if e = fn(i); e != nil {
260+ return e
261+ }
262+ }
263+ return nil
264+}
265+
266+type upload struct {
267+ s *Store
268+ key, id string
269+ done bool
270+}
271+type metadata struct {
272+ Key string
273+ Started time.Time
274+}
275+
276+func (u *upload) dir() string { return filepath.Join(u.s.root, "multipart", u.id) }
277+
278+// NewMultipart starts an isolated staging upload.
279+func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
280+ logEntry := zerowrap.FromCtx(ctx)
281+ logEntry.Debug().Msg("blobdisk: NewMultipart")
282+ if e := valid(key); e != nil {
283+ return nil, e
284+ }
285+ if e := s.init(ctx); e != nil {
286+ return nil, e
287+ }
288+ b := make([]byte, 16)
289+ if _, e := rand.Read(b); e != nil {
290+ return nil, e
291+ }
292+ u := &upload{s: s, key: key, id: hex.EncodeToString(b)}
293+ if e := os.Mkdir(u.dir(), 0700); e != nil {
294+ return nil, e
295+ }
296+ data, _ := json.Marshal(metadata{key, time.Now().UTC()})
297+ if e := os.WriteFile(filepath.Join(u.dir(), "metadata"), data, 0600); e != nil {
298+ os.RemoveAll(u.dir())
299+ return nil, e
300+ }
301+ return u, nil
302+}
303+func (u *upload) ID() string { return u.id }
304+func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error {
305+ logEntry := zerowrap.FromCtx(ctx)
306+ logEntry.Debug().Msg("blobdisk: UploadPart")
307+ if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 {
308+ return domain.ErrInvalid
309+ }
310+ f, e := os.CreateTemp(u.dir(), ".tmp-*")
311+ if e != nil {
312+ return e
313+ }
314+ defer os.Remove(f.Name())
315+ count, e := io.Copy(f, ctxReader{ctx, r})
316+ if e == nil && count != size {
317+ e = domain.ErrInvalid
318+ }
319+ if e == nil {
320+ e = f.Sync()
321+ }
322+ e = errors.Join(e, f.Close())
323+ if e != nil {
324+ return e
325+ }
326+ return os.Rename(f.Name(), filepath.Join(u.dir(), fmt.Sprintf("%05d", n)))
327+}
328+func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) {
329+ logEntry := zerowrap.FromCtx(ctx)
330+ logEntry.Debug().Msg("blobdisk: Complete")
331+ if u.done {
332+ return ports.BlobInfo{}, domain.ErrInvalid
333+ }
334+ u.done = true
335+ defer os.RemoveAll(u.dir())
336+ entries, e := os.ReadDir(u.dir())
337+ if e != nil {
338+ return ports.BlobInfo{}, e
339+ }
340+ var names []string
341+ for _, d := range entries {
342+ if n, e := strconv.Atoi(d.Name()); e == nil && n > 0 {
343+ names = append(names, d.Name())
344+ }
345+ }
346+ sort.Strings(names)
347+ if len(names) == 0 {
348+ return ports.BlobInfo{}, domain.ErrInvalid
349+ }
350+ var size int64
351+ var readers []io.Reader
352+ var files []*os.File
353+ defer func() {
354+ for _, f := range files {
355+ f.Close()
356+ }
357+ }()
358+ for i, n := range names {
359+ f, e := os.Open(filepath.Join(u.dir(), n))
360+ if e != nil {
361+ return ports.BlobInfo{}, e
362+ }
363+ files = append(files, f)
364+ info, e := f.Stat()
365+ if e != nil {
366+ return ports.BlobInfo{}, e
367+ }
368+ if i < len(names)-1 && info.Size() < ports.BlobMinPartSize {
369+ return ports.BlobInfo{}, domain.ErrInvalid
370+ }
371+ size += info.Size()
372+ readers = append(readers, f)
373+ }
374+ info, e := u.s.Put(ctx, u.key, io.MultiReader(readers...), size)
375+ info.Digest = ""
376+ return info, e
377+}
378+func (u *upload) Abort(ctx context.Context) error {
379+ logEntry := zerowrap.FromCtx(ctx)
380+ logEntry.Debug().Msg("blobdisk: Abort")
381+ if u.done {
382+ return domain.ErrInvalid
383+ }
384+ u.done = true
385+ return u.s.AbortMultipart(ctx, u.key, u.id)
386+}
387+func validID(id string) bool {
388+ b, e := hex.DecodeString(id)
389+ return e == nil && len(b) == 16 && id == strings.ToLower(id)
390+}
391+
392+// AbortMultipart removes a listed upload by identity.
393+func (s *Store) AbortMultipart(ctx context.Context, key, id string) error {
394+ logEntry := zerowrap.FromCtx(ctx)
395+ logEntry.Debug().Msg("blobdisk: AbortMultipart")
396+ if e := valid(key); e != nil {
397+ return e
398+ }
399+ if !validID(id) {
400+ return domain.ErrInvalid
401+ }
402+ if e := ctx.Err(); e != nil {
403+ return e
404+ }
405+ dir := filepath.Join(s.root, "multipart", id)
406+ b, e := os.ReadFile(filepath.Join(dir, "metadata"))
407+ if errors.Is(e, fs.ErrNotExist) {
408+ return nil
409+ }
410+ if e != nil {
411+ return e
412+ }
413+ var m metadata
414+ if e = json.Unmarshal(b, &m); e != nil {
415+ return e
416+ }
417+ if m.Key != key {
418+ return domain.ErrInvalid
419+ }
420+ return os.RemoveAll(dir)
421+}
422+
423+// ListMultipart visits unfinished uploads matching prefix.
424+func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
425+ logEntry := zerowrap.FromCtx(ctx)
426+ logEntry.Debug().Msg("blobdisk: ListMultipart")
427+ entries, e := os.ReadDir(filepath.Join(s.root, "multipart"))
428+ if errors.Is(e, fs.ErrNotExist) {
429+ return nil
430+ }
431+ if e != nil {
432+ return e
433+ }
434+ for _, d := range entries {
435+ if e = ctx.Err(); e != nil {
436+ return e
437+ }
438+ if !d.IsDir() || !validID(d.Name()) {
439+ continue
440+ }
441+ b, e := os.ReadFile(filepath.Join(s.root, "multipart", d.Name(), "metadata"))
442+ if e != nil {
443+ return e
444+ }
445+ var m metadata
446+ if e = json.Unmarshal(b, &m); e != nil {
447+ return e
448+ }
449+ if strings.HasPrefix(m.Key, prefix) {
450+ if e = fn(ports.MultipartInfo{Key: m.Key, ID: d.Name(), Started: m.Started}); e != nil {
451+ return e
452+ }
453+ }
454+ }
455+ return nil
456+}
A internal/adapters/blobdisk/blobdisk_test.go +29 -0
@@ -0,0 +1,29 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobdisk_test
3+
4+import (
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "bnema.dev/ermite/internal/adapters/blobdisk"
11+ "bnema.dev/ermite/internal/adapters/blobtest"
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestContract(t *testing.T) {
18+ blobtest.Run(t, func(t *testing.T) ports.BlobStore { return blobdisk.New(t.TempDir()) })
19+}
20+func TestCrashTempInvisible(t *testing.T) {
21+ root := t.TempDir()
22+ s := blobdisk.New(root)
23+ ctx := context.Background()
24+ require.NoError(t, s.Check(ctx))
25+ require.NoError(t, os.WriteFile(filepath.Join(root, "tmp", ".tmp-crashed"), []byte("partial"), 0600))
26+ require.NoError(t, s.List(ctx, "", func(ports.BlobInfo) error { t.Fatal("temporary object listed"); return nil }))
27+ _, e := s.Stat(ctx, ".tmp-crashed")
28+ require.ErrorIs(t, e, domain.ErrNotFound)
29+}
@@ -0,0 +1,29 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobdisk_test
3+
4+import (
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "bnema.dev/ermite/internal/adapters/blobdisk"
11+ "bnema.dev/ermite/internal/adapters/blobtest"
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestContract(t *testing.T) {
18+ blobtest.Run(t, func(t *testing.T) ports.BlobStore { return blobdisk.New(t.TempDir()) })
19+}
20+func TestCrashTempInvisible(t *testing.T) {
21+ root := t.TempDir()
22+ s := blobdisk.New(root)
23+ ctx := context.Background()
24+ require.NoError(t, s.Check(ctx))
25+ require.NoError(t, os.WriteFile(filepath.Join(root, "tmp", ".tmp-crashed"), []byte("partial"), 0600))
26+ require.NoError(t, s.List(ctx, "", func(ports.BlobInfo) error { t.Fatal("temporary object listed"); return nil }))
27+ _, e := s.Stat(ctx, ".tmp-crashed")
28+ require.ErrorIs(t, e, domain.ErrNotFound)
29+}
A internal/adapters/blobs3/blobs3.go +461 -0
@@ -0,0 +1,461 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobs3 implements the blob port with the object, multipart and
4+// listing S3 APIs. Checksums are WhenRequired, credentials are explicit, and
5+// no bucket creation, policy, ACL, tagging or lifecycle API is ever called.
6+package blobs3
7+
8+import (
9+ "bytes"
10+ "context"
11+ "crypto/sha256"
12+ "encoding/hex"
13+ "errors"
14+ "fmt"
15+ "io"
16+ "net/http"
17+ "sort"
18+ "strconv"
19+ "strings"
20+ "sync"
21+ "time"
22+
23+ "bnema.dev/ermite/internal/domain"
24+ "bnema.dev/ermite/internal/ports"
25+ "github.com/aws/aws-sdk-go-v2/aws"
26+ "github.com/aws/aws-sdk-go-v2/credentials"
27+ "github.com/aws/aws-sdk-go-v2/service/s3"
28+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
29+ "github.com/bnema/zerowrap"
30+)
31+
32+// Store is a provider-independent S3 blob store.
33+type Store struct {
34+ client *s3.Client
35+ cfg Config
36+}
37+
38+var _ ports.BlobStore = (*Store)(nil)
39+
40+// New builds a client without consulting ambient configuration or probing.
41+func New(ctx context.Context, cfg Config) (*Store, error) {
42+ if e := ctx.Err(); e != nil {
43+ return nil, e
44+ }
45+ if cfg.Bucket == "" || cfg.Region == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
46+ return nil, fmt.Errorf("bucket, region and explicit credentials are required: %w", domain.ErrInvalid)
47+ }
48+ cfg.Prefix = strings.Trim(cfg.Prefix, "/")
49+ if cfg.Prefix != "" {
50+ cfg.Prefix += "/"
51+ }
52+ client := cfg.HTTPClient
53+ if client == nil {
54+ transport := http.DefaultTransport.(*http.Transport).Clone()
55+ transport.IdleConnTimeout = 60 * time.Second
56+ transport.ResponseHeaderTimeout = 60 * time.Second
57+ client = &http.Client{Transport: transport}
58+ }
59+ ac := aws.Config{Region: cfg.Region, Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), HTTPClient: client, RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired, ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired, RetryMaxAttempts: 3}
60+ c := s3.NewFromConfig(ac, func(o *s3.Options) {
61+ if cfg.Endpoint != "" {
62+ o.BaseEndpoint = aws.String(cfg.Endpoint)
63+ }
64+ o.UsePathStyle = cfg.ForcePathStyle
65+ })
66+ return &Store{client: c, cfg: cfg}, nil
67+}
68+func valid(key string) error {
69+ if !ports.ValidBlobKey(key) {
70+ return domain.ErrInvalid
71+ }
72+ return nil
73+}
74+func (s *Store) key(key string) *string { return aws.String(s.cfg.Prefix + key) }
75+
76+type statusError interface{ HTTPStatusCode() int }
77+type codeError interface{ ErrorCode() string }
78+
79+func status(err error) int {
80+ var e statusError
81+ if errors.As(err, &e) {
82+ return e.HTTPStatusCode()
83+ }
84+ return 0
85+}
86+func translate(err error) error {
87+ if err == nil {
88+ return nil
89+ }
90+ var c codeError
91+ if errors.As(err, &c) {
92+ switch c.ErrorCode() {
93+ case "InvalidRange":
94+ return domain.ErrOutOfRange
95+ case "NoSuchKey", "NotFound":
96+ return domain.ErrNotFound
97+ case "EntityTooSmall":
98+ return domain.ErrInvalid
99+ }
100+ }
101+ switch status(err) {
102+ case 404:
103+ return domain.ErrNotFound
104+ case 416:
105+ return domain.ErrOutOfRange
106+ }
107+ return err
108+}
109+
110+type ctxReader struct {
111+ ctx context.Context
112+ r io.Reader
113+}
114+
115+func (r ctxReader) Read(p []byte) (int, error) {
116+ if e := r.ctx.Err(); e != nil {
117+ return 0, e
118+ }
119+ return r.r.Read(p)
120+}
121+
122+// One pooled buffer per in-flight upload bounds staging to BlobPartSize.
123+var buffers = sync.Pool{New: func() any { b := make([]byte, ports.BlobPartSize); return &b }}
124+
125+func exactEnd(ctx context.Context, r io.Reader) error {
126+ var b [1]byte
127+ n, e := io.ReadFull(ctxReader{ctx, r}, b[:])
128+ if n != 0 {
129+ return domain.ErrInvalid
130+ }
131+ if e == io.EOF {
132+ return nil
133+ }
134+ return e
135+}
136+func readPart(ctx context.Context, r io.Reader, b []byte) error {
137+ _, e := io.ReadFull(ctxReader{ctx, r}, b)
138+ if e == io.EOF || e == io.ErrUnexpectedEOF {
139+ return domain.ErrInvalid
140+ }
141+ return e
142+}
143+
144+// Put uses signed, seekable bodies, multiparting objects larger than one buffer.
145+func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
146+ logEntry := zerowrap.FromCtx(ctx)
147+ logEntry.Debug().Msg("blobs3: Put")
148+ if e := valid(key); e != nil {
149+ return ports.BlobInfo{}, e
150+ }
151+ if size < 0 || size > int64(ports.BlobPartSize)*ports.BlobMaxParts {
152+ return ports.BlobInfo{}, domain.ErrInvalid
153+ }
154+ bp := buffers.Get().(*[]byte)
155+ defer buffers.Put(bp)
156+ h := sha256.New()
157+ source := io.TeeReader(r, h)
158+ if size <= ports.BlobPartSize {
159+ b := (*bp)[:int(size)]
160+ if e := readPart(ctx, source, b); e != nil {
161+ return ports.BlobInfo{}, e
162+ }
163+ if e := exactEnd(ctx, r); e != nil {
164+ return ports.BlobInfo{}, e
165+ }
166+ _, e := s.client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Body: bytes.NewReader(b), ContentLength: aws.Int64(size)})
167+ if e != nil {
168+ return ports.BlobInfo{}, translate(e)
169+ }
170+ } else {
171+ u, e := s.NewMultipart(ctx, key)
172+ if e != nil {
173+ return ports.BlobInfo{}, e
174+ }
175+ completed := false
176+ defer func() {
177+ if !completed {
178+ cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
179+ defer cancel()
180+ _ = s.AbortMultipart(cleanup, key, u.ID())
181+ }
182+ }()
183+ remaining := size
184+ for n := 1; remaining > 0; n++ {
185+ count := min(remaining, int64(ports.BlobPartSize))
186+ b := (*bp)[:int(count)]
187+ if e = readPart(ctx, source, b); e != nil {
188+ return ports.BlobInfo{}, e
189+ }
190+ if e = u.UploadPart(ctx, n, bytes.NewReader(b), count); e != nil {
191+ return ports.BlobInfo{}, e
192+ }
193+ remaining -= count
194+ }
195+ if e = exactEnd(ctx, r); e != nil {
196+ return ports.BlobInfo{}, e
197+ }
198+ if _, e = u.Complete(ctx); e != nil {
199+ return ports.BlobInfo{}, e
200+ }
201+ completed = true
202+ }
203+ log := zerowrap.FromCtx(ctx)
204+ log.Debug().Str("key", key).Int64("size", size).Msg("blob stored")
205+ return ports.BlobInfo{Key: key, Size: size, Digest: "sha256:" + hex.EncodeToString(h.Sum(nil)), ModTime: time.Now().UTC()}, nil
206+}
207+
208+// Stat retrieves metadata without downloading content.
209+func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
210+ if e := valid(key); e != nil {
211+ return ports.BlobInfo{}, e
212+ }
213+ o, e := s.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
214+ if e != nil {
215+ return ports.BlobInfo{}, translate(e)
216+ }
217+ return ports.BlobInfo{Key: key, Size: aws.ToInt64(o.ContentLength), ModTime: aws.ToTime(o.LastModified)}, nil
218+}
219+
220+// Open streams a single range of an object.
221+//
222+//hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into an HTTP Range header; range rules belong to the port
223+func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) {
224+ if e := valid(key); e != nil {
225+ return nil, ports.BlobInfo{}, e
226+ }
227+ if offset < 0 {
228+ return nil, ports.BlobInfo{}, domain.ErrInvalid
229+ }
230+ if length == 0 {
231+ info, e := s.Stat(ctx, key)
232+ if e != nil {
233+ return nil, info, e
234+ }
235+ if offset >= info.Size {
236+ return nil, info, domain.ErrOutOfRange
237+ }
238+ return io.NopCloser(strings.NewReader("")), info, nil
239+ }
240+ rg := fmt.Sprintf("bytes=%d-", offset)
241+ if length > 0 && length-1 <= int64(^uint64(0)>>1)-offset {
242+ rg += strconv.FormatInt(offset+length-1, 10)
243+ }
244+ o, e := s.client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Range: aws.String(rg)})
245+ if e != nil {
246+ return nil, ports.BlobInfo{}, translate(e)
247+ }
248+ size := aws.ToInt64(o.ContentLength)
249+ if _, total, ok := strings.Cut(aws.ToString(o.ContentRange), "/"); ok {
250+ if n, e := strconv.ParseInt(total, 10, 64); e == nil {
251+ size = n
252+ }
253+ }
254+ return o.Body, ports.BlobInfo{Key: key, Size: size, ModTime: aws.ToTime(o.LastModified)}, nil
255+}
256+
257+// Delete removes a key; absence is success.
258+func (s *Store) Delete(ctx context.Context, key string) error {
259+ logEntry := zerowrap.FromCtx(ctx)
260+ logEntry.Debug().Msg("blobs3: Delete")
261+ if e := valid(key); e != nil {
262+ return e
263+ }
264+ _, e := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
265+ if status(e) == 404 {
266+ return nil
267+ }
268+ return translate(e)
269+}
270+
271+// List visits matching objects lexicographically.
272+func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error {
273+ logEntry := zerowrap.FromCtx(ctx)
274+ logEntry.Debug().Msg("blobs3: List")
275+ p := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)})
276+ for p.HasMorePages() {
277+ o, e := p.NextPage(ctx)
278+ if e != nil {
279+ return translate(e)
280+ }
281+ for _, v := range o.Contents {
282+ if e = fn(ports.BlobInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), Size: aws.ToInt64(v.Size), ModTime: aws.ToTime(v.LastModified)}); e != nil {
283+ return e
284+ }
285+ }
286+ }
287+ return nil
288+}
289+
290+// Check probes the existing bucket without modifying it.
291+func (s *Store) Check(ctx context.Context) error {
292+ logEntry := zerowrap.FromCtx(ctx)
293+ logEntry.Debug().Msg("blobs3: Check")
294+ _, e := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.cfg.Bucket)})
295+ if status(e) == 403 {
296+ _, e = s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: aws.String(s.cfg.Prefix), MaxKeys: aws.Int32(1)})
297+ }
298+ if e == nil {
299+ return nil
300+ }
301+ where := s.cfg.Endpoint
302+ if where == "" {
303+ where = s.cfg.Region
304+ }
305+ return fmt.Errorf("bucket %q is not reachable at %s: %w", s.cfg.Bucket, where, e)
306+}
307+
308+type part struct {
309+ etag string
310+ size int64
311+}
312+type upload struct {
313+ s *Store
314+ key, id string
315+ parts map[int]part
316+ done bool
317+}
318+
319+// NewMultipart initiates an object upload.
320+func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
321+ logEntry := zerowrap.FromCtx(ctx)
322+ logEntry.Debug().Msg("blobs3: NewMultipart")
323+ if e := valid(key); e != nil {
324+ return nil, e
325+ }
326+ o, e := s.client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
327+ if e != nil {
328+ return nil, translate(e)
329+ }
330+ return &upload{s: s, key: key, id: aws.ToString(o.UploadId), parts: map[int]part{}}, nil
331+}
332+func (u *upload) ID() string { return u.id }
333+func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error {
334+ logEntry := zerowrap.FromCtx(ctx)
335+ logEntry.Debug().Msg("blobs3: UploadPart")
336+ if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 || size > ports.BlobPartSize {
337+ return domain.ErrInvalid
338+ }
339+ if e := ctx.Err(); e != nil {
340+ return e
341+ }
342+ var body io.ReadSeeker
343+ if seeker, ok := r.(io.ReadSeeker); ok {
344+ start, e := seeker.Seek(0, io.SeekCurrent)
345+ if e != nil {
346+ return e
347+ }
348+ end, e := seeker.Seek(0, io.SeekEnd)
349+ if e != nil {
350+ return e
351+ }
352+ if _, e = seeker.Seek(start, io.SeekStart); e != nil {
353+ return e
354+ }
355+ if end-start != size {
356+ return domain.ErrInvalid
357+ }
358+ body = seeker
359+ } else {
360+ bp := buffers.Get().(*[]byte)
361+ defer buffers.Put(bp)
362+ b := (*bp)[:int(size)]
363+ if e := readPart(ctx, r, b); e != nil {
364+ return e
365+ }
366+ if e := exactEnd(ctx, r); e != nil {
367+ return e
368+ }
369+ body = bytes.NewReader(b)
370+ }
371+ o, e := u.s.client.UploadPart(ctx, &s3.UploadPartInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), PartNumber: aws.Int32(int32(n)), Body: body, ContentLength: aws.Int64(size)})
372+ if e != nil {
373+ return translate(e)
374+ }
375+ u.parts[n] = part{aws.ToString(o.ETag), size}
376+ log := zerowrap.FromCtx(ctx)
377+ log.Debug().Int("part", n).Msg("blob part uploaded")
378+ return nil
379+}
380+func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) {
381+ logEntry := zerowrap.FromCtx(ctx)
382+ logEntry.Debug().Msg("blobs3: Complete")
383+ if u.done {
384+ return ports.BlobInfo{}, domain.ErrInvalid
385+ }
386+ u.done = true
387+ var nums []int
388+ for n := range u.parts {
389+ nums = append(nums, n)
390+ }
391+ sort.Ints(nums)
392+ var parts []types.CompletedPart
393+ var size int64
394+ invalid := len(nums) == 0
395+ for i, n := range nums {
396+ p := u.parts[n]
397+ if i < len(nums)-1 && p.size < ports.BlobMinPartSize {
398+ invalid = true
399+ }
400+ size += p.size
401+ parts = append(parts, types.CompletedPart{PartNumber: aws.Int32(int32(n)), ETag: aws.String(p.etag)})
402+ }
403+ if invalid {
404+ _ = u.s.AbortMultipart(ctx, u.key, u.id)
405+ return ports.BlobInfo{}, domain.ErrInvalid
406+ }
407+ _, e := u.s.client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), MultipartUpload: &types.CompletedMultipartUpload{Parts: parts}})
408+ if e != nil {
409+ cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
410+ defer cancel()
411+ _ = u.s.AbortMultipart(cleanup, u.key, u.id)
412+ return ports.BlobInfo{}, translate(e)
413+ }
414+ return ports.BlobInfo{Key: u.key, Size: size, ModTime: time.Now().UTC()}, nil
415+}
416+func (u *upload) Abort(ctx context.Context) error {
417+ logEntry := zerowrap.FromCtx(ctx)
418+ logEntry.Debug().Msg("blobs3: Abort")
419+ if u.done {
420+ return domain.ErrInvalid
421+ }
422+ u.done = true
423+ return u.s.AbortMultipart(ctx, u.key, u.id)
424+}
425+
426+// AbortMultipart discards a listed upload.
427+func (s *Store) AbortMultipart(ctx context.Context, key, id string) error {
428+ logEntry := zerowrap.FromCtx(ctx)
429+ logEntry.Debug().Msg("blobs3: AbortMultipart")
430+ if e := valid(key); e != nil {
431+ return e
432+ }
433+ _, e := s.client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), UploadId: aws.String(id)})
434+ if status(e) == 404 {
435+ return nil
436+ }
437+ return translate(e)
438+}
439+
440+// ListMultipart visits unfinished uploads matching prefix.
441+func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
442+ logEntry := zerowrap.FromCtx(ctx)
443+ logEntry.Debug().Msg("blobs3: ListMultipart")
444+ input := &s3.ListMultipartUploadsInput{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)}
445+ for {
446+ o, e := s.client.ListMultipartUploads(ctx, input)
447+ if e != nil {
448+ return translate(e)
449+ }
450+ for _, v := range o.Uploads {
451+ if e = fn(ports.MultipartInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), ID: aws.ToString(v.UploadId), Started: aws.ToTime(v.Initiated)}); e != nil {
452+ return e
453+ }
454+ }
455+ if !aws.ToBool(o.IsTruncated) {
456+ return nil
457+ }
458+ input.KeyMarker = o.NextKeyMarker
459+ input.UploadIdMarker = o.NextUploadIdMarker
460+ }
461+}
@@ -0,0 +1,461 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobs3 implements the blob port with the object, multipart and
4+// listing S3 APIs. Checksums are WhenRequired, credentials are explicit, and
5+// no bucket creation, policy, ACL, tagging or lifecycle API is ever called.
6+package blobs3
7+
8+import (
9+ "bytes"
10+ "context"
11+ "crypto/sha256"
12+ "encoding/hex"
13+ "errors"
14+ "fmt"
15+ "io"
16+ "net/http"
17+ "sort"
18+ "strconv"
19+ "strings"
20+ "sync"
21+ "time"
22+
23+ "bnema.dev/ermite/internal/domain"
24+ "bnema.dev/ermite/internal/ports"
25+ "github.com/aws/aws-sdk-go-v2/aws"
26+ "github.com/aws/aws-sdk-go-v2/credentials"
27+ "github.com/aws/aws-sdk-go-v2/service/s3"
28+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
29+ "github.com/bnema/zerowrap"
30+)
31+
32+// Store is a provider-independent S3 blob store.
33+type Store struct {
34+ client *s3.Client
35+ cfg Config
36+}
37+
38+var _ ports.BlobStore = (*Store)(nil)
39+
40+// New builds a client without consulting ambient configuration or probing.
41+func New(ctx context.Context, cfg Config) (*Store, error) {
42+ if e := ctx.Err(); e != nil {
43+ return nil, e
44+ }
45+ if cfg.Bucket == "" || cfg.Region == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
46+ return nil, fmt.Errorf("bucket, region and explicit credentials are required: %w", domain.ErrInvalid)
47+ }
48+ cfg.Prefix = strings.Trim(cfg.Prefix, "/")
49+ if cfg.Prefix != "" {
50+ cfg.Prefix += "/"
51+ }
52+ client := cfg.HTTPClient
53+ if client == nil {
54+ transport := http.DefaultTransport.(*http.Transport).Clone()
55+ transport.IdleConnTimeout = 60 * time.Second
56+ transport.ResponseHeaderTimeout = 60 * time.Second
57+ client = &http.Client{Transport: transport}
58+ }
59+ ac := aws.Config{Region: cfg.Region, Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), HTTPClient: client, RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired, ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired, RetryMaxAttempts: 3}
60+ c := s3.NewFromConfig(ac, func(o *s3.Options) {
61+ if cfg.Endpoint != "" {
62+ o.BaseEndpoint = aws.String(cfg.Endpoint)
63+ }
64+ o.UsePathStyle = cfg.ForcePathStyle
65+ })
66+ return &Store{client: c, cfg: cfg}, nil
67+}
68+func valid(key string) error {
69+ if !ports.ValidBlobKey(key) {
70+ return domain.ErrInvalid
71+ }
72+ return nil
73+}
74+func (s *Store) key(key string) *string { return aws.String(s.cfg.Prefix + key) }
75+
76+type statusError interface{ HTTPStatusCode() int }
77+type codeError interface{ ErrorCode() string }
78+
79+func status(err error) int {
80+ var e statusError
81+ if errors.As(err, &e) {
82+ return e.HTTPStatusCode()
83+ }
84+ return 0
85+}
86+func translate(err error) error {
87+ if err == nil {
88+ return nil
89+ }
90+ var c codeError
91+ if errors.As(err, &c) {
92+ switch c.ErrorCode() {
93+ case "InvalidRange":
94+ return domain.ErrOutOfRange
95+ case "NoSuchKey", "NotFound":
96+ return domain.ErrNotFound
97+ case "EntityTooSmall":
98+ return domain.ErrInvalid
99+ }
100+ }
101+ switch status(err) {
102+ case 404:
103+ return domain.ErrNotFound
104+ case 416:
105+ return domain.ErrOutOfRange
106+ }
107+ return err
108+}
109+
110+type ctxReader struct {
111+ ctx context.Context
112+ r io.Reader
113+}
114+
115+func (r ctxReader) Read(p []byte) (int, error) {
116+ if e := r.ctx.Err(); e != nil {
117+ return 0, e
118+ }
119+ return r.r.Read(p)
120+}
121+
122+// One pooled buffer per in-flight upload bounds staging to BlobPartSize.
123+var buffers = sync.Pool{New: func() any { b := make([]byte, ports.BlobPartSize); return &b }}
124+
125+func exactEnd(ctx context.Context, r io.Reader) error {
126+ var b [1]byte
127+ n, e := io.ReadFull(ctxReader{ctx, r}, b[:])
128+ if n != 0 {
129+ return domain.ErrInvalid
130+ }
131+ if e == io.EOF {
132+ return nil
133+ }
134+ return e
135+}
136+func readPart(ctx context.Context, r io.Reader, b []byte) error {
137+ _, e := io.ReadFull(ctxReader{ctx, r}, b)
138+ if e == io.EOF || e == io.ErrUnexpectedEOF {
139+ return domain.ErrInvalid
140+ }
141+ return e
142+}
143+
144+// Put uses signed, seekable bodies, multiparting objects larger than one buffer.
145+func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
146+ logEntry := zerowrap.FromCtx(ctx)
147+ logEntry.Debug().Msg("blobs3: Put")
148+ if e := valid(key); e != nil {
149+ return ports.BlobInfo{}, e
150+ }
151+ if size < 0 || size > int64(ports.BlobPartSize)*ports.BlobMaxParts {
152+ return ports.BlobInfo{}, domain.ErrInvalid
153+ }
154+ bp := buffers.Get().(*[]byte)
155+ defer buffers.Put(bp)
156+ h := sha256.New()
157+ source := io.TeeReader(r, h)
158+ if size <= ports.BlobPartSize {
159+ b := (*bp)[:int(size)]
160+ if e := readPart(ctx, source, b); e != nil {
161+ return ports.BlobInfo{}, e
162+ }
163+ if e := exactEnd(ctx, r); e != nil {
164+ return ports.BlobInfo{}, e
165+ }
166+ _, e := s.client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Body: bytes.NewReader(b), ContentLength: aws.Int64(size)})
167+ if e != nil {
168+ return ports.BlobInfo{}, translate(e)
169+ }
170+ } else {
171+ u, e := s.NewMultipart(ctx, key)
172+ if e != nil {
173+ return ports.BlobInfo{}, e
174+ }
175+ completed := false
176+ defer func() {
177+ if !completed {
178+ cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
179+ defer cancel()
180+ _ = s.AbortMultipart(cleanup, key, u.ID())
181+ }
182+ }()
183+ remaining := size
184+ for n := 1; remaining > 0; n++ {
185+ count := min(remaining, int64(ports.BlobPartSize))
186+ b := (*bp)[:int(count)]
187+ if e = readPart(ctx, source, b); e != nil {
188+ return ports.BlobInfo{}, e
189+ }
190+ if e = u.UploadPart(ctx, n, bytes.NewReader(b), count); e != nil {
191+ return ports.BlobInfo{}, e
192+ }
193+ remaining -= count
194+ }
195+ if e = exactEnd(ctx, r); e != nil {
196+ return ports.BlobInfo{}, e
197+ }
198+ if _, e = u.Complete(ctx); e != nil {
199+ return ports.BlobInfo{}, e
200+ }
201+ completed = true
202+ }
203+ log := zerowrap.FromCtx(ctx)
204+ log.Debug().Str("key", key).Int64("size", size).Msg("blob stored")
205+ return ports.BlobInfo{Key: key, Size: size, Digest: "sha256:" + hex.EncodeToString(h.Sum(nil)), ModTime: time.Now().UTC()}, nil
206+}
207+
208+// Stat retrieves metadata without downloading content.
209+func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
210+ if e := valid(key); e != nil {
211+ return ports.BlobInfo{}, e
212+ }
213+ o, e := s.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
214+ if e != nil {
215+ return ports.BlobInfo{}, translate(e)
216+ }
217+ return ports.BlobInfo{Key: key, Size: aws.ToInt64(o.ContentLength), ModTime: aws.ToTime(o.LastModified)}, nil
218+}
219+
220+// Open streams a single range of an object.
221+//
222+//hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into an HTTP Range header; range rules belong to the port
223+func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) {
224+ if e := valid(key); e != nil {
225+ return nil, ports.BlobInfo{}, e
226+ }
227+ if offset < 0 {
228+ return nil, ports.BlobInfo{}, domain.ErrInvalid
229+ }
230+ if length == 0 {
231+ info, e := s.Stat(ctx, key)
232+ if e != nil {
233+ return nil, info, e
234+ }
235+ if offset >= info.Size {
236+ return nil, info, domain.ErrOutOfRange
237+ }
238+ return io.NopCloser(strings.NewReader("")), info, nil
239+ }
240+ rg := fmt.Sprintf("bytes=%d-", offset)
241+ if length > 0 && length-1 <= int64(^uint64(0)>>1)-offset {
242+ rg += strconv.FormatInt(offset+length-1, 10)
243+ }
244+ o, e := s.client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Range: aws.String(rg)})
245+ if e != nil {
246+ return nil, ports.BlobInfo{}, translate(e)
247+ }
248+ size := aws.ToInt64(o.ContentLength)
249+ if _, total, ok := strings.Cut(aws.ToString(o.ContentRange), "/"); ok {
250+ if n, e := strconv.ParseInt(total, 10, 64); e == nil {
251+ size = n
252+ }
253+ }
254+ return o.Body, ports.BlobInfo{Key: key, Size: size, ModTime: aws.ToTime(o.LastModified)}, nil
255+}
256+
257+// Delete removes a key; absence is success.
258+func (s *Store) Delete(ctx context.Context, key string) error {
259+ logEntry := zerowrap.FromCtx(ctx)
260+ logEntry.Debug().Msg("blobs3: Delete")
261+ if e := valid(key); e != nil {
262+ return e
263+ }
264+ _, e := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
265+ if status(e) == 404 {
266+ return nil
267+ }
268+ return translate(e)
269+}
270+
271+// List visits matching objects lexicographically.
272+func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error {
273+ logEntry := zerowrap.FromCtx(ctx)
274+ logEntry.Debug().Msg("blobs3: List")
275+ p := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)})
276+ for p.HasMorePages() {
277+ o, e := p.NextPage(ctx)
278+ if e != nil {
279+ return translate(e)
280+ }
281+ for _, v := range o.Contents {
282+ if e = fn(ports.BlobInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), Size: aws.ToInt64(v.Size), ModTime: aws.ToTime(v.LastModified)}); e != nil {
283+ return e
284+ }
285+ }
286+ }
287+ return nil
288+}
289+
290+// Check probes the existing bucket without modifying it.
291+func (s *Store) Check(ctx context.Context) error {
292+ logEntry := zerowrap.FromCtx(ctx)
293+ logEntry.Debug().Msg("blobs3: Check")
294+ _, e := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.cfg.Bucket)})
295+ if status(e) == 403 {
296+ _, e = s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: aws.String(s.cfg.Prefix), MaxKeys: aws.Int32(1)})
297+ }
298+ if e == nil {
299+ return nil
300+ }
301+ where := s.cfg.Endpoint
302+ if where == "" {
303+ where = s.cfg.Region
304+ }
305+ return fmt.Errorf("bucket %q is not reachable at %s: %w", s.cfg.Bucket, where, e)
306+}
307+
308+type part struct {
309+ etag string
310+ size int64
311+}
312+type upload struct {
313+ s *Store
314+ key, id string
315+ parts map[int]part
316+ done bool
317+}
318+
319+// NewMultipart initiates an object upload.
320+func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
321+ logEntry := zerowrap.FromCtx(ctx)
322+ logEntry.Debug().Msg("blobs3: NewMultipart")
323+ if e := valid(key); e != nil {
324+ return nil, e
325+ }
326+ o, e := s.client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
327+ if e != nil {
328+ return nil, translate(e)
329+ }
330+ return &upload{s: s, key: key, id: aws.ToString(o.UploadId), parts: map[int]part{}}, nil
331+}
332+func (u *upload) ID() string { return u.id }
333+func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error {
334+ logEntry := zerowrap.FromCtx(ctx)
335+ logEntry.Debug().Msg("blobs3: UploadPart")
336+ if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 || size > ports.BlobPartSize {
337+ return domain.ErrInvalid
338+ }
339+ if e := ctx.Err(); e != nil {
340+ return e
341+ }
342+ var body io.ReadSeeker
343+ if seeker, ok := r.(io.ReadSeeker); ok {
344+ start, e := seeker.Seek(0, io.SeekCurrent)
345+ if e != nil {
346+ return e
347+ }
348+ end, e := seeker.Seek(0, io.SeekEnd)
349+ if e != nil {
350+ return e
351+ }
352+ if _, e = seeker.Seek(start, io.SeekStart); e != nil {
353+ return e
354+ }
355+ if end-start != size {
356+ return domain.ErrInvalid
357+ }
358+ body = seeker
359+ } else {
360+ bp := buffers.Get().(*[]byte)
361+ defer buffers.Put(bp)
362+ b := (*bp)[:int(size)]
363+ if e := readPart(ctx, r, b); e != nil {
364+ return e
365+ }
366+ if e := exactEnd(ctx, r); e != nil {
367+ return e
368+ }
369+ body = bytes.NewReader(b)
370+ }
371+ o, e := u.s.client.UploadPart(ctx, &s3.UploadPartInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), PartNumber: aws.Int32(int32(n)), Body: body, ContentLength: aws.Int64(size)})
372+ if e != nil {
373+ return translate(e)
374+ }
375+ u.parts[n] = part{aws.ToString(o.ETag), size}
376+ log := zerowrap.FromCtx(ctx)
377+ log.Debug().Int("part", n).Msg("blob part uploaded")
378+ return nil
379+}
380+func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) {
381+ logEntry := zerowrap.FromCtx(ctx)
382+ logEntry.Debug().Msg("blobs3: Complete")
383+ if u.done {
384+ return ports.BlobInfo{}, domain.ErrInvalid
385+ }
386+ u.done = true
387+ var nums []int
388+ for n := range u.parts {
389+ nums = append(nums, n)
390+ }
391+ sort.Ints(nums)
392+ var parts []types.CompletedPart
393+ var size int64
394+ invalid := len(nums) == 0
395+ for i, n := range nums {
396+ p := u.parts[n]
397+ if i < len(nums)-1 && p.size < ports.BlobMinPartSize {
398+ invalid = true
399+ }
400+ size += p.size
401+ parts = append(parts, types.CompletedPart{PartNumber: aws.Int32(int32(n)), ETag: aws.String(p.etag)})
402+ }
403+ if invalid {
404+ _ = u.s.AbortMultipart(ctx, u.key, u.id)
405+ return ports.BlobInfo{}, domain.ErrInvalid
406+ }
407+ _, e := u.s.client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), MultipartUpload: &types.CompletedMultipartUpload{Parts: parts}})
408+ if e != nil {
409+ cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
410+ defer cancel()
411+ _ = u.s.AbortMultipart(cleanup, u.key, u.id)
412+ return ports.BlobInfo{}, translate(e)
413+ }
414+ return ports.BlobInfo{Key: u.key, Size: size, ModTime: time.Now().UTC()}, nil
415+}
416+func (u *upload) Abort(ctx context.Context) error {
417+ logEntry := zerowrap.FromCtx(ctx)
418+ logEntry.Debug().Msg("blobs3: Abort")
419+ if u.done {
420+ return domain.ErrInvalid
421+ }
422+ u.done = true
423+ return u.s.AbortMultipart(ctx, u.key, u.id)
424+}
425+
426+// AbortMultipart discards a listed upload.
427+func (s *Store) AbortMultipart(ctx context.Context, key, id string) error {
428+ logEntry := zerowrap.FromCtx(ctx)
429+ logEntry.Debug().Msg("blobs3: AbortMultipart")
430+ if e := valid(key); e != nil {
431+ return e
432+ }
433+ _, e := s.client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), UploadId: aws.String(id)})
434+ if status(e) == 404 {
435+ return nil
436+ }
437+ return translate(e)
438+}
439+
440+// ListMultipart visits unfinished uploads matching prefix.
441+func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
442+ logEntry := zerowrap.FromCtx(ctx)
443+ logEntry.Debug().Msg("blobs3: ListMultipart")
444+ input := &s3.ListMultipartUploadsInput{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)}
445+ for {
446+ o, e := s.client.ListMultipartUploads(ctx, input)
447+ if e != nil {
448+ return translate(e)
449+ }
450+ for _, v := range o.Uploads {
451+ if e = fn(ports.MultipartInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), ID: aws.ToString(v.UploadId), Started: aws.ToTime(v.Initiated)}); e != nil {
452+ return e
453+ }
454+ }
455+ if !aws.ToBool(o.IsTruncated) {
456+ return nil
457+ }
458+ input.KeyMarker = o.NextKeyMarker
459+ input.UploadIdMarker = o.NextUploadIdMarker
460+ }
461+}
A internal/adapters/blobs3/blobs3_test.go +85 -0
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3_test
3+
4+import (
5+ "context"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ "bnema.dev/ermite/internal/adapters/blobs3"
12+ "bnema.dev/ermite/internal/adapters/blobtest"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/johannesboyne/gofakes3"
15+ "github.com/johannesboyne/gofakes3/backend/s3mem"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func fake(t *testing.T) (*blobs3.Store, *s3mem.Backend) {
20+ t.Helper()
21+ backend := s3mem.New()
22+ require.NoError(t, backend.CreateBucket("b"))
23+ handler := gofakes3.New(backend).Server()
24+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25+ t.Logf("S3 operation: %s %s", r.Method, r.URL.RequestURI())
26+ for _, q := range []string{"policy", "acl", "tagging", "lifecycle", "versioning"} {
27+ if r.URL.Query().Has(q) {
28+ t.Errorf("unexpected admin operation %s", r.URL)
29+ }
30+ }
31+ if r.Method == http.MethodPut && strings.Trim(r.URL.Path, "/") == "b" {
32+ t.Error("unexpected bucket creation")
33+ }
34+ if r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/b/") {
35+ if r.Header.Get("X-Amz-Content-Sha256") == "UNSIGNED-PAYLOAD" || r.Header.Get("X-Amz-Content-Sha256") == "" {
36+ t.Error("payload must be signed")
37+ }
38+ }
39+ handler.ServeHTTP(w, r)
40+ }))
41+ t.Cleanup(srv.Close)
42+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "/pfx/", Endpoint: srv.URL, Region: "us-east-1", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true})
43+ require.NoError(t, e)
44+ return s, backend
45+}
46+func TestContract(t *testing.T) {
47+ blobtest.Run(t, func(t *testing.T) ports.BlobStore { s, _ := fake(t); return s })
48+}
49+func TestPrefix(t *testing.T) {
50+ s, b := fake(t)
51+ _, e := s.Put(context.Background(), "ci/one", strings.NewReader("x"), 1)
52+ require.NoError(t, e)
53+ objects, e := b.ListBucket("b", nil, gofakes3.ListBucketPage{})
54+ require.NoError(t, e)
55+ require.Len(t, objects.Contents, 1)
56+ require.Equal(t, "pfx/ci/one", objects.Contents[0].Key)
57+}
58+func TestMissingBucket(t *testing.T) {
59+ s, b := fake(t)
60+ require.NoError(t, b.DeleteBucket("b"))
61+ e := s.Check(context.Background())
62+ require.ErrorContains(t, e, `bucket "b" is not reachable at http://`)
63+ require.NotContains(t, e.Error(), "test-secret")
64+}
65+
66+func TestCheckDeniedHeadFallsBack(t *testing.T) {
67+ var listed bool
68+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+ if r.Method == http.MethodHead {
70+ w.WriteHeader(http.StatusForbidden)
71+ return
72+ }
73+ if r.Method != http.MethodGet || r.URL.Query().Get("list-type") != "2" || r.URL.Query().Get("max-keys") != "1" || r.URL.Query().Get("prefix") != "pfx/" {
74+ t.Errorf("unexpected probe: %s %s", r.Method, r.URL)
75+ }
76+ listed = true
77+ w.Header().Set("Content-Type", "application/xml")
78+ w.Write([]byte(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>b</Name><IsTruncated>false</IsTruncated></ListBucketResult>`))
79+ }))
80+ defer srv.Close()
81+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "pfx", Endpoint: srv.URL, Region: "test-region", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true})
82+ require.NoError(t, e)
83+ require.NoError(t, s.Check(context.Background()))
84+ require.True(t, listed)
85+}
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3_test
3+
4+import (
5+ "context"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ "bnema.dev/ermite/internal/adapters/blobs3"
12+ "bnema.dev/ermite/internal/adapters/blobtest"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/johannesboyne/gofakes3"
15+ "github.com/johannesboyne/gofakes3/backend/s3mem"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func fake(t *testing.T) (*blobs3.Store, *s3mem.Backend) {
20+ t.Helper()
21+ backend := s3mem.New()
22+ require.NoError(t, backend.CreateBucket("b"))
23+ handler := gofakes3.New(backend).Server()
24+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25+ t.Logf("S3 operation: %s %s", r.Method, r.URL.RequestURI())
26+ for _, q := range []string{"policy", "acl", "tagging", "lifecycle", "versioning"} {
27+ if r.URL.Query().Has(q) {
28+ t.Errorf("unexpected admin operation %s", r.URL)
29+ }
30+ }
31+ if r.Method == http.MethodPut && strings.Trim(r.URL.Path, "/") == "b" {
32+ t.Error("unexpected bucket creation")
33+ }
34+ if r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/b/") {
35+ if r.Header.Get("X-Amz-Content-Sha256") == "UNSIGNED-PAYLOAD" || r.Header.Get("X-Amz-Content-Sha256") == "" {
36+ t.Error("payload must be signed")
37+ }
38+ }
39+ handler.ServeHTTP(w, r)
40+ }))
41+ t.Cleanup(srv.Close)
42+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "/pfx/", Endpoint: srv.URL, Region: "us-east-1", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true})
43+ require.NoError(t, e)
44+ return s, backend
45+}
46+func TestContract(t *testing.T) {
47+ blobtest.Run(t, func(t *testing.T) ports.BlobStore { s, _ := fake(t); return s })
48+}
49+func TestPrefix(t *testing.T) {
50+ s, b := fake(t)
51+ _, e := s.Put(context.Background(), "ci/one", strings.NewReader("x"), 1)
52+ require.NoError(t, e)
53+ objects, e := b.ListBucket("b", nil, gofakes3.ListBucketPage{})
54+ require.NoError(t, e)
55+ require.Len(t, objects.Contents, 1)
56+ require.Equal(t, "pfx/ci/one", objects.Contents[0].Key)
57+}
58+func TestMissingBucket(t *testing.T) {
59+ s, b := fake(t)
60+ require.NoError(t, b.DeleteBucket("b"))
61+ e := s.Check(context.Background())
62+ require.ErrorContains(t, e, `bucket "b" is not reachable at http://`)
63+ require.NotContains(t, e.Error(), "test-secret")
64+}
65+
66+func TestCheckDeniedHeadFallsBack(t *testing.T) {
67+ var listed bool
68+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+ if r.Method == http.MethodHead {
70+ w.WriteHeader(http.StatusForbidden)
71+ return
72+ }
73+ if r.Method != http.MethodGet || r.URL.Query().Get("list-type") != "2" || r.URL.Query().Get("max-keys") != "1" || r.URL.Query().Get("prefix") != "pfx/" {
74+ t.Errorf("unexpected probe: %s %s", r.Method, r.URL)
75+ }
76+ listed = true
77+ w.Header().Set("Content-Type", "application/xml")
78+ w.Write([]byte(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>b</Name><IsTruncated>false</IsTruncated></ListBucketResult>`))
79+ }))
80+ defer srv.Close()
81+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "pfx", Endpoint: srv.URL, Region: "test-region", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true})
82+ require.NoError(t, e)
83+ require.NoError(t, s.Check(context.Background()))
84+ require.True(t, listed)
85+}
A internal/adapters/blobs3/config.go +13 -0
@@ -0,0 +1,13 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3
3+
4+import "net/http"
5+
6+// Config supplies explicit credentials and endpoint settings; no ambient
7+// credential source is consulted.
8+type Config struct {
9+ Bucket, Prefix, Endpoint, Region, AccessKeyID, SecretAccessKey string
10+ ForcePathStyle bool
11+ // HTTPClient optionally overrides the transport (primarily for tests).
12+ HTTPClient *http.Client
13+}
@@ -0,0 +1,13 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3
3+
4+import "net/http"
5+
6+// Config supplies explicit credentials and endpoint settings; no ambient
7+// credential source is consulted.
8+type Config struct {
9+ Bucket, Prefix, Endpoint, Region, AccessKeyID, SecretAccessKey string
10+ ForcePathStyle bool
11+ // HTTPClient optionally overrides the transport (primarily for tests).
12+ HTTPClient *http.Client
13+}
A internal/adapters/blobs3/integration_test.go +39 -0
@@ -0,0 +1,39 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3_test
3+
4+import (
5+ "context"
6+ "crypto/rand"
7+ "encoding/hex"
8+ "os"
9+ "testing"
10+
11+ "bnema.dev/ermite/internal/adapters/blobs3"
12+ "bnema.dev/ermite/internal/adapters/blobtest"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestIntegration_Contract(t *testing.T) {
18+ get := func(k string) string { return os.Getenv("ERMITE_TEST_S3_" + k) }
19+ if get("BUCKET") == "" || get("ACCESS_KEY_ID") == "" || get("SECRET_ACCESS_KEY") == "" {
20+ t.Skip("set ERMITE_TEST_S3_* to run")
21+ }
22+ region := get("REGION")
23+ if region == "" {
24+ region = "us-east-1"
25+ }
26+ blobtest.Run(t, func(t *testing.T) ports.BlobStore {
27+ b := make([]byte, 16)
28+ _, e := rand.Read(b)
29+ require.NoError(t, e)
30+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: get("BUCKET"), Endpoint: get("ENDPOINT"), Region: region, AccessKeyID: get("ACCESS_KEY_ID"), SecretAccessKey: get("SECRET_ACCESS_KEY"), ForcePathStyle: get("FORCE_PATH_STYLE") == "1", Prefix: "ermite-test/" + hex.EncodeToString(b) + "/"})
31+ require.NoError(t, e)
32+ t.Cleanup(func() {
33+ ctx := context.Background()
34+ require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { return s.Delete(ctx, i.Key) }))
35+ require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { return s.AbortMultipart(ctx, i.Key, i.ID) }))
36+ })
37+ return s
38+ })
39+}
@@ -0,0 +1,39 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3_test
3+
4+import (
5+ "context"
6+ "crypto/rand"
7+ "encoding/hex"
8+ "os"
9+ "testing"
10+
11+ "bnema.dev/ermite/internal/adapters/blobs3"
12+ "bnema.dev/ermite/internal/adapters/blobtest"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestIntegration_Contract(t *testing.T) {
18+ get := func(k string) string { return os.Getenv("ERMITE_TEST_S3_" + k) }
19+ if get("BUCKET") == "" || get("ACCESS_KEY_ID") == "" || get("SECRET_ACCESS_KEY") == "" {
20+ t.Skip("set ERMITE_TEST_S3_* to run")
21+ }
22+ region := get("REGION")
23+ if region == "" {
24+ region = "us-east-1"
25+ }
26+ blobtest.Run(t, func(t *testing.T) ports.BlobStore {
27+ b := make([]byte, 16)
28+ _, e := rand.Read(b)
29+ require.NoError(t, e)
30+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: get("BUCKET"), Endpoint: get("ENDPOINT"), Region: region, AccessKeyID: get("ACCESS_KEY_ID"), SecretAccessKey: get("SECRET_ACCESS_KEY"), ForcePathStyle: get("FORCE_PATH_STYLE") == "1", Prefix: "ermite-test/" + hex.EncodeToString(b) + "/"})
31+ require.NoError(t, e)
32+ t.Cleanup(func() {
33+ ctx := context.Background()
34+ require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { return s.Delete(ctx, i.Key) }))
35+ require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { return s.AbortMultipart(ctx, i.Key, i.ID) }))
36+ })
37+ return s
38+ })
39+}
A internal/adapters/blobtest/suite.go +234 -0
@@ -0,0 +1,234 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobtest provides the shared BlobStore contract suite.
4+package blobtest
5+
6+import (
7+ "bytes"
8+ "context"
9+ "crypto/sha256"
10+ "encoding/hex"
11+ "errors"
12+ "io"
13+ "strings"
14+ "testing"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "github.com/stretchr/testify/require"
19+)
20+
21+// Run checks the blob contract against a fresh store for each subtest.
22+func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) {
23+ ctx := context.Background()
24+ t.Run("roundtrip", func(t *testing.T) {
25+ s := newStore(t)
26+ require.NoError(t, s.Check(ctx))
27+ data := []byte("abcdefghij")
28+ info, e := s.Put(ctx, "ci/roundtrip", bytes.NewReader(data), int64(len(data)))
29+ require.NoError(t, e)
30+ h := sha256.Sum256(data)
31+ require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest)
32+ info, e = s.Stat(ctx, "ci/roundtrip")
33+ require.NoError(t, e)
34+ require.EqualValues(t, len(data), info.Size)
35+ require.False(t, info.ModTime.IsZero())
36+ for _, r := range []struct {
37+ off, len int64
38+ want string
39+ }{{0, -1, "abcdefghij"}, {0, 3, "abc"}, {3, 4, "defg"}, {9, -1, "j"}, {3, 0, ""}} {
40+ rc, meta, e := s.Open(ctx, "ci/roundtrip", r.off, r.len)
41+ require.NoError(t, e)
42+ b, e := io.ReadAll(rc)
43+ require.NoError(t, e)
44+ require.NoError(t, rc.Close())
45+ require.Equal(t, r.want, string(b))
46+ require.EqualValues(t, 10, meta.Size)
47+ }
48+ for _, off := range []int64{10, 11} {
49+ _, _, e := s.Open(ctx, "ci/roundtrip", off, -1)
50+ require.ErrorIs(t, e, domain.ErrOutOfRange)
51+ }
52+ _, e = s.Put(ctx, "ci/roundtrip", strings.NewReader("new"), 3)
53+ require.NoError(t, e)
54+ rc, _, e := s.Open(ctx, "ci/roundtrip", 0, -1)
55+ require.NoError(t, e)
56+ b, e := io.ReadAll(rc)
57+ require.NoError(t, e)
58+ rc.Close()
59+ require.Equal(t, "new", string(b))
60+ require.NoError(t, s.Delete(ctx, "ci/roundtrip"))
61+ require.NoError(t, s.Delete(ctx, "ci/roundtrip"))
62+ _, e = s.Stat(ctx, "ci/roundtrip")
63+ require.ErrorIs(t, e, domain.ErrNotFound)
64+ _, _, e = s.Open(ctx, "ci/absent", 0, -1)
65+ require.ErrorIs(t, e, domain.ErrNotFound)
66+ })
67+ t.Run("list_and_reserved_names", func(t *testing.T) {
68+ s := newStore(t)
69+ keys := []string{"a.parts/b", "a/.tmp-object", "ci/z", "ci/a"}
70+ for _, k := range keys {
71+ _, e := s.Put(ctx, k, strings.NewReader(k), int64(len(k)))
72+ require.NoError(t, e)
73+ r, _, e := s.Open(ctx, k, 0, -1)
74+ require.NoError(t, e)
75+ b, e := io.ReadAll(r)
76+ r.Close()
77+ require.NoError(t, e)
78+ require.Equal(t, k, string(b))
79+ }
80+ var got []string
81+ require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil }))
82+ require.Equal(t, []string{"a.parts/b", "a/.tmp-object", "ci/a", "ci/z"}, got)
83+ got = nil
84+ require.NoError(t, s.List(ctx, "ci/", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil }))
85+ require.Equal(t, []string{"ci/a", "ci/z"}, got)
86+ stop := errors.New("stop")
87+ calls := 0
88+ require.ErrorIs(t, s.List(ctx, "ci/", func(ports.BlobInfo) error { calls++; return stop }), stop)
89+ require.Equal(t, 1, calls)
90+ })
91+ t.Run("invalid", func(t *testing.T) {
92+ s := newStore(t)
93+ for _, key := range []string{"", "/a", "a//b", "a/../b", "a/./b", "é", strings.Repeat("a", 513)} {
94+ _, e := s.Put(ctx, key, strings.NewReader("x"), 1)
95+ require.ErrorIs(t, e, domain.ErrInvalid)
96+ _, e = s.Stat(ctx, key)
97+ require.ErrorIs(t, e, domain.ErrInvalid)
98+ _, _, e = s.Open(ctx, key, 0, -1)
99+ require.ErrorIs(t, e, domain.ErrInvalid)
100+ require.ErrorIs(t, s.Delete(ctx, key), domain.ErrInvalid)
101+ _, e = s.NewMultipart(ctx, key)
102+ require.ErrorIs(t, e, domain.ErrInvalid)
103+ require.ErrorIs(t, s.AbortMultipart(ctx, key, "id"), domain.ErrInvalid)
104+ }
105+ _, e := s.Put(ctx, "ci/negative", strings.NewReader(""), -1)
106+ require.ErrorIs(t, e, domain.ErrInvalid)
107+ })
108+ t.Run("multipart", func(t *testing.T) {
109+ s := newStore(t)
110+ u, e := s.NewMultipart(ctx, "ci/multi")
111+ require.NoError(t, e)
112+ var all []byte
113+ for n := 1; n <= 4; n++ {
114+ size := ports.BlobMinPartSize
115+ if n == 4 {
116+ size = 1024
117+ }
118+ data := bytes.Repeat([]byte{byte(n)}, size)
119+ all = append(all, data...)
120+ require.NoError(t, u.UploadPart(ctx, n, bytes.NewReader(data), int64(size)))
121+ }
122+ info, e := u.Complete(ctx)
123+ require.NoError(t, e)
124+ require.Empty(t, info.Digest)
125+ r, _, e := s.Open(ctx, "ci/multi", 0, -1)
126+ require.NoError(t, e)
127+ b, e := io.ReadAll(r)
128+ r.Close()
129+ require.NoError(t, e)
130+ require.Equal(t, all, b)
131+ })
132+ t.Run("abort", func(t *testing.T) {
133+ s := newStore(t)
134+ for _, byID := range []bool{false, true} {
135+ u, e := s.NewMultipart(ctx, "ci/abort")
136+ require.NoError(t, e)
137+ require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1))
138+ var uploads []ports.MultipartInfo
139+ require.NoError(t, s.ListMultipart(ctx, "ci/", func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil }))
140+ require.Len(t, uploads, 1)
141+ require.Equal(t, u.ID(), uploads[0].ID)
142+ require.Equal(t, "ci/abort", uploads[0].Key)
143+ require.False(t, uploads[0].Started.IsZero())
144+ if byID {
145+ require.NoError(t, s.AbortMultipart(ctx, "ci/abort", u.ID()))
146+ } else {
147+ require.NoError(t, u.Abort(ctx))
148+ }
149+ _, e = s.Stat(ctx, "ci/abort")
150+ require.ErrorIs(t, e, domain.ErrNotFound)
151+ uploads = nil
152+ require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil }))
153+ require.Empty(t, uploads)
154+ }
155+ })
156+ t.Run("undersized_part", func(t *testing.T) {
157+ s := newStore(t)
158+ u, e := s.NewMultipart(ctx, "ci/small")
159+ require.NoError(t, e)
160+ require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1))
161+ require.NoError(t, u.UploadPart(ctx, 2, strings.NewReader("y"), 1))
162+ _, e = u.Complete(ctx)
163+ require.ErrorIs(t, e, domain.ErrInvalid)
164+ _, e = s.Stat(ctx, "ci/small")
165+ require.ErrorIs(t, e, domain.ErrNotFound)
166+ })
167+ t.Run("failed_put", func(t *testing.T) {
168+ s := newStore(t)
169+ _, e := s.Put(ctx, "ci/fail", io.MultiReader(strings.NewReader("abc"), failReader{}), 10)
170+ require.Error(t, e)
171+ _, e = s.Stat(ctx, "ci/fail")
172+ require.ErrorIs(t, e, domain.ErrNotFound)
173+ for _, v := range []struct {
174+ data string
175+ size int64
176+ }{{"a", 2}, {"abc", 2}} {
177+ _, e = s.Put(ctx, "ci/size", strings.NewReader(v.data), v.size)
178+ require.ErrorIs(t, e, domain.ErrInvalid)
179+ _, e = s.Stat(ctx, "ci/size")
180+ require.ErrorIs(t, e, domain.ErrNotFound)
181+ }
182+ })
183+ t.Run("large_put", func(t *testing.T) {
184+ s := newStore(t)
185+ data := bytes.Repeat([]byte("large"), ports.BlobPartSize/5+100)
186+ info, e := s.Put(ctx, "ci/large", bytes.NewBuffer(data), int64(len(data)))
187+ require.NoError(t, e)
188+ h := sha256.Sum256(data)
189+ require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest)
190+ r, _, e := s.Open(ctx, "ci/large", 0, -1)
191+ require.NoError(t, e)
192+ got, e := io.ReadAll(r)
193+ require.NoError(t, e)
194+ require.NoError(t, r.Close())
195+ require.Equal(t, data, got)
196+ for _, size := range []int64{int64(len(data) - 1), int64(len(data) + 1)} {
197+ _, e = s.Put(ctx, "ci/badlarge", bytes.NewBuffer(data), size)
198+ require.ErrorIs(t, e, domain.ErrInvalid)
199+ _, e = s.Stat(ctx, "ci/badlarge")
200+ require.ErrorIs(t, e, domain.ErrNotFound)
201+ }
202+ require.NoError(t, s.ListMultipart(ctx, "", func(ports.MultipartInfo) error { t.Error("unfinished upload after Put"); return nil }))
203+ })
204+
205+ t.Run("cancel_put", func(t *testing.T) {
206+ s := newStore(t)
207+ c, cancel := context.WithCancel(ctx)
208+ defer cancel()
209+ r := &cancelReader{cancel: cancel}
210+ _, e := s.Put(c, "ci/cancel", r, 1024)
211+ require.ErrorIs(t, e, context.Canceled)
212+ _, e = s.Stat(ctx, "ci/cancel")
213+ require.ErrorIs(t, e, domain.ErrNotFound)
214+ })
215+}
216+
217+type failReader struct{}
218+
219+func (failReader) Read([]byte) (int, error) { return 0, errors.New("reader failed") }
220+
221+type cancelReader struct {
222+ cancel context.CancelFunc
223+ done bool
224+}
225+
226+func (r *cancelReader) Read(p []byte) (int, error) {
227+ if r.done {
228+ return 0, context.Canceled
229+ }
230+ r.done = true
231+ r.cancel()
232+ p[0] = 'x'
233+ return 1, nil
234+}
@@ -0,0 +1,234 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobtest provides the shared BlobStore contract suite.
4+package blobtest
5+
6+import (
7+ "bytes"
8+ "context"
9+ "crypto/sha256"
10+ "encoding/hex"
11+ "errors"
12+ "io"
13+ "strings"
14+ "testing"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "github.com/stretchr/testify/require"
19+)
20+
21+// Run checks the blob contract against a fresh store for each subtest.
22+func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) {
23+ ctx := context.Background()
24+ t.Run("roundtrip", func(t *testing.T) {
25+ s := newStore(t)
26+ require.NoError(t, s.Check(ctx))
27+ data := []byte("abcdefghij")
28+ info, e := s.Put(ctx, "ci/roundtrip", bytes.NewReader(data), int64(len(data)))
29+ require.NoError(t, e)
30+ h := sha256.Sum256(data)
31+ require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest)
32+ info, e = s.Stat(ctx, "ci/roundtrip")
33+ require.NoError(t, e)
34+ require.EqualValues(t, len(data), info.Size)
35+ require.False(t, info.ModTime.IsZero())
36+ for _, r := range []struct {
37+ off, len int64
38+ want string
39+ }{{0, -1, "abcdefghij"}, {0, 3, "abc"}, {3, 4, "defg"}, {9, -1, "j"}, {3, 0, ""}} {
40+ rc, meta, e := s.Open(ctx, "ci/roundtrip", r.off, r.len)
41+ require.NoError(t, e)
42+ b, e := io.ReadAll(rc)
43+ require.NoError(t, e)
44+ require.NoError(t, rc.Close())
45+ require.Equal(t, r.want, string(b))
46+ require.EqualValues(t, 10, meta.Size)
47+ }
48+ for _, off := range []int64{10, 11} {
49+ _, _, e := s.Open(ctx, "ci/roundtrip", off, -1)
50+ require.ErrorIs(t, e, domain.ErrOutOfRange)
51+ }
52+ _, e = s.Put(ctx, "ci/roundtrip", strings.NewReader("new"), 3)
53+ require.NoError(t, e)
54+ rc, _, e := s.Open(ctx, "ci/roundtrip", 0, -1)
55+ require.NoError(t, e)
56+ b, e := io.ReadAll(rc)
57+ require.NoError(t, e)
58+ rc.Close()
59+ require.Equal(t, "new", string(b))
60+ require.NoError(t, s.Delete(ctx, "ci/roundtrip"))
61+ require.NoError(t, s.Delete(ctx, "ci/roundtrip"))
62+ _, e = s.Stat(ctx, "ci/roundtrip")
63+ require.ErrorIs(t, e, domain.ErrNotFound)
64+ _, _, e = s.Open(ctx, "ci/absent", 0, -1)
65+ require.ErrorIs(t, e, domain.ErrNotFound)
66+ })
67+ t.Run("list_and_reserved_names", func(t *testing.T) {
68+ s := newStore(t)
69+ keys := []string{"a.parts/b", "a/.tmp-object", "ci/z", "ci/a"}
70+ for _, k := range keys {
71+ _, e := s.Put(ctx, k, strings.NewReader(k), int64(len(k)))
72+ require.NoError(t, e)
73+ r, _, e := s.Open(ctx, k, 0, -1)
74+ require.NoError(t, e)
75+ b, e := io.ReadAll(r)
76+ r.Close()
77+ require.NoError(t, e)
78+ require.Equal(t, k, string(b))
79+ }
80+ var got []string
81+ require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil }))
82+ require.Equal(t, []string{"a.parts/b", "a/.tmp-object", "ci/a", "ci/z"}, got)
83+ got = nil
84+ require.NoError(t, s.List(ctx, "ci/", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil }))
85+ require.Equal(t, []string{"ci/a", "ci/z"}, got)
86+ stop := errors.New("stop")
87+ calls := 0
88+ require.ErrorIs(t, s.List(ctx, "ci/", func(ports.BlobInfo) error { calls++; return stop }), stop)
89+ require.Equal(t, 1, calls)
90+ })
91+ t.Run("invalid", func(t *testing.T) {
92+ s := newStore(t)
93+ for _, key := range []string{"", "/a", "a//b", "a/../b", "a/./b", "é", strings.Repeat("a", 513)} {
94+ _, e := s.Put(ctx, key, strings.NewReader("x"), 1)
95+ require.ErrorIs(t, e, domain.ErrInvalid)
96+ _, e = s.Stat(ctx, key)
97+ require.ErrorIs(t, e, domain.ErrInvalid)
98+ _, _, e = s.Open(ctx, key, 0, -1)
99+ require.ErrorIs(t, e, domain.ErrInvalid)
100+ require.ErrorIs(t, s.Delete(ctx, key), domain.ErrInvalid)
101+ _, e = s.NewMultipart(ctx, key)
102+ require.ErrorIs(t, e, domain.ErrInvalid)
103+ require.ErrorIs(t, s.AbortMultipart(ctx, key, "id"), domain.ErrInvalid)
104+ }
105+ _, e := s.Put(ctx, "ci/negative", strings.NewReader(""), -1)
106+ require.ErrorIs(t, e, domain.ErrInvalid)
107+ })
108+ t.Run("multipart", func(t *testing.T) {
109+ s := newStore(t)
110+ u, e := s.NewMultipart(ctx, "ci/multi")
111+ require.NoError(t, e)
112+ var all []byte
113+ for n := 1; n <= 4; n++ {
114+ size := ports.BlobMinPartSize
115+ if n == 4 {
116+ size = 1024
117+ }
118+ data := bytes.Repeat([]byte{byte(n)}, size)
119+ all = append(all, data...)
120+ require.NoError(t, u.UploadPart(ctx, n, bytes.NewReader(data), int64(size)))
121+ }
122+ info, e := u.Complete(ctx)
123+ require.NoError(t, e)
124+ require.Empty(t, info.Digest)
125+ r, _, e := s.Open(ctx, "ci/multi", 0, -1)
126+ require.NoError(t, e)
127+ b, e := io.ReadAll(r)
128+ r.Close()
129+ require.NoError(t, e)
130+ require.Equal(t, all, b)
131+ })
132+ t.Run("abort", func(t *testing.T) {
133+ s := newStore(t)
134+ for _, byID := range []bool{false, true} {
135+ u, e := s.NewMultipart(ctx, "ci/abort")
136+ require.NoError(t, e)
137+ require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1))
138+ var uploads []ports.MultipartInfo
139+ require.NoError(t, s.ListMultipart(ctx, "ci/", func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil }))
140+ require.Len(t, uploads, 1)
141+ require.Equal(t, u.ID(), uploads[0].ID)
142+ require.Equal(t, "ci/abort", uploads[0].Key)
143+ require.False(t, uploads[0].Started.IsZero())
144+ if byID {
145+ require.NoError(t, s.AbortMultipart(ctx, "ci/abort", u.ID()))
146+ } else {
147+ require.NoError(t, u.Abort(ctx))
148+ }
149+ _, e = s.Stat(ctx, "ci/abort")
150+ require.ErrorIs(t, e, domain.ErrNotFound)
151+ uploads = nil
152+ require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil }))
153+ require.Empty(t, uploads)
154+ }
155+ })
156+ t.Run("undersized_part", func(t *testing.T) {
157+ s := newStore(t)
158+ u, e := s.NewMultipart(ctx, "ci/small")
159+ require.NoError(t, e)
160+ require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1))
161+ require.NoError(t, u.UploadPart(ctx, 2, strings.NewReader("y"), 1))
162+ _, e = u.Complete(ctx)
163+ require.ErrorIs(t, e, domain.ErrInvalid)
164+ _, e = s.Stat(ctx, "ci/small")
165+ require.ErrorIs(t, e, domain.ErrNotFound)
166+ })
167+ t.Run("failed_put", func(t *testing.T) {
168+ s := newStore(t)
169+ _, e := s.Put(ctx, "ci/fail", io.MultiReader(strings.NewReader("abc"), failReader{}), 10)
170+ require.Error(t, e)
171+ _, e = s.Stat(ctx, "ci/fail")
172+ require.ErrorIs(t, e, domain.ErrNotFound)
173+ for _, v := range []struct {
174+ data string
175+ size int64
176+ }{{"a", 2}, {"abc", 2}} {
177+ _, e = s.Put(ctx, "ci/size", strings.NewReader(v.data), v.size)
178+ require.ErrorIs(t, e, domain.ErrInvalid)
179+ _, e = s.Stat(ctx, "ci/size")
180+ require.ErrorIs(t, e, domain.ErrNotFound)
181+ }
182+ })
183+ t.Run("large_put", func(t *testing.T) {
184+ s := newStore(t)
185+ data := bytes.Repeat([]byte("large"), ports.BlobPartSize/5+100)
186+ info, e := s.Put(ctx, "ci/large", bytes.NewBuffer(data), int64(len(data)))
187+ require.NoError(t, e)
188+ h := sha256.Sum256(data)
189+ require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest)
190+ r, _, e := s.Open(ctx, "ci/large", 0, -1)
191+ require.NoError(t, e)
192+ got, e := io.ReadAll(r)
193+ require.NoError(t, e)
194+ require.NoError(t, r.Close())
195+ require.Equal(t, data, got)
196+ for _, size := range []int64{int64(len(data) - 1), int64(len(data) + 1)} {
197+ _, e = s.Put(ctx, "ci/badlarge", bytes.NewBuffer(data), size)
198+ require.ErrorIs(t, e, domain.ErrInvalid)
199+ _, e = s.Stat(ctx, "ci/badlarge")
200+ require.ErrorIs(t, e, domain.ErrNotFound)
201+ }
202+ require.NoError(t, s.ListMultipart(ctx, "", func(ports.MultipartInfo) error { t.Error("unfinished upload after Put"); return nil }))
203+ })
204+
205+ t.Run("cancel_put", func(t *testing.T) {
206+ s := newStore(t)
207+ c, cancel := context.WithCancel(ctx)
208+ defer cancel()
209+ r := &cancelReader{cancel: cancel}
210+ _, e := s.Put(c, "ci/cancel", r, 1024)
211+ require.ErrorIs(t, e, context.Canceled)
212+ _, e = s.Stat(ctx, "ci/cancel")
213+ require.ErrorIs(t, e, domain.ErrNotFound)
214+ })
215+}
216+
217+type failReader struct{}
218+
219+func (failReader) Read([]byte) (int, error) { return 0, errors.New("reader failed") }
220+
221+type cancelReader struct {
222+ cancel context.CancelFunc
223+ done bool
224+}
225+
226+func (r *cancelReader) Read(p []byte) (int, error) {
227+ if r.done {
228+ return 0, context.Canceled
229+ }
230+ r.done = true
231+ r.cancel()
232+ p[0] = 'x'
233+ return 1, nil
234+}
A internal/ports/blob.go +72 -0
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ports
4+
5+import (
6+ "context"
7+ "io"
8+ "strings"
9+ "time"
10+)
11+
12+// BlobStore keeps the contents of release assets and CI artifacts, never
13+// their metadata. Keys are built by the use cases from ids only ("a/b/c",
14+// ASCII, no empty segment, no ".", no ".." segment, no leading "/", ≤ 512
15+// bytes); an adapter rejects any other key with domain.ErrInvalid.
16+// *blobdisk.Store and *blobs3.Store implement it.
17+type BlobStore interface {
18+ Put(ctx context.Context, key string, r io.Reader, size int64) (BlobInfo, error) // size ≥ 0 required; replaces; a failure leaves no object
19+ Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, BlobInfo, error) // length < 0: to the end; offset ≥ size → domain.ErrOutOfRange
20+ Stat(ctx context.Context, key string) (BlobInfo, error)
21+ Delete(ctx context.Context, key string) error // absent: nil
22+ List(ctx context.Context, prefix string, fn func(BlobInfo) error) error // lexicographic; fn error stops and is returned
23+ NewMultipart(ctx context.Context, key string) (MultipartUpload, error)
24+ ListMultipart(ctx context.Context, prefix string, fn func(MultipartInfo) error) error
25+ AbortMultipart(ctx context.Context, key, id string) error // for GC of uploads listed by ListMultipart
26+ Check(ctx context.Context) error // startup probe; never creates anything
27+}
28+type BlobInfo struct {
29+ Key string
30+ Size int64
31+ Digest string
32+ ModTime time.Time
33+} // Digest "sha256:<hex>" from Put, "" from Stat/List/Open on S3
34+type MultipartInfo struct {
35+ Key, ID string
36+ Started time.Time
37+}
38+
39+// MultipartUpload uploads one object in parts of BlobMinPartSize or more
40+// (the last may be smaller), at most BlobMaxParts, numbered from 1 in the
41+// order given to Complete. Abort discards everything; Complete or Abort
42+// must be called exactly once.
43+type MultipartUpload interface {
44+ ID() string
45+ UploadPart(ctx context.Context, n int, r io.Reader, size int64) error
46+ Complete(ctx context.Context) (BlobInfo, error) // parts in number order; Digest "" (the caller hashes)
47+ Abort(ctx context.Context) error
48+}
49+
50+const (
51+ BlobMinPartSize = 5 << 20
52+ BlobPartSize = 8 << 20
53+ BlobMaxParts = 10_000
54+)
55+
56+// ValidBlobKey reports whether key follows the rule above.
57+func ValidBlobKey(key string) bool {
58+ if len(key) == 0 || len(key) > 512 {
59+ return false
60+ }
61+ for _, c := range []byte(key) {
62+ if c > 127 {
63+ return false
64+ }
65+ }
66+ for _, segment := range strings.Split(key, "/") {
67+ if segment == "" || segment == "." || segment == ".." {
68+ return false
69+ }
70+ }
71+ return true
72+}
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ports
4+
5+import (
6+ "context"
7+ "io"
8+ "strings"
9+ "time"
10+)
11+
12+// BlobStore keeps the contents of release assets and CI artifacts, never
13+// their metadata. Keys are built by the use cases from ids only ("a/b/c",
14+// ASCII, no empty segment, no ".", no ".." segment, no leading "/", ≤ 512
15+// bytes); an adapter rejects any other key with domain.ErrInvalid.
16+// *blobdisk.Store and *blobs3.Store implement it.
17+type BlobStore interface {
18+ Put(ctx context.Context, key string, r io.Reader, size int64) (BlobInfo, error) // size ≥ 0 required; replaces; a failure leaves no object
19+ Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, BlobInfo, error) // length < 0: to the end; offset ≥ size → domain.ErrOutOfRange
20+ Stat(ctx context.Context, key string) (BlobInfo, error)
21+ Delete(ctx context.Context, key string) error // absent: nil
22+ List(ctx context.Context, prefix string, fn func(BlobInfo) error) error // lexicographic; fn error stops and is returned
23+ NewMultipart(ctx context.Context, key string) (MultipartUpload, error)
24+ ListMultipart(ctx context.Context, prefix string, fn func(MultipartInfo) error) error
25+ AbortMultipart(ctx context.Context, key, id string) error // for GC of uploads listed by ListMultipart
26+ Check(ctx context.Context) error // startup probe; never creates anything
27+}
28+type BlobInfo struct {
29+ Key string
30+ Size int64
31+ Digest string
32+ ModTime time.Time
33+} // Digest "sha256:<hex>" from Put, "" from Stat/List/Open on S3
34+type MultipartInfo struct {
35+ Key, ID string
36+ Started time.Time
37+}
38+
39+// MultipartUpload uploads one object in parts of BlobMinPartSize or more
40+// (the last may be smaller), at most BlobMaxParts, numbered from 1 in the
41+// order given to Complete. Abort discards everything; Complete or Abort
42+// must be called exactly once.
43+type MultipartUpload interface {
44+ ID() string
45+ UploadPart(ctx context.Context, n int, r io.Reader, size int64) error
46+ Complete(ctx context.Context) (BlobInfo, error) // parts in number order; Digest "" (the caller hashes)
47+ Abort(ctx context.Context) error
48+}
49+
50+const (
51+ BlobMinPartSize = 5 << 20
52+ BlobPartSize = 8 << 20
53+ BlobMaxParts = 10_000
54+)
55+
56+// ValidBlobKey reports whether key follows the rule above.
57+func ValidBlobKey(key string) bool {
58+ if len(key) == 0 || len(key) > 512 {
59+ return false
60+ }
61+ for _, c := range []byte(key) {
62+ if c > 127 {
63+ return false
64+ }
65+ }
66+ for _, segment := range strings.Split(key, "/") {
67+ if segment == "" || segment == "." || segment == ".." {
68+ return false
69+ }
70+ }
71+ return true
72+}
A internal/ports/blob_test.go +19 -0
@@ -0,0 +1,19 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package ports_test
3+
4+import (
5+ "strings"
6+ "testing"
7+
8+ "bnema.dev/ermite/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestValidBlobKey(t *testing.T) {
13+ for _, v := range []struct {
14+ key string
15+ ok bool
16+ }{{"ci/00000000000000000001/x", true}, {"a/.tmp-object", true}, {"a.parts/b", true}, {"", false}, {"/a", false}, {"a//b", false}, {"a/../b", false}, {"a/./b", false}, {"é", false}, {strings.Repeat("a", 513), false}} {
17+ t.Run(v.key, func(t *testing.T) { require.Equal(t, v.ok, ports.ValidBlobKey(v.key)) })
18+ }
19+}
@@ -0,0 +1,19 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package ports_test
3+
4+import (
5+ "strings"
6+ "testing"
7+
8+ "bnema.dev/ermite/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestValidBlobKey(t *testing.T) {
13+ for _, v := range []struct {
14+ key string
15+ ok bool
16+ }{{"ci/00000000000000000001/x", true}, {"a/.tmp-object", true}, {"a.parts/b", true}, {"", false}, {"/a", false}, {"a//b", false}, {"a/../b", false}, {"a/./b", false}, {"é", false}, {strings.Repeat("a", 513), false}} {
17+ t.Run(v.key, func(t *testing.T) { require.Equal(t, v.ok, ports.ValidBlobKey(v.key)) })
18+ }
19+}
A internal/ports/mocks/BlobStore.go +644 -0
@@ -0,0 +1,644 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewBlobStore creates a new instance of BlobStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewBlobStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *BlobStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &BlobStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// BlobStore is an autogenerated mock type for the BlobStore type
39+type BlobStore struct {
40+ mock.Mock
41+}
42+
43+type BlobStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *BlobStore) EXPECT() *BlobStore_Expecter {
48+ return &BlobStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// AbortMultipart provides a mock function for the type BlobStore
52+func (_mock *BlobStore) AbortMultipart(ctx context.Context, key string, id string) error {
53+ ret := _mock.Called(ctx, key, id)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for AbortMultipart")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
61+ r0 = returnFunc(ctx, key, id)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// BlobStore_AbortMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AbortMultipart'
69+type BlobStore_AbortMultipart_Call struct {
70+ *mock.Call
71+}
72+
73+// AbortMultipart is a helper method to define mock.On call
74+// - ctx context.Context
75+// - key string
76+// - id string
77+func (_e *BlobStore_Expecter) AbortMultipart(ctx any, key any, id any) *BlobStore_AbortMultipart_Call {
78+ return &BlobStore_AbortMultipart_Call{Call: _e.mock.On("AbortMultipart", ctx, key, id)}
79+}
80+
81+func (_c *BlobStore_AbortMultipart_Call) Run(run func(ctx context.Context, key string, id string)) *BlobStore_AbortMultipart_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 string
88+ if args[1] != nil {
89+ arg1 = args[1].(string)
90+ }
91+ var arg2 string
92+ if args[2] != nil {
93+ arg2 = args[2].(string)
94+ }
95+ run(
96+ arg0,
97+ arg1,
98+ arg2,
99+ )
100+ })
101+ return _c
102+}
103+
104+func (_c *BlobStore_AbortMultipart_Call) Return(err error) *BlobStore_AbortMultipart_Call {
105+ _c.Call.Return(err)
106+ return _c
107+}
108+
109+func (_c *BlobStore_AbortMultipart_Call) RunAndReturn(run func(ctx context.Context, key string, id string) error) *BlobStore_AbortMultipart_Call {
110+ _c.Call.Return(run)
111+ return _c
112+}
113+
114+// Check provides a mock function for the type BlobStore
115+func (_mock *BlobStore) Check(ctx context.Context) error {
116+ ret := _mock.Called(ctx)
117+
118+ if len(ret) == 0 {
119+ panic("no return value specified for Check")
120+ }
121+
122+ var r0 error
123+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
124+ r0 = returnFunc(ctx)
125+ } else {
126+ r0 = ret.Error(0)
127+ }
128+ return r0
129+}
130+
131+// BlobStore_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check'
132+type BlobStore_Check_Call struct {
133+ *mock.Call
134+}
135+
136+// Check is a helper method to define mock.On call
137+// - ctx context.Context
138+func (_e *BlobStore_Expecter) Check(ctx any) *BlobStore_Check_Call {
139+ return &BlobStore_Check_Call{Call: _e.mock.On("Check", ctx)}
140+}
141+
142+func (_c *BlobStore_Check_Call) Run(run func(ctx context.Context)) *BlobStore_Check_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ var arg0 context.Context
145+ if args[0] != nil {
146+ arg0 = args[0].(context.Context)
147+ }
148+ run(
149+ arg0,
150+ )
151+ })
152+ return _c
153+}
154+
155+func (_c *BlobStore_Check_Call) Return(err error) *BlobStore_Check_Call {
156+ _c.Call.Return(err)
157+ return _c
158+}
159+
160+func (_c *BlobStore_Check_Call) RunAndReturn(run func(ctx context.Context) error) *BlobStore_Check_Call {
161+ _c.Call.Return(run)
162+ return _c
163+}
164+
165+// Delete provides a mock function for the type BlobStore
166+func (_mock *BlobStore) Delete(ctx context.Context, key string) error {
167+ ret := _mock.Called(ctx, key)
168+
169+ if len(ret) == 0 {
170+ panic("no return value specified for Delete")
171+ }
172+
173+ var r0 error
174+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
175+ r0 = returnFunc(ctx, key)
176+ } else {
177+ r0 = ret.Error(0)
178+ }
179+ return r0
180+}
181+
182+// BlobStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
183+type BlobStore_Delete_Call struct {
184+ *mock.Call
185+}
186+
187+// Delete is a helper method to define mock.On call
188+// - ctx context.Context
189+// - key string
190+func (_e *BlobStore_Expecter) Delete(ctx any, key any) *BlobStore_Delete_Call {
191+ return &BlobStore_Delete_Call{Call: _e.mock.On("Delete", ctx, key)}
192+}
193+
194+func (_c *BlobStore_Delete_Call) Run(run func(ctx context.Context, key string)) *BlobStore_Delete_Call {
195+ _c.Call.Run(func(args mock.Arguments) {
196+ var arg0 context.Context
197+ if args[0] != nil {
198+ arg0 = args[0].(context.Context)
199+ }
200+ var arg1 string
201+ if args[1] != nil {
202+ arg1 = args[1].(string)
203+ }
204+ run(
205+ arg0,
206+ arg1,
207+ )
208+ })
209+ return _c
210+}
211+
212+func (_c *BlobStore_Delete_Call) Return(err error) *BlobStore_Delete_Call {
213+ _c.Call.Return(err)
214+ return _c
215+}
216+
217+func (_c *BlobStore_Delete_Call) RunAndReturn(run func(ctx context.Context, key string) error) *BlobStore_Delete_Call {
218+ _c.Call.Return(run)
219+ return _c
220+}
221+
222+// List provides a mock function for the type BlobStore
223+func (_mock *BlobStore) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error {
224+ ret := _mock.Called(ctx, prefix, fn)
225+
226+ if len(ret) == 0 {
227+ panic("no return value specified for List")
228+ }
229+
230+ var r0 error
231+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, func(ports.BlobInfo) error) error); ok {
232+ r0 = returnFunc(ctx, prefix, fn)
233+ } else {
234+ r0 = ret.Error(0)
235+ }
236+ return r0
237+}
238+
239+// BlobStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
240+type BlobStore_List_Call struct {
241+ *mock.Call
242+}
243+
244+// List is a helper method to define mock.On call
245+// - ctx context.Context
246+// - prefix string
247+// - fn func(ports.BlobInfo) error
248+func (_e *BlobStore_Expecter) List(ctx any, prefix any, fn any) *BlobStore_List_Call {
249+ return &BlobStore_List_Call{Call: _e.mock.On("List", ctx, prefix, fn)}
250+}
251+
252+func (_c *BlobStore_List_Call) Run(run func(ctx context.Context, prefix string, fn func(ports.BlobInfo) error)) *BlobStore_List_Call {
253+ _c.Call.Run(func(args mock.Arguments) {
254+ var arg0 context.Context
255+ if args[0] != nil {
256+ arg0 = args[0].(context.Context)
257+ }
258+ var arg1 string
259+ if args[1] != nil {
260+ arg1 = args[1].(string)
261+ }
262+ var arg2 func(ports.BlobInfo) error
263+ if args[2] != nil {
264+ arg2 = args[2].(func(ports.BlobInfo) error)
265+ }
266+ run(
267+ arg0,
268+ arg1,
269+ arg2,
270+ )
271+ })
272+ return _c
273+}
274+
275+func (_c *BlobStore_List_Call) Return(err error) *BlobStore_List_Call {
276+ _c.Call.Return(err)
277+ return _c
278+}
279+
280+func (_c *BlobStore_List_Call) RunAndReturn(run func(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error) *BlobStore_List_Call {
281+ _c.Call.Return(run)
282+ return _c
283+}
284+
285+// ListMultipart provides a mock function for the type BlobStore
286+func (_mock *BlobStore) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
287+ ret := _mock.Called(ctx, prefix, fn)
288+
289+ if len(ret) == 0 {
290+ panic("no return value specified for ListMultipart")
291+ }
292+
293+ var r0 error
294+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, func(ports.MultipartInfo) error) error); ok {
295+ r0 = returnFunc(ctx, prefix, fn)
296+ } else {
297+ r0 = ret.Error(0)
298+ }
299+ return r0
300+}
301+
302+// BlobStore_ListMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListMultipart'
303+type BlobStore_ListMultipart_Call struct {
304+ *mock.Call
305+}
306+
307+// ListMultipart is a helper method to define mock.On call
308+// - ctx context.Context
309+// - prefix string
310+// - fn func(ports.MultipartInfo) error
311+func (_e *BlobStore_Expecter) ListMultipart(ctx any, prefix any, fn any) *BlobStore_ListMultipart_Call {
312+ return &BlobStore_ListMultipart_Call{Call: _e.mock.On("ListMultipart", ctx, prefix, fn)}
313+}
314+
315+func (_c *BlobStore_ListMultipart_Call) Run(run func(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error)) *BlobStore_ListMultipart_Call {
316+ _c.Call.Run(func(args mock.Arguments) {
317+ var arg0 context.Context
318+ if args[0] != nil {
319+ arg0 = args[0].(context.Context)
320+ }
321+ var arg1 string
322+ if args[1] != nil {
323+ arg1 = args[1].(string)
324+ }
325+ var arg2 func(ports.MultipartInfo) error
326+ if args[2] != nil {
327+ arg2 = args[2].(func(ports.MultipartInfo) error)
328+ }
329+ run(
330+ arg0,
331+ arg1,
332+ arg2,
333+ )
334+ })
335+ return _c
336+}
337+
338+func (_c *BlobStore_ListMultipart_Call) Return(err error) *BlobStore_ListMultipart_Call {
339+ _c.Call.Return(err)
340+ return _c
341+}
342+
343+func (_c *BlobStore_ListMultipart_Call) RunAndReturn(run func(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error) *BlobStore_ListMultipart_Call {
344+ _c.Call.Return(run)
345+ return _c
346+}
347+
348+// NewMultipart provides a mock function for the type BlobStore
349+func (_mock *BlobStore) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
350+ ret := _mock.Called(ctx, key)
351+
352+ if len(ret) == 0 {
353+ panic("no return value specified for NewMultipart")
354+ }
355+
356+ var r0 ports.MultipartUpload
357+ var r1 error
358+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.MultipartUpload, error)); ok {
359+ return returnFunc(ctx, key)
360+ }
361+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.MultipartUpload); ok {
362+ r0 = returnFunc(ctx, key)
363+ } else {
364+ if ret.Get(0) != nil {
365+ r0 = ret.Get(0).(ports.MultipartUpload)
366+ }
367+ }
368+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
369+ r1 = returnFunc(ctx, key)
370+ } else {
371+ r1 = ret.Error(1)
372+ }
373+ return r0, r1
374+}
375+
376+// BlobStore_NewMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewMultipart'
377+type BlobStore_NewMultipart_Call struct {
378+ *mock.Call
379+}
380+
381+// NewMultipart is a helper method to define mock.On call
382+// - ctx context.Context
383+// - key string
384+func (_e *BlobStore_Expecter) NewMultipart(ctx any, key any) *BlobStore_NewMultipart_Call {
385+ return &BlobStore_NewMultipart_Call{Call: _e.mock.On("NewMultipart", ctx, key)}
386+}
387+
388+func (_c *BlobStore_NewMultipart_Call) Run(run func(ctx context.Context, key string)) *BlobStore_NewMultipart_Call {
389+ _c.Call.Run(func(args mock.Arguments) {
390+ var arg0 context.Context
391+ if args[0] != nil {
392+ arg0 = args[0].(context.Context)
393+ }
394+ var arg1 string
395+ if args[1] != nil {
396+ arg1 = args[1].(string)
397+ }
398+ run(
399+ arg0,
400+ arg1,
401+ )
402+ })
403+ return _c
404+}
405+
406+func (_c *BlobStore_NewMultipart_Call) Return(multipartUpload ports.MultipartUpload, err error) *BlobStore_NewMultipart_Call {
407+ _c.Call.Return(multipartUpload, err)
408+ return _c
409+}
410+
411+func (_c *BlobStore_NewMultipart_Call) RunAndReturn(run func(ctx context.Context, key string) (ports.MultipartUpload, error)) *BlobStore_NewMultipart_Call {
412+ _c.Call.Return(run)
413+ return _c
414+}
415+
416+// Open provides a mock function for the type BlobStore
417+func (_mock *BlobStore) Open(ctx context.Context, key string, offset int64, length int64) (io.ReadCloser, ports.BlobInfo, error) {
418+ ret := _mock.Called(ctx, key, offset, length)
419+
420+ if len(ret) == 0 {
421+ panic("no return value specified for Open")
422+ }
423+
424+ var r0 io.ReadCloser
425+ var r1 ports.BlobInfo
426+ var r2 error
427+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int64, int64) (io.ReadCloser, ports.BlobInfo, error)); ok {
428+ return returnFunc(ctx, key, offset, length)
429+ }
430+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int64, int64) io.ReadCloser); ok {
431+ r0 = returnFunc(ctx, key, offset, length)
432+ } else {
433+ if ret.Get(0) != nil {
434+ r0 = ret.Get(0).(io.ReadCloser)
435+ }
436+ }
437+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int64, int64) ports.BlobInfo); ok {
438+ r1 = returnFunc(ctx, key, offset, length)
439+ } else {
440+ r1 = ret.Get(1).(ports.BlobInfo)
441+ }
442+ if returnFunc, ok := ret.Get(2).(func(context.Context, string, int64, int64) error); ok {
443+ r2 = returnFunc(ctx, key, offset, length)
444+ } else {
445+ r2 = ret.Error(2)
446+ }
447+ return r0, r1, r2
448+}
449+
450+// BlobStore_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open'
451+type BlobStore_Open_Call struct {
452+ *mock.Call
453+}
454+
455+// Open is a helper method to define mock.On call
456+// - ctx context.Context
457+// - key string
458+// - offset int64
459+// - length int64
460+func (_e *BlobStore_Expecter) Open(ctx any, key any, offset any, length any) *BlobStore_Open_Call {
461+ return &BlobStore_Open_Call{Call: _e.mock.On("Open", ctx, key, offset, length)}
462+}
463+
464+func (_c *BlobStore_Open_Call) Run(run func(ctx context.Context, key string, offset int64, length int64)) *BlobStore_Open_Call {
465+ _c.Call.Run(func(args mock.Arguments) {
466+ var arg0 context.Context
467+ if args[0] != nil {
468+ arg0 = args[0].(context.Context)
469+ }
470+ var arg1 string
471+ if args[1] != nil {
472+ arg1 = args[1].(string)
473+ }
474+ var arg2 int64
475+ if args[2] != nil {
476+ arg2 = args[2].(int64)
477+ }
478+ var arg3 int64
479+ if args[3] != nil {
480+ arg3 = args[3].(int64)
481+ }
482+ run(
483+ arg0,
484+ arg1,
485+ arg2,
486+ arg3,
487+ )
488+ })
489+ return _c
490+}
491+
492+func (_c *BlobStore_Open_Call) Return(readCloser io.ReadCloser, blobInfo ports.BlobInfo, err error) *BlobStore_Open_Call {
493+ _c.Call.Return(readCloser, blobInfo, err)
494+ return _c
495+}
496+
497+func (_c *BlobStore_Open_Call) RunAndReturn(run func(ctx context.Context, key string, offset int64, length int64) (io.ReadCloser, ports.BlobInfo, error)) *BlobStore_Open_Call {
498+ _c.Call.Return(run)
499+ return _c
500+}
501+
502+// Put provides a mock function for the type BlobStore
503+func (_mock *BlobStore) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
504+ ret := _mock.Called(ctx, key, r, size)
505+
506+ if len(ret) == 0 {
507+ panic("no return value specified for Put")
508+ }
509+
510+ var r0 ports.BlobInfo
511+ var r1 error
512+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader, int64) (ports.BlobInfo, error)); ok {
513+ return returnFunc(ctx, key, r, size)
514+ }
515+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader, int64) ports.BlobInfo); ok {
516+ r0 = returnFunc(ctx, key, r, size)
517+ } else {
518+ r0 = ret.Get(0).(ports.BlobInfo)
519+ }
520+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, io.Reader, int64) error); ok {
521+ r1 = returnFunc(ctx, key, r, size)
522+ } else {
523+ r1 = ret.Error(1)
524+ }
525+ return r0, r1
526+}
527+
528+// BlobStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put'
529+type BlobStore_Put_Call struct {
530+ *mock.Call
531+}
532+
533+// Put is a helper method to define mock.On call
534+// - ctx context.Context
535+// - key string
536+// - r io.Reader
537+// - size int64
538+func (_e *BlobStore_Expecter) Put(ctx any, key any, r any, size any) *BlobStore_Put_Call {
539+ return &BlobStore_Put_Call{Call: _e.mock.On("Put", ctx, key, r, size)}
540+}
541+
542+func (_c *BlobStore_Put_Call) Run(run func(ctx context.Context, key string, r io.Reader, size int64)) *BlobStore_Put_Call {
543+ _c.Call.Run(func(args mock.Arguments) {
544+ var arg0 context.Context
545+ if args[0] != nil {
546+ arg0 = args[0].(context.Context)
547+ }
548+ var arg1 string
549+ if args[1] != nil {
550+ arg1 = args[1].(string)
551+ }
552+ var arg2 io.Reader
553+ if args[2] != nil {
554+ arg2 = args[2].(io.Reader)
555+ }
556+ var arg3 int64
557+ if args[3] != nil {
558+ arg3 = args[3].(int64)
559+ }
560+ run(
561+ arg0,
562+ arg1,
563+ arg2,
564+ arg3,
565+ )
566+ })
567+ return _c
568+}
569+
570+func (_c *BlobStore_Put_Call) Return(blobInfo ports.BlobInfo, err error) *BlobStore_Put_Call {
571+ _c.Call.Return(blobInfo, err)
572+ return _c
573+}
574+
575+func (_c *BlobStore_Put_Call) RunAndReturn(run func(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error)) *BlobStore_Put_Call {
576+ _c.Call.Return(run)
577+ return _c
578+}
579+
580+// Stat provides a mock function for the type BlobStore
581+func (_mock *BlobStore) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
582+ ret := _mock.Called(ctx, key)
583+
584+ if len(ret) == 0 {
585+ panic("no return value specified for Stat")
586+ }
587+
588+ var r0 ports.BlobInfo
589+ var r1 error
590+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.BlobInfo, error)); ok {
591+ return returnFunc(ctx, key)
592+ }
593+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.BlobInfo); ok {
594+ r0 = returnFunc(ctx, key)
595+ } else {
596+ r0 = ret.Get(0).(ports.BlobInfo)
597+ }
598+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
599+ r1 = returnFunc(ctx, key)
600+ } else {
601+ r1 = ret.Error(1)
602+ }
603+ return r0, r1
604+}
605+
606+// BlobStore_Stat_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stat'
607+type BlobStore_Stat_Call struct {
608+ *mock.Call
609+}
610+
611+// Stat is a helper method to define mock.On call
612+// - ctx context.Context
613+// - key string
614+func (_e *BlobStore_Expecter) Stat(ctx any, key any) *BlobStore_Stat_Call {
615+ return &BlobStore_Stat_Call{Call: _e.mock.On("Stat", ctx, key)}
616+}
617+
618+func (_c *BlobStore_Stat_Call) Run(run func(ctx context.Context, key string)) *BlobStore_Stat_Call {
619+ _c.Call.Run(func(args mock.Arguments) {
620+ var arg0 context.Context
621+ if args[0] != nil {
622+ arg0 = args[0].(context.Context)
623+ }
624+ var arg1 string
625+ if args[1] != nil {
626+ arg1 = args[1].(string)
627+ }
628+ run(
629+ arg0,
630+ arg1,
631+ )
632+ })
633+ return _c
634+}
635+
636+func (_c *BlobStore_Stat_Call) Return(blobInfo ports.BlobInfo, err error) *BlobStore_Stat_Call {
637+ _c.Call.Return(blobInfo, err)
638+ return _c
639+}
640+
641+func (_c *BlobStore_Stat_Call) RunAndReturn(run func(ctx context.Context, key string) (ports.BlobInfo, error)) *BlobStore_Stat_Call {
642+ _c.Call.Return(run)
643+ return _c
644+}
@@ -0,0 +1,644 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewBlobStore creates a new instance of BlobStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewBlobStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *BlobStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &BlobStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// BlobStore is an autogenerated mock type for the BlobStore type
39+type BlobStore struct {
40+ mock.Mock
41+}
42+
43+type BlobStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *BlobStore) EXPECT() *BlobStore_Expecter {
48+ return &BlobStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// AbortMultipart provides a mock function for the type BlobStore
52+func (_mock *BlobStore) AbortMultipart(ctx context.Context, key string, id string) error {
53+ ret := _mock.Called(ctx, key, id)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for AbortMultipart")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
61+ r0 = returnFunc(ctx, key, id)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// BlobStore_AbortMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AbortMultipart'
69+type BlobStore_AbortMultipart_Call struct {
70+ *mock.Call
71+}
72+
73+// AbortMultipart is a helper method to define mock.On call
74+// - ctx context.Context
75+// - key string
76+// - id string
77+func (_e *BlobStore_Expecter) AbortMultipart(ctx any, key any, id any) *BlobStore_AbortMultipart_Call {
78+ return &BlobStore_AbortMultipart_Call{Call: _e.mock.On("AbortMultipart", ctx, key, id)}
79+}
80+
81+func (_c *BlobStore_AbortMultipart_Call) Run(run func(ctx context.Context, key string, id string)) *BlobStore_AbortMultipart_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 string
88+ if args[1] != nil {
89+ arg1 = args[1].(string)
90+ }
91+ var arg2 string
92+ if args[2] != nil {
93+ arg2 = args[2].(string)
94+ }
95+ run(
96+ arg0,
97+ arg1,
98+ arg2,
99+ )
100+ })
101+ return _c
102+}
103+
104+func (_c *BlobStore_AbortMultipart_Call) Return(err error) *BlobStore_AbortMultipart_Call {
105+ _c.Call.Return(err)
106+ return _c
107+}
108+
109+func (_c *BlobStore_AbortMultipart_Call) RunAndReturn(run func(ctx context.Context, key string, id string) error) *BlobStore_AbortMultipart_Call {
110+ _c.Call.Return(run)
111+ return _c
112+}
113+
114+// Check provides a mock function for the type BlobStore
115+func (_mock *BlobStore) Check(ctx context.Context) error {
116+ ret := _mock.Called(ctx)
117+
118+ if len(ret) == 0 {
119+ panic("no return value specified for Check")
120+ }
121+
122+ var r0 error
123+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
124+ r0 = returnFunc(ctx)
125+ } else {
126+ r0 = ret.Error(0)
127+ }
128+ return r0
129+}
130+
131+// BlobStore_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check'
132+type BlobStore_Check_Call struct {
133+ *mock.Call
134+}
135+
136+// Check is a helper method to define mock.On call
137+// - ctx context.Context
138+func (_e *BlobStore_Expecter) Check(ctx any) *BlobStore_Check_Call {
139+ return &BlobStore_Check_Call{Call: _e.mock.On("Check", ctx)}
140+}
141+
142+func (_c *BlobStore_Check_Call) Run(run func(ctx context.Context)) *BlobStore_Check_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ var arg0 context.Context
145+ if args[0] != nil {
146+ arg0 = args[0].(context.Context)
147+ }
148+ run(
149+ arg0,
150+ )
151+ })
152+ return _c
153+}
154+
155+func (_c *BlobStore_Check_Call) Return(err error) *BlobStore_Check_Call {
156+ _c.Call.Return(err)
157+ return _c
158+}
159+
160+func (_c *BlobStore_Check_Call) RunAndReturn(run func(ctx context.Context) error) *BlobStore_Check_Call {
161+ _c.Call.Return(run)
162+ return _c
163+}
164+
165+// Delete provides a mock function for the type BlobStore
166+func (_mock *BlobStore) Delete(ctx context.Context, key string) error {
167+ ret := _mock.Called(ctx, key)
168+
169+ if len(ret) == 0 {
170+ panic("no return value specified for Delete")
171+ }
172+
173+ var r0 error
174+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
175+ r0 = returnFunc(ctx, key)
176+ } else {
177+ r0 = ret.Error(0)
178+ }
179+ return r0
180+}
181+
182+// BlobStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
183+type BlobStore_Delete_Call struct {
184+ *mock.Call
185+}
186+
187+// Delete is a helper method to define mock.On call
188+// - ctx context.Context
189+// - key string
190+func (_e *BlobStore_Expecter) Delete(ctx any, key any) *BlobStore_Delete_Call {
191+ return &BlobStore_Delete_Call{Call: _e.mock.On("Delete", ctx, key)}
192+}
193+
194+func (_c *BlobStore_Delete_Call) Run(run func(ctx context.Context, key string)) *BlobStore_Delete_Call {
195+ _c.Call.Run(func(args mock.Arguments) {
196+ var arg0 context.Context
197+ if args[0] != nil {
198+ arg0 = args[0].(context.Context)
199+ }
200+ var arg1 string
201+ if args[1] != nil {
202+ arg1 = args[1].(string)
203+ }
204+ run(
205+ arg0,
206+ arg1,
207+ )
208+ })
209+ return _c
210+}
211+
212+func (_c *BlobStore_Delete_Call) Return(err error) *BlobStore_Delete_Call {
213+ _c.Call.Return(err)
214+ return _c
215+}
216+
217+func (_c *BlobStore_Delete_Call) RunAndReturn(run func(ctx context.Context, key string) error) *BlobStore_Delete_Call {
218+ _c.Call.Return(run)
219+ return _c
220+}
221+
222+// List provides a mock function for the type BlobStore
223+func (_mock *BlobStore) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error {
224+ ret := _mock.Called(ctx, prefix, fn)
225+
226+ if len(ret) == 0 {
227+ panic("no return value specified for List")
228+ }
229+
230+ var r0 error
231+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, func(ports.BlobInfo) error) error); ok {
232+ r0 = returnFunc(ctx, prefix, fn)
233+ } else {
234+ r0 = ret.Error(0)
235+ }
236+ return r0
237+}
238+
239+// BlobStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
240+type BlobStore_List_Call struct {
241+ *mock.Call
242+}
243+
244+// List is a helper method to define mock.On call
245+// - ctx context.Context
246+// - prefix string
247+// - fn func(ports.BlobInfo) error
248+func (_e *BlobStore_Expecter) List(ctx any, prefix any, fn any) *BlobStore_List_Call {
249+ return &BlobStore_List_Call{Call: _e.mock.On("List", ctx, prefix, fn)}
250+}
251+
252+func (_c *BlobStore_List_Call) Run(run func(ctx context.Context, prefix string, fn func(ports.BlobInfo) error)) *BlobStore_List_Call {
253+ _c.Call.Run(func(args mock.Arguments) {
254+ var arg0 context.Context
255+ if args[0] != nil {
256+ arg0 = args[0].(context.Context)
257+ }
258+ var arg1 string
259+ if args[1] != nil {
260+ arg1 = args[1].(string)
261+ }
262+ var arg2 func(ports.BlobInfo) error
263+ if args[2] != nil {
264+ arg2 = args[2].(func(ports.BlobInfo) error)
265+ }
266+ run(
267+ arg0,
268+ arg1,
269+ arg2,
270+ )
271+ })
272+ return _c
273+}
274+
275+func (_c *BlobStore_List_Call) Return(err error) *BlobStore_List_Call {
276+ _c.Call.Return(err)
277+ return _c
278+}
279+
280+func (_c *BlobStore_List_Call) RunAndReturn(run func(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error) *BlobStore_List_Call {
281+ _c.Call.Return(run)
282+ return _c
283+}
284+
285+// ListMultipart provides a mock function for the type BlobStore
286+func (_mock *BlobStore) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
287+ ret := _mock.Called(ctx, prefix, fn)
288+
289+ if len(ret) == 0 {
290+ panic("no return value specified for ListMultipart")
291+ }
292+
293+ var r0 error
294+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, func(ports.MultipartInfo) error) error); ok {
295+ r0 = returnFunc(ctx, prefix, fn)
296+ } else {
297+ r0 = ret.Error(0)
298+ }
299+ return r0
300+}
301+
302+// BlobStore_ListMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListMultipart'
303+type BlobStore_ListMultipart_Call struct {
304+ *mock.Call
305+}
306+
307+// ListMultipart is a helper method to define mock.On call
308+// - ctx context.Context
309+// - prefix string
310+// - fn func(ports.MultipartInfo) error
311+func (_e *BlobStore_Expecter) ListMultipart(ctx any, prefix any, fn any) *BlobStore_ListMultipart_Call {
312+ return &BlobStore_ListMultipart_Call{Call: _e.mock.On("ListMultipart", ctx, prefix, fn)}
313+}
314+
315+func (_c *BlobStore_ListMultipart_Call) Run(run func(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error)) *BlobStore_ListMultipart_Call {
316+ _c.Call.Run(func(args mock.Arguments) {
317+ var arg0 context.Context
318+ if args[0] != nil {
319+ arg0 = args[0].(context.Context)
320+ }
321+ var arg1 string
322+ if args[1] != nil {
323+ arg1 = args[1].(string)
324+ }
325+ var arg2 func(ports.MultipartInfo) error
326+ if args[2] != nil {
327+ arg2 = args[2].(func(ports.MultipartInfo) error)
328+ }
329+ run(
330+ arg0,
331+ arg1,
332+ arg2,
333+ )
334+ })
335+ return _c
336+}
337+
338+func (_c *BlobStore_ListMultipart_Call) Return(err error) *BlobStore_ListMultipart_Call {
339+ _c.Call.Return(err)
340+ return _c
341+}
342+
343+func (_c *BlobStore_ListMultipart_Call) RunAndReturn(run func(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error) *BlobStore_ListMultipart_Call {
344+ _c.Call.Return(run)
345+ return _c
346+}
347+
348+// NewMultipart provides a mock function for the type BlobStore
349+func (_mock *BlobStore) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
350+ ret := _mock.Called(ctx, key)
351+
352+ if len(ret) == 0 {
353+ panic("no return value specified for NewMultipart")
354+ }
355+
356+ var r0 ports.MultipartUpload
357+ var r1 error
358+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.MultipartUpload, error)); ok {
359+ return returnFunc(ctx, key)
360+ }
361+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.MultipartUpload); ok {
362+ r0 = returnFunc(ctx, key)
363+ } else {
364+ if ret.Get(0) != nil {
365+ r0 = ret.Get(0).(ports.MultipartUpload)
366+ }
367+ }
368+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
369+ r1 = returnFunc(ctx, key)
370+ } else {
371+ r1 = ret.Error(1)
372+ }
373+ return r0, r1
374+}
375+
376+// BlobStore_NewMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewMultipart'
377+type BlobStore_NewMultipart_Call struct {
378+ *mock.Call
379+}
380+
381+// NewMultipart is a helper method to define mock.On call
382+// - ctx context.Context
383+// - key string
384+func (_e *BlobStore_Expecter) NewMultipart(ctx any, key any) *BlobStore_NewMultipart_Call {
385+ return &BlobStore_NewMultipart_Call{Call: _e.mock.On("NewMultipart", ctx, key)}
386+}
387+
388+func (_c *BlobStore_NewMultipart_Call) Run(run func(ctx context.Context, key string)) *BlobStore_NewMultipart_Call {
389+ _c.Call.Run(func(args mock.Arguments) {
390+ var arg0 context.Context
391+ if args[0] != nil {
392+ arg0 = args[0].(context.Context)
393+ }
394+ var arg1 string
395+ if args[1] != nil {
396+ arg1 = args[1].(string)
397+ }
398+ run(
399+ arg0,
400+ arg1,
401+ )
402+ })
403+ return _c
404+}
405+
406+func (_c *BlobStore_NewMultipart_Call) Return(multipartUpload ports.MultipartUpload, err error) *BlobStore_NewMultipart_Call {
407+ _c.Call.Return(multipartUpload, err)
408+ return _c
409+}
410+
411+func (_c *BlobStore_NewMultipart_Call) RunAndReturn(run func(ctx context.Context, key string) (ports.MultipartUpload, error)) *BlobStore_NewMultipart_Call {
412+ _c.Call.Return(run)
413+ return _c
414+}
415+
416+// Open provides a mock function for the type BlobStore
417+func (_mock *BlobStore) Open(ctx context.Context, key string, offset int64, length int64) (io.ReadCloser, ports.BlobInfo, error) {
418+ ret := _mock.Called(ctx, key, offset, length)
419+
420+ if len(ret) == 0 {
421+ panic("no return value specified for Open")
422+ }
423+
424+ var r0 io.ReadCloser
425+ var r1 ports.BlobInfo
426+ var r2 error
427+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int64, int64) (io.ReadCloser, ports.BlobInfo, error)); ok {
428+ return returnFunc(ctx, key, offset, length)
429+ }
430+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int64, int64) io.ReadCloser); ok {
431+ r0 = returnFunc(ctx, key, offset, length)
432+ } else {
433+ if ret.Get(0) != nil {
434+ r0 = ret.Get(0).(io.ReadCloser)
435+ }
436+ }
437+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int64, int64) ports.BlobInfo); ok {
438+ r1 = returnFunc(ctx, key, offset, length)
439+ } else {
440+ r1 = ret.Get(1).(ports.BlobInfo)
441+ }
442+ if returnFunc, ok := ret.Get(2).(func(context.Context, string, int64, int64) error); ok {
443+ r2 = returnFunc(ctx, key, offset, length)
444+ } else {
445+ r2 = ret.Error(2)
446+ }
447+ return r0, r1, r2
448+}
449+
450+// BlobStore_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open'
451+type BlobStore_Open_Call struct {
452+ *mock.Call
453+}
454+
455+// Open is a helper method to define mock.On call
456+// - ctx context.Context
457+// - key string
458+// - offset int64
459+// - length int64
460+func (_e *BlobStore_Expecter) Open(ctx any, key any, offset any, length any) *BlobStore_Open_Call {
461+ return &BlobStore_Open_Call{Call: _e.mock.On("Open", ctx, key, offset, length)}
462+}
463+
464+func (_c *BlobStore_Open_Call) Run(run func(ctx context.Context, key string, offset int64, length int64)) *BlobStore_Open_Call {
465+ _c.Call.Run(func(args mock.Arguments) {
466+ var arg0 context.Context
467+ if args[0] != nil {
468+ arg0 = args[0].(context.Context)
469+ }
470+ var arg1 string
471+ if args[1] != nil {
472+ arg1 = args[1].(string)
473+ }
474+ var arg2 int64
475+ if args[2] != nil {
476+ arg2 = args[2].(int64)
477+ }
478+ var arg3 int64
479+ if args[3] != nil {
480+ arg3 = args[3].(int64)
481+ }
482+ run(
483+ arg0,
484+ arg1,
485+ arg2,
486+ arg3,
487+ )
488+ })
489+ return _c
490+}
491+
492+func (_c *BlobStore_Open_Call) Return(readCloser io.ReadCloser, blobInfo ports.BlobInfo, err error) *BlobStore_Open_Call {
493+ _c.Call.Return(readCloser, blobInfo, err)
494+ return _c
495+}
496+
497+func (_c *BlobStore_Open_Call) RunAndReturn(run func(ctx context.Context, key string, offset int64, length int64) (io.ReadCloser, ports.BlobInfo, error)) *BlobStore_Open_Call {
498+ _c.Call.Return(run)
499+ return _c
500+}
501+
502+// Put provides a mock function for the type BlobStore
503+func (_mock *BlobStore) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
504+ ret := _mock.Called(ctx, key, r, size)
505+
506+ if len(ret) == 0 {
507+ panic("no return value specified for Put")
508+ }
509+
510+ var r0 ports.BlobInfo
511+ var r1 error
512+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader, int64) (ports.BlobInfo, error)); ok {
513+ return returnFunc(ctx, key, r, size)
514+ }
515+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader, int64) ports.BlobInfo); ok {
516+ r0 = returnFunc(ctx, key, r, size)
517+ } else {
518+ r0 = ret.Get(0).(ports.BlobInfo)
519+ }
520+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, io.Reader, int64) error); ok {
521+ r1 = returnFunc(ctx, key, r, size)
522+ } else {
523+ r1 = ret.Error(1)
524+ }
525+ return r0, r1
526+}
527+
528+// BlobStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put'
529+type BlobStore_Put_Call struct {
530+ *mock.Call
531+}
532+
533+// Put is a helper method to define mock.On call
534+// - ctx context.Context
535+// - key string
536+// - r io.Reader
537+// - size int64
538+func (_e *BlobStore_Expecter) Put(ctx any, key any, r any, size any) *BlobStore_Put_Call {
539+ return &BlobStore_Put_Call{Call: _e.mock.On("Put", ctx, key, r, size)}
540+}
541+
542+func (_c *BlobStore_Put_Call) Run(run func(ctx context.Context, key string, r io.Reader, size int64)) *BlobStore_Put_Call {
543+ _c.Call.Run(func(args mock.Arguments) {
544+ var arg0 context.Context
545+ if args[0] != nil {
546+ arg0 = args[0].(context.Context)
547+ }
548+ var arg1 string
549+ if args[1] != nil {
550+ arg1 = args[1].(string)
551+ }
552+ var arg2 io.Reader
553+ if args[2] != nil {
554+ arg2 = args[2].(io.Reader)
555+ }
556+ var arg3 int64
557+ if args[3] != nil {
558+ arg3 = args[3].(int64)
559+ }
560+ run(
561+ arg0,
562+ arg1,
563+ arg2,
564+ arg3,
565+ )
566+ })
567+ return _c
568+}
569+
570+func (_c *BlobStore_Put_Call) Return(blobInfo ports.BlobInfo, err error) *BlobStore_Put_Call {
571+ _c.Call.Return(blobInfo, err)
572+ return _c
573+}
574+
575+func (_c *BlobStore_Put_Call) RunAndReturn(run func(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error)) *BlobStore_Put_Call {
576+ _c.Call.Return(run)
577+ return _c
578+}
579+
580+// Stat provides a mock function for the type BlobStore
581+func (_mock *BlobStore) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
582+ ret := _mock.Called(ctx, key)
583+
584+ if len(ret) == 0 {
585+ panic("no return value specified for Stat")
586+ }
587+
588+ var r0 ports.BlobInfo
589+ var r1 error
590+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.BlobInfo, error)); ok {
591+ return returnFunc(ctx, key)
592+ }
593+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.BlobInfo); ok {
594+ r0 = returnFunc(ctx, key)
595+ } else {
596+ r0 = ret.Get(0).(ports.BlobInfo)
597+ }
598+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
599+ r1 = returnFunc(ctx, key)
600+ } else {
601+ r1 = ret.Error(1)
602+ }
603+ return r0, r1
604+}
605+
606+// BlobStore_Stat_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stat'
607+type BlobStore_Stat_Call struct {
608+ *mock.Call
609+}
610+
611+// Stat is a helper method to define mock.On call
612+// - ctx context.Context
613+// - key string
614+func (_e *BlobStore_Expecter) Stat(ctx any, key any) *BlobStore_Stat_Call {
615+ return &BlobStore_Stat_Call{Call: _e.mock.On("Stat", ctx, key)}
616+}
617+
618+func (_c *BlobStore_Stat_Call) Run(run func(ctx context.Context, key string)) *BlobStore_Stat_Call {
619+ _c.Call.Run(func(args mock.Arguments) {
620+ var arg0 context.Context
621+ if args[0] != nil {
622+ arg0 = args[0].(context.Context)
623+ }
624+ var arg1 string
625+ if args[1] != nil {
626+ arg1 = args[1].(string)
627+ }
628+ run(
629+ arg0,
630+ arg1,
631+ )
632+ })
633+ return _c
634+}
635+
636+func (_c *BlobStore_Stat_Call) Return(blobInfo ports.BlobInfo, err error) *BlobStore_Stat_Call {
637+ _c.Call.Return(blobInfo, err)
638+ return _c
639+}
640+
641+func (_c *BlobStore_Stat_Call) RunAndReturn(run func(ctx context.Context, key string) (ports.BlobInfo, error)) *BlobStore_Stat_Call {
642+ _c.Call.Return(run)
643+ return _c
644+}
A internal/ports/mocks/MultipartUpload.go +273 -0
@@ -0,0 +1,273 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMultipartUpload creates a new instance of MultipartUpload. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMultipartUpload(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MultipartUpload {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &MultipartUpload{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// MultipartUpload is an autogenerated mock type for the MultipartUpload type
39+type MultipartUpload struct {
40+ mock.Mock
41+}
42+
43+type MultipartUpload_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *MultipartUpload) EXPECT() *MultipartUpload_Expecter {
48+ return &MultipartUpload_Expecter{mock: &_m.Mock}
49+}
50+
51+// Abort provides a mock function for the type MultipartUpload
52+func (_mock *MultipartUpload) Abort(ctx context.Context) error {
53+ ret := _mock.Called(ctx)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Abort")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
61+ r0 = returnFunc(ctx)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// MultipartUpload_Abort_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Abort'
69+type MultipartUpload_Abort_Call struct {
70+ *mock.Call
71+}
72+
73+// Abort is a helper method to define mock.On call
74+// - ctx context.Context
75+func (_e *MultipartUpload_Expecter) Abort(ctx any) *MultipartUpload_Abort_Call {
76+ return &MultipartUpload_Abort_Call{Call: _e.mock.On("Abort", ctx)}
77+}
78+
79+func (_c *MultipartUpload_Abort_Call) Run(run func(ctx context.Context)) *MultipartUpload_Abort_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ run(
86+ arg0,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *MultipartUpload_Abort_Call) Return(err error) *MultipartUpload_Abort_Call {
93+ _c.Call.Return(err)
94+ return _c
95+}
96+
97+func (_c *MultipartUpload_Abort_Call) RunAndReturn(run func(ctx context.Context) error) *MultipartUpload_Abort_Call {
98+ _c.Call.Return(run)
99+ return _c
100+}
101+
102+// Complete provides a mock function for the type MultipartUpload
103+func (_mock *MultipartUpload) Complete(ctx context.Context) (ports.BlobInfo, error) {
104+ ret := _mock.Called(ctx)
105+
106+ if len(ret) == 0 {
107+ panic("no return value specified for Complete")
108+ }
109+
110+ var r0 ports.BlobInfo
111+ var r1 error
112+ if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.BlobInfo, error)); ok {
113+ return returnFunc(ctx)
114+ }
115+ if returnFunc, ok := ret.Get(0).(func(context.Context) ports.BlobInfo); ok {
116+ r0 = returnFunc(ctx)
117+ } else {
118+ r0 = ret.Get(0).(ports.BlobInfo)
119+ }
120+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
121+ r1 = returnFunc(ctx)
122+ } else {
123+ r1 = ret.Error(1)
124+ }
125+ return r0, r1
126+}
127+
128+// MultipartUpload_Complete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Complete'
129+type MultipartUpload_Complete_Call struct {
130+ *mock.Call
131+}
132+
133+// Complete is a helper method to define mock.On call
134+// - ctx context.Context
135+func (_e *MultipartUpload_Expecter) Complete(ctx any) *MultipartUpload_Complete_Call {
136+ return &MultipartUpload_Complete_Call{Call: _e.mock.On("Complete", ctx)}
137+}
138+
139+func (_c *MultipartUpload_Complete_Call) Run(run func(ctx context.Context)) *MultipartUpload_Complete_Call {
140+ _c.Call.Run(func(args mock.Arguments) {
141+ var arg0 context.Context
142+ if args[0] != nil {
143+ arg0 = args[0].(context.Context)
144+ }
145+ run(
146+ arg0,
147+ )
148+ })
149+ return _c
150+}
151+
152+func (_c *MultipartUpload_Complete_Call) Return(blobInfo ports.BlobInfo, err error) *MultipartUpload_Complete_Call {
153+ _c.Call.Return(blobInfo, err)
154+ return _c
155+}
156+
157+func (_c *MultipartUpload_Complete_Call) RunAndReturn(run func(ctx context.Context) (ports.BlobInfo, error)) *MultipartUpload_Complete_Call {
158+ _c.Call.Return(run)
159+ return _c
160+}
161+
162+// ID provides a mock function for the type MultipartUpload
163+func (_mock *MultipartUpload) ID() string {
164+ ret := _mock.Called()
165+
166+ if len(ret) == 0 {
167+ panic("no return value specified for ID")
168+ }
169+
170+ var r0 string
171+ if returnFunc, ok := ret.Get(0).(func() string); ok {
172+ r0 = returnFunc()
173+ } else {
174+ r0 = ret.Get(0).(string)
175+ }
176+ return r0
177+}
178+
179+// MultipartUpload_ID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ID'
180+type MultipartUpload_ID_Call struct {
181+ *mock.Call
182+}
183+
184+// ID is a helper method to define mock.On call
185+func (_e *MultipartUpload_Expecter) ID() *MultipartUpload_ID_Call {
186+ return &MultipartUpload_ID_Call{Call: _e.mock.On("ID")}
187+}
188+
189+func (_c *MultipartUpload_ID_Call) Run(run func()) *MultipartUpload_ID_Call {
190+ _c.Call.Run(func(args mock.Arguments) {
191+ run()
192+ })
193+ return _c
194+}
195+
196+func (_c *MultipartUpload_ID_Call) Return(s string) *MultipartUpload_ID_Call {
197+ _c.Call.Return(s)
198+ return _c
199+}
200+
201+func (_c *MultipartUpload_ID_Call) RunAndReturn(run func() string) *MultipartUpload_ID_Call {
202+ _c.Call.Return(run)
203+ return _c
204+}
205+
206+// UploadPart provides a mock function for the type MultipartUpload
207+func (_mock *MultipartUpload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error {
208+ ret := _mock.Called(ctx, n, r, size)
209+
210+ if len(ret) == 0 {
211+ panic("no return value specified for UploadPart")
212+ }
213+
214+ var r0 error
215+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, io.Reader, int64) error); ok {
216+ r0 = returnFunc(ctx, n, r, size)
217+ } else {
218+ r0 = ret.Error(0)
219+ }
220+ return r0
221+}
222+
223+// MultipartUpload_UploadPart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadPart'
224+type MultipartUpload_UploadPart_Call struct {
225+ *mock.Call
226+}
227+
228+// UploadPart is a helper method to define mock.On call
229+// - ctx context.Context
230+// - n int
231+// - r io.Reader
232+// - size int64
233+func (_e *MultipartUpload_Expecter) UploadPart(ctx any, n any, r any, size any) *MultipartUpload_UploadPart_Call {
234+ return &MultipartUpload_UploadPart_Call{Call: _e.mock.On("UploadPart", ctx, n, r, size)}
235+}
236+
237+func (_c *MultipartUpload_UploadPart_Call) Run(run func(ctx context.Context, n int, r io.Reader, size int64)) *MultipartUpload_UploadPart_Call {
238+ _c.Call.Run(func(args mock.Arguments) {
239+ var arg0 context.Context
240+ if args[0] != nil {
241+ arg0 = args[0].(context.Context)
242+ }
243+ var arg1 int
244+ if args[1] != nil {
245+ arg1 = args[1].(int)
246+ }
247+ var arg2 io.Reader
248+ if args[2] != nil {
249+ arg2 = args[2].(io.Reader)
250+ }
251+ var arg3 int64
252+ if args[3] != nil {
253+ arg3 = args[3].(int64)
254+ }
255+ run(
256+ arg0,
257+ arg1,
258+ arg2,
259+ arg3,
260+ )
261+ })
262+ return _c
263+}
264+
265+func (_c *MultipartUpload_UploadPart_Call) Return(err error) *MultipartUpload_UploadPart_Call {
266+ _c.Call.Return(err)
267+ return _c
268+}
269+
270+func (_c *MultipartUpload_UploadPart_Call) RunAndReturn(run func(ctx context.Context, n int, r io.Reader, size int64) error) *MultipartUpload_UploadPart_Call {
271+ _c.Call.Return(run)
272+ return _c
273+}
@@ -0,0 +1,273 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMultipartUpload creates a new instance of MultipartUpload. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMultipartUpload(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MultipartUpload {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &MultipartUpload{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// MultipartUpload is an autogenerated mock type for the MultipartUpload type
39+type MultipartUpload struct {
40+ mock.Mock
41+}
42+
43+type MultipartUpload_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *MultipartUpload) EXPECT() *MultipartUpload_Expecter {
48+ return &MultipartUpload_Expecter{mock: &_m.Mock}
49+}
50+
51+// Abort provides a mock function for the type MultipartUpload
52+func (_mock *MultipartUpload) Abort(ctx context.Context) error {
53+ ret := _mock.Called(ctx)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Abort")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
61+ r0 = returnFunc(ctx)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// MultipartUpload_Abort_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Abort'
69+type MultipartUpload_Abort_Call struct {
70+ *mock.Call
71+}
72+
73+// Abort is a helper method to define mock.On call
74+// - ctx context.Context
75+func (_e *MultipartUpload_Expecter) Abort(ctx any) *MultipartUpload_Abort_Call {
76+ return &MultipartUpload_Abort_Call{Call: _e.mock.On("Abort", ctx)}
77+}
78+
79+func (_c *MultipartUpload_Abort_Call) Run(run func(ctx context.Context)) *MultipartUpload_Abort_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ run(
86+ arg0,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *MultipartUpload_Abort_Call) Return(err error) *MultipartUpload_Abort_Call {
93+ _c.Call.Return(err)
94+ return _c
95+}
96+
97+func (_c *MultipartUpload_Abort_Call) RunAndReturn(run func(ctx context.Context) error) *MultipartUpload_Abort_Call {
98+ _c.Call.Return(run)
99+ return _c
100+}
101+
102+// Complete provides a mock function for the type MultipartUpload
103+func (_mock *MultipartUpload) Complete(ctx context.Context) (ports.BlobInfo, error) {
104+ ret := _mock.Called(ctx)
105+
106+ if len(ret) == 0 {
107+ panic("no return value specified for Complete")
108+ }
109+
110+ var r0 ports.BlobInfo
111+ var r1 error
112+ if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.BlobInfo, error)); ok {
113+ return returnFunc(ctx)
114+ }
115+ if returnFunc, ok := ret.Get(0).(func(context.Context) ports.BlobInfo); ok {
116+ r0 = returnFunc(ctx)
117+ } else {
118+ r0 = ret.Get(0).(ports.BlobInfo)
119+ }
120+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
121+ r1 = returnFunc(ctx)
122+ } else {
123+ r1 = ret.Error(1)
124+ }
125+ return r0, r1
126+}
127+
128+// MultipartUpload_Complete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Complete'
129+type MultipartUpload_Complete_Call struct {
130+ *mock.Call
131+}
132+
133+// Complete is a helper method to define mock.On call
134+// - ctx context.Context
135+func (_e *MultipartUpload_Expecter) Complete(ctx any) *MultipartUpload_Complete_Call {
136+ return &MultipartUpload_Complete_Call{Call: _e.mock.On("Complete", ctx)}
137+}
138+
139+func (_c *MultipartUpload_Complete_Call) Run(run func(ctx context.Context)) *MultipartUpload_Complete_Call {
140+ _c.Call.Run(func(args mock.Arguments) {
141+ var arg0 context.Context
142+ if args[0] != nil {
143+ arg0 = args[0].(context.Context)
144+ }
145+ run(
146+ arg0,
147+ )
148+ })
149+ return _c
150+}
151+
152+func (_c *MultipartUpload_Complete_Call) Return(blobInfo ports.BlobInfo, err error) *MultipartUpload_Complete_Call {
153+ _c.Call.Return(blobInfo, err)
154+ return _c
155+}
156+
157+func (_c *MultipartUpload_Complete_Call) RunAndReturn(run func(ctx context.Context) (ports.BlobInfo, error)) *MultipartUpload_Complete_Call {
158+ _c.Call.Return(run)
159+ return _c
160+}
161+
162+// ID provides a mock function for the type MultipartUpload
163+func (_mock *MultipartUpload) ID() string {
164+ ret := _mock.Called()
165+
166+ if len(ret) == 0 {
167+ panic("no return value specified for ID")
168+ }
169+
170+ var r0 string
171+ if returnFunc, ok := ret.Get(0).(func() string); ok {
172+ r0 = returnFunc()
173+ } else {
174+ r0 = ret.Get(0).(string)
175+ }
176+ return r0
177+}
178+
179+// MultipartUpload_ID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ID'
180+type MultipartUpload_ID_Call struct {
181+ *mock.Call
182+}
183+
184+// ID is a helper method to define mock.On call
185+func (_e *MultipartUpload_Expecter) ID() *MultipartUpload_ID_Call {
186+ return &MultipartUpload_ID_Call{Call: _e.mock.On("ID")}
187+}
188+
189+func (_c *MultipartUpload_ID_Call) Run(run func()) *MultipartUpload_ID_Call {
190+ _c.Call.Run(func(args mock.Arguments) {
191+ run()
192+ })
193+ return _c
194+}
195+
196+func (_c *MultipartUpload_ID_Call) Return(s string) *MultipartUpload_ID_Call {
197+ _c.Call.Return(s)
198+ return _c
199+}
200+
201+func (_c *MultipartUpload_ID_Call) RunAndReturn(run func() string) *MultipartUpload_ID_Call {
202+ _c.Call.Return(run)
203+ return _c
204+}
205+
206+// UploadPart provides a mock function for the type MultipartUpload
207+func (_mock *MultipartUpload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error {
208+ ret := _mock.Called(ctx, n, r, size)
209+
210+ if len(ret) == 0 {
211+ panic("no return value specified for UploadPart")
212+ }
213+
214+ var r0 error
215+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, io.Reader, int64) error); ok {
216+ r0 = returnFunc(ctx, n, r, size)
217+ } else {
218+ r0 = ret.Error(0)
219+ }
220+ return r0
221+}
222+
223+// MultipartUpload_UploadPart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadPart'
224+type MultipartUpload_UploadPart_Call struct {
225+ *mock.Call
226+}
227+
228+// UploadPart is a helper method to define mock.On call
229+// - ctx context.Context
230+// - n int
231+// - r io.Reader
232+// - size int64
233+func (_e *MultipartUpload_Expecter) UploadPart(ctx any, n any, r any, size any) *MultipartUpload_UploadPart_Call {
234+ return &MultipartUpload_UploadPart_Call{Call: _e.mock.On("UploadPart", ctx, n, r, size)}
235+}
236+
237+func (_c *MultipartUpload_UploadPart_Call) Run(run func(ctx context.Context, n int, r io.Reader, size int64)) *MultipartUpload_UploadPart_Call {
238+ _c.Call.Run(func(args mock.Arguments) {
239+ var arg0 context.Context
240+ if args[0] != nil {
241+ arg0 = args[0].(context.Context)
242+ }
243+ var arg1 int
244+ if args[1] != nil {
245+ arg1 = args[1].(int)
246+ }
247+ var arg2 io.Reader
248+ if args[2] != nil {
249+ arg2 = args[2].(io.Reader)
250+ }
251+ var arg3 int64
252+ if args[3] != nil {
253+ arg3 = args[3].(int64)
254+ }
255+ run(
256+ arg0,
257+ arg1,
258+ arg2,
259+ arg3,
260+ )
261+ })
262+ return _c
263+}
264+
265+func (_c *MultipartUpload_UploadPart_Call) Return(err error) *MultipartUpload_UploadPart_Call {
266+ _c.Call.Return(err)
267+ return _c
268+}
269+
270+func (_c *MultipartUpload_UploadPart_Call) RunAndReturn(run func(ctx context.Context, n int, r io.Reader, size int64) error) *MultipartUpload_UploadPart_Call {
271+ _c.Call.Return(run)
272+ return _c
273+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close