@@ -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.
2122const 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.
2634type 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
0 commit comments