Skip to content

Commit 0849d6c

Browse files
bigbessssciel
authored andcommitted
backup: harden fs/s3 storage backends
Address review findings on the new lib/backup/storage backends: - s3: distinguish a missing key from a missing bucket. isKeyNotFound now matches only NoSuchKey, not any 404 (a missing bucket returns 404 as NoSuchBucket). Get also probes the object with a one-byte read (GET) rather than Stat (HEAD): a GET 404 has a body, so the real code is preserved; a HEAD 404 has none and is reported as NoSuchKey regardless. The probe also drops the extra HEAD request. - fs: Put flushes the data and parent directory to disk before returning, so a crash cannot leave an object present but truncated. - fs: Put widens the object mode from CreateTemp's 0600 to 0644, so a backup written by one user can be read by another. - fs: objectPath uses a relative-path escape check; the old string-prefix check rejected every key when the root resolved to /. - fs: Get rejects a key that resolves to a directory with ErrKeyNotFound instead of a later, opaque read error. - fs: Put enforces the size contract (non-negative, exact byte count) so a wrong size fails loudly instead of storing a short object; the Storage interface now documents the contract for both backends. - fs: New sweeps stale temp files left by interrupted Puts, which are otherwise hidden from List and cannot be removed through the API. - fs: List returns a non-nil empty slice, matching the s3 backend. - s3 integration test: tag integration_docker so unit:fullSkipDocker excludes it, matching the repo convention. Part of TNTP-8210
1 parent 1c0f2b3 commit 0849d6c

5 files changed

Lines changed: 229 additions & 14 deletions

File tree

lib/backup/storage/fs/fs.go

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"path/filepath"
1111
"sort"
1212
"strings"
13+
"time"
1314

1415
"github.com/tarantool/tt/lib/backup/storage"
1516
)
@@ -20,7 +21,14 @@ const tempFilePrefix = ".tt-backup-"
2021
// tempFilePattern is the glob pattern passed to os.CreateTemp for temporary files.
2122
const tempFilePattern = tempFilePrefix + "*"
2223

23-
var errPathRequired = errors.New("fs storage path is required")
24+
// staleTempFileAge is how old a leftover temp file must be before New sweeps it.
25+
// The threshold keeps the sweep from racing a concurrent Put in another process.
26+
const staleTempFileAge = 24 * time.Hour
27+
28+
var (
29+
errPathRequired = errors.New("fs storage path is required")
30+
errNegativeSize = errors.New("fs object size must be non-negative")
31+
)
2432

2533
// Config describes local filesystem storage configuration.
2634
type Config struct {
@@ -58,7 +66,37 @@ func New(cfg Config) (*Storage, error) {
5866
return nil, fmt.Errorf("failed to resolve storage root %q: %w", cfg.Path, err)
5967
}
6068

61-
return &Storage{root: root}, nil
69+
s := &Storage{root: root}
70+
s.sweepStaleTempFiles()
71+
72+
return s, nil
73+
}
74+
75+
// sweepStaleTempFiles best-effort removes temp files left behind by interrupted
76+
// Put calls (process killed between os.CreateTemp and the deferred os.Remove).
77+
// Such files are hidden from List and cannot be Deleted through the API, so they
78+
// would otherwise accumulate forever. Errors are ignored: this is an optimization,
79+
// not a guarantee, and the root may not exist yet.
80+
func (s *Storage) sweepStaleTempFiles() {
81+
cutoff := time.Now().Add(-staleTempFileAge)
82+
_ = filepath.WalkDir(s.root, func(path string, d os.DirEntry, err error) error {
83+
switch {
84+
case err != nil:
85+
return nil
86+
case d.IsDir() || !isTempFile(d.Name()):
87+
return nil
88+
}
89+
90+
info, err := d.Info()
91+
switch {
92+
case err != nil:
93+
return nil
94+
case info.ModTime().Before(cutoff):
95+
_ = os.Remove(path)
96+
}
97+
98+
return nil
99+
})
62100
}
63101

64102
// List returns objects whose keys start with the given prefix, sorted by key.
@@ -74,7 +112,7 @@ func (s *Storage) List(ctx context.Context, prefix string) ([]storage.ObjectInfo
74112

75113
root := filepath.Join(s.root, filepath.FromSlash(path.Dir(cleanPrefix)))
76114

77-
var objects []storage.ObjectInfo
115+
objects := make([]storage.ObjectInfo, 0)
78116
if _, err := os.Stat(root); err != nil {
79117
if errors.Is(err, os.ErrNotExist) {
80118
return objects, nil
@@ -165,12 +203,25 @@ func (s *Storage) Get(ctx context.Context, key string) (io.ReadCloser, error) {
165203
return nil, fmt.Errorf("failed to open object %q: %w", key, err)
166204
}
167205

206+
// A key that resolves to a directory is not a stored object; report it as
207+
// missing here rather than deferring an opaque EISDIR to the caller's Read.
208+
info, err := f.Stat()
209+
switch {
210+
case err != nil:
211+
_ = f.Close()
212+
return nil, fmt.Errorf("failed to stat object %q: %w", key, err)
213+
case !info.Mode().IsRegular():
214+
_ = f.Close()
215+
return nil, storage.ErrKeyNotFound
216+
}
217+
168218
return f, nil
169219
}
170220

171-
// Put stores an object to the filesystem. The size parameter is unused;
172-
// it exists to satisfy the storage.Storage interface.
173-
func (s *Storage) Put(ctx context.Context, key string, r io.Reader, _ int64) error {
221+
// Put stores an object to the filesystem via a temp file and atomic rename.
222+
// size must be the exact, non-negative number of bytes r yields; a mismatch is
223+
// an error so a wrong size cannot silently store a truncated object.
224+
func (s *Storage) Put(ctx context.Context, key string, r io.Reader, size int64) error {
174225
if err := ctx.Err(); err != nil {
175226
return fmt.Errorf("failed to put object %q: %w", key, err)
176227
}
@@ -180,6 +231,10 @@ func (s *Storage) Put(ctx context.Context, key string, r io.Reader, _ int64) err
180231
return fmt.Errorf("failed to resolve object path %q: %w", key, err)
181232
}
182233

234+
if size < 0 {
235+
return fmt.Errorf("failed to put object %q: %w", key, errNegativeSize)
236+
}
237+
183238
dir := filepath.Dir(path)
184239
if err := os.MkdirAll(dir, 0o755); err != nil {
185240
return fmt.Errorf("failed to create object directory for %q: %w", key, err)
@@ -193,9 +248,29 @@ func (s *Storage) Put(ctx context.Context, key string, r io.Reader, _ int64) err
193248
tmpPath := tmp.Name()
194249
defer os.Remove(tmpPath)
195250

196-
if _, err := io.Copy(tmp, ctxReader{ctx, r}); err != nil {
251+
written, err := io.Copy(tmp, ctxReader{ctx, r})
252+
switch {
253+
case err != nil:
197254
_ = tmp.Close()
198255
return fmt.Errorf("failed to write object %q: %w", key, err)
256+
case written != size:
257+
_ = tmp.Close()
258+
return fmt.Errorf("failed to write object %q: wrote %d bytes, expected %d",
259+
key, written, size)
260+
}
261+
262+
// os.CreateTemp makes the file 0600; widen it to match the 0755 directories
263+
// so a backup written by one user can be read back by another.
264+
if err := tmp.Chmod(0o644); err != nil {
265+
_ = tmp.Close()
266+
return fmt.Errorf("failed to set object mode %q: %w", key, err)
267+
}
268+
269+
// Flush the data before the rename so a crash cannot leave the object present
270+
// under its final name but truncated or empty.
271+
if err := tmp.Sync(); err != nil {
272+
_ = tmp.Close()
273+
return fmt.Errorf("failed to sync object %q: %w", key, err)
199274
}
200275

201276
if err := tmp.Close(); err != nil {
@@ -206,6 +281,28 @@ func (s *Storage) Put(ctx context.Context, key string, r io.Reader, _ int64) err
206281
return fmt.Errorf("failed to store object %q: %w", key, err)
207282
}
208283

284+
// Flush the directory so the rename itself survives a crash.
285+
if err := syncDir(dir); err != nil {
286+
return fmt.Errorf("failed to persist object %q: %w", key, err)
287+
}
288+
289+
return nil
290+
}
291+
292+
// syncDir flushes a directory to disk so a rename within it is durable across a crash.
293+
func syncDir(dir string) error {
294+
d, err := os.Open(dir)
295+
if err != nil {
296+
return fmt.Errorf("failed to open directory %q: %w", dir, err)
297+
}
298+
if err := d.Sync(); err != nil {
299+
_ = d.Close()
300+
return fmt.Errorf("failed to sync directory %q: %w", dir, err)
301+
}
302+
if err := d.Close(); err != nil {
303+
return fmt.Errorf("failed to close directory %q: %w", dir, err)
304+
}
305+
209306
return nil
210307
}
211308

@@ -243,7 +340,11 @@ func (s *Storage) objectPath(key string) (string, error) {
243340
}
244341

245342
path := filepath.Join(s.root, filepath.FromSlash(cleanKey))
246-
if path != s.root && !strings.HasPrefix(path, s.root+string(os.PathSeparator)) {
343+
// Use a relative-path check rather than a string prefix: the prefix form
344+
// (s.root + separator) is "//" when the root resolves to "/", which no joined
345+
// path ever has, so it would reject every key under a root of "/".
346+
rel, err := filepath.Rel(s.root, path)
347+
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
247348
return "", fmt.Errorf("storage key %q escapes storage root", cleanKey)
248349
}
249350

lib/backup/storage/fs/fs_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"errors"
77
"io"
8+
"os"
89
"path/filepath"
910
"sync"
1011
"testing"
@@ -74,6 +75,52 @@ func TestGetNotFound(t *testing.T) {
7475
require.True(t, errors.Is(err, storage.ErrKeyNotFound))
7576
}
7677

78+
func TestGetDirectoryKeyNotFound(t *testing.T) {
79+
ctx := t.Context()
80+
s := newTestStorage(t, t.TempDir())
81+
82+
// Store an object so that its parent ("data") exists as a directory, then
83+
// request that directory as a key: it must read as missing, not EISDIR.
84+
require.NoError(t, s.Put(ctx, storage.ArchiveKey("dir", "rs1"),
85+
bytes.NewReader([]byte("archive")), int64(len("archive"))))
86+
87+
_, err := s.Get(ctx, "data")
88+
require.True(t, errors.Is(err, storage.ErrKeyNotFound))
89+
}
90+
91+
func TestPutRejectsNegativeSize(t *testing.T) {
92+
s := newTestStorage(t, t.TempDir())
93+
94+
err := s.Put(t.Context(), storage.ManifestKey("neg"), bytes.NewReader([]byte("x")), -1)
95+
require.True(t, errors.Is(err, errNegativeSize))
96+
}
97+
98+
func TestPutRejectsSizeMismatch(t *testing.T) {
99+
ctx := t.Context()
100+
s := newTestStorage(t, t.TempDir())
101+
102+
key := storage.ManifestKey("mismatch")
103+
err := s.Put(ctx, key, bytes.NewReader([]byte("four")), 100)
104+
require.Error(t, err)
105+
106+
// The failed Put must not leave a partial object behind.
107+
_, err = s.Get(ctx, key)
108+
require.True(t, errors.Is(err, storage.ErrKeyNotFound))
109+
}
110+
111+
func TestPutStoresReadableMode(t *testing.T) {
112+
ctx := t.Context()
113+
root := t.TempDir()
114+
s := newTestStorage(t, root)
115+
116+
key := storage.ManifestKey("mode")
117+
require.NoError(t, s.Put(ctx, key, bytes.NewReader([]byte("x")), 1))
118+
119+
info, err := os.Stat(filepath.Join(root, "cluster", "production", key))
120+
require.NoError(t, err)
121+
require.Equal(t, os.FileMode(0o644), info.Mode().Perm())
122+
}
123+
77124
func TestList(t *testing.T) {
78125
ctx := t.Context()
79126
s := newTestStorage(t, t.TempDir())

lib/backup/storage/s3/s3.go

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
package s3
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"fmt"
78
"io"
8-
"net/http"
99
"sort"
1010
"strings"
1111

@@ -120,7 +120,15 @@ func (s *Storage) Get(ctx context.Context, key string) (io.ReadCloser, error) {
120120
return nil, fmt.Errorf("failed to get s3 object %q: %w", cleanKey, err)
121121
}
122122

123-
if _, err := object.Stat(); err != nil {
123+
// GetObject is lazy: the request is issued on the first read. Force it here
124+
// with a one-byte probe so a missing object is detected up front, and use a
125+
// read (GET) rather than object.Stat (HEAD): a GET 404 carries a body, so
126+
// minio-go reports NoSuchKey vs NoSuchBucket accurately, whereas a bodyless
127+
// HEAD 404 is synthesized as NoSuchKey and would hide a missing bucket. The
128+
// probed byte is replayed to the caller so no data is lost.
129+
var probe [1]byte
130+
n, err := object.Read(probe[:])
131+
if err != nil && err != io.EOF {
124132
_ = object.Close()
125133
if isKeyNotFound(err) {
126134
return nil, storage.ErrKeyNotFound
@@ -129,7 +137,27 @@ func (s *Storage) Get(ctx context.Context, key string) (io.ReadCloser, error) {
129137
return nil, fmt.Errorf("failed to get s3 object %q: %w", cleanKey, err)
130138
}
131139

132-
return object, nil
140+
return &objectReadCloser{
141+
Reader: io.MultiReader(bytes.NewReader(probe[:n]), object),
142+
closer: object,
143+
}, nil
144+
}
145+
146+
// objectReadCloser rejoins the probed first byte with the rest of the object
147+
// stream (via the embedded Reader) while delegating Close to the underlying
148+
// object. Read is promoted from the embedded Reader so that io.EOF is passed
149+
// through unwrapped.
150+
type objectReadCloser struct {
151+
io.Reader
152+
closer io.Closer
153+
}
154+
155+
func (o *objectReadCloser) Close() error {
156+
if err := o.closer.Close(); err != nil {
157+
return fmt.Errorf("failed to close s3 object: %w", err)
158+
}
159+
160+
return nil
133161
}
134162

135163
// Put uploads the object; size must be non-negative and is passed through to minio-go.
@@ -196,7 +224,13 @@ func validateConfig(cfg Config) error {
196224
}
197225

198226
// isKeyNotFound reports whether err means the object key does not exist.
227+
//
228+
// It matches only the NoSuchKey code, never a bare 404: a missing bucket also
229+
// returns 404 (as NoSuchBucket), and treating that as "key not found" would make
230+
// Get report "not found" and Delete report success against a misconfigured bucket.
231+
// Callers must pass errors from body-bearing requests (GET, DELETE), where minio-go
232+
// decodes the real code; a HEAD 404 has no body and is synthesized as NoSuchKey
233+
// regardless of the actual cause, so Get probes with a read rather than a Stat.
199234
func isKeyNotFound(err error) bool {
200-
resp := minio.ToErrorResponse(err)
201-
return resp.Code == "NoSuchKey" || resp.StatusCode == http.StatusNotFound
235+
return minio.ToErrorResponse(err).Code == "NoSuchKey"
202236
}

lib/backup/storage/s3/s3_integration_test.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//go:build integration
1+
//go:build integration_docker
22

33
package s3
44

@@ -82,6 +82,27 @@ func TestGarageDelete(t *testing.T) {
8282
require.True(t, errors.Is(err, storage.ErrKeyNotFound))
8383
}
8484

85+
func TestGarageGetMissingBucketIsNotKeyNotFound(t *testing.T) {
86+
ctx := t.Context()
87+
testcontainers.SkipIfProviderIsNotHealthy(t)
88+
89+
garage := startGarage(ctx, t)
90+
st, err := New(Config{
91+
Endpoint: garage.endpoint,
92+
Bucket: "no-such-bucket",
93+
Region: garage.region,
94+
AccessKeyID: garage.accessKey,
95+
SecretAccessKey: garage.secretKey,
96+
})
97+
require.NoError(t, err)
98+
99+
// A missing bucket must surface as a real error, not be flattened into
100+
// ErrKeyNotFound the way a bodyless HEAD 404 would be.
101+
_, err = st.Get(ctx, storage.ManifestKey("whatever"))
102+
require.Error(t, err)
103+
require.False(t, errors.Is(err, storage.ErrKeyNotFound))
104+
}
105+
85106
type garageInstance struct {
86107
endpoint string
87108
bucket string

lib/backup/storage/storage.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,22 @@ import (
1111
)
1212

1313
// Storage describes a backup object storage backend.
14+
//
15+
// Keys are canonical slash-separated paths (see CleanKey). Implementations must
16+
// behave uniformly: Get reports a missing object via ErrKeyNotFound, List reports
17+
// it via an empty (non-nil) result, Delete is idempotent (a missing key is not an
18+
// error), List results are sorted by key, and every method honors ctx cancellation.
1419
type Storage interface {
20+
// List returns objects whose key starts with prefix, sorted by key.
1521
List(ctx context.Context, prefix string) ([]ObjectInfo, error)
22+
// Get opens the object for reading, returning ErrKeyNotFound if it is absent.
1623
Get(ctx context.Context, key string) (io.ReadCloser, error)
24+
// Put stores the bytes read from r under key. size must be the exact,
25+
// non-negative number of bytes r yields; implementations reject a negative
26+
// size or a byte-count mismatch. Streaming with an unknown length is not
27+
// supported.
1728
Put(ctx context.Context, key string, r io.Reader, size int64) error
29+
// Delete removes the object; a missing object is not an error.
1830
Delete(ctx context.Context, key string) error
1931
}
2032

0 commit comments

Comments
 (0)