Skip to content

Commit ede7712

Browse files
committed
fix(content/oci): write the metadata files atomically
`index.json` and `oci-layout` are written with `os.WriteFile`, which opens the file with `O_TRUNC`. The truncation succeeds even when the file system is full, so a write that fails part-way replaces a good file with an empty one, or with only the part of the content that was written before the failure. A store whose `index.json` has been emptied this way fails to open with "failed to decode index file: EOF" for as long as the file is there, and nothing rebuilds it, so a transient disk-full condition becomes permanent. Write both files to a temporary file in the same directory and rename that file over the target, which is what the package already does for blobs in `content/oci/storage.go`, and what `registry/remote/config` does for the configuration file. The content is flushed before the rename: with delayed allocation a write can be accepted against space that is never allocated, and the resulting failure is reported neither by the write nor by `Close`, so a rename without a flush can still publish content that never reached the disk. The directory entry is flushed afterwards on a best-effort basis, since by then the rename has taken effect and reporting a failure would describe an operation that did happen. Treat a zero-length `index.json` or `oci-layout` as a missing one and rewrite it. The store already recovers from a missing index, and an empty file carries no information and describes the same state. The recovery is deliberately limited to zero-length files: one that is malformed but not empty still fails, since discarding it would silently lose the tags of the store and hide a problem that is not a partial write. Recovering the index does lose the tags it held, which is documented on New. The permission that `os.WriteFile` produced is preserved. A file being created is created with 0666, so the umask applies to it as before, and the permission of an existing file is restored onto the temporary file before the rename. Replacing a file rather than writing through it differs from `os.WriteFile` in ways that are documented on the function. Signed-off-by: Chris Wedgwood <cw@f00f.org>
1 parent dec8fa8 commit ede7712

6 files changed

Lines changed: 680 additions & 19 deletions

File tree

content/oci/atomicwrite.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*
2+
Copyright The ORAS Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
*/
15+
16+
package oci
17+
18+
import (
19+
"crypto/rand"
20+
"errors"
21+
"fmt"
22+
"io/fs"
23+
"os"
24+
"path/filepath"
25+
"strings"
26+
)
27+
28+
// tempFileAttempts is the number of names tried when creating a temporary
29+
// file. A name carries the 128 bits of randomness of rand.Text, so a single
30+
// collision is already unlikely; retrying is only a guard against a caller
31+
// that has filled the directory with matching names.
32+
const tempFileAttempts = 10
33+
34+
// tempFileSuffixMinLen is the minimum length of the random part of the name of
35+
// a temporary file. rand.Text returns 26 characters today and is documented to
36+
// possibly return more in a future release, so the recognizer accepts any name
37+
// at least this long: a store written by an older binary must stay
38+
// recognizable to a newer one.
39+
const tempFileSuffixMinLen = 26
40+
41+
// fileWrite is the function used to write to a file, overridable in tests.
42+
var fileWrite = (*os.File).Write
43+
44+
// createTempFile creates a new temporary file in dir whose name is base joined
45+
// with a random string, in the same manner as the ingest files created by
46+
// Storage.
47+
//
48+
// Unlike os.CreateTemp, which always creates the file with permission 0600,
49+
// the file is created with the permission bits perm, so that the process umask
50+
// is applied to it in exactly the same way as it would be by os.WriteFile.
51+
func createTempFile(dir, base string, perm os.FileMode) (*os.File, error) {
52+
for range tempFileAttempts {
53+
name := filepath.Join(dir, base+"_"+rand.Text())
54+
file, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
55+
if err == nil {
56+
return file, nil
57+
}
58+
if !errors.Is(err, fs.ErrExist) {
59+
return nil, fmt.Errorf("failed to create temporary file: %w", err)
60+
}
61+
}
62+
return nil, fmt.Errorf("failed to create temporary file in %s: %w", dir, fs.ErrExist)
63+
}
64+
65+
// writeFileAtomic writes data to the file named by path, by writing it to a
66+
// temporary file in the same directory and renaming that file over path.
67+
//
68+
// Unlike os.WriteFile, which truncates the file before writing it, a failed
69+
// write leaves any existing file untouched: the truncation performed by
70+
// os.WriteFile succeeds even when the file system is full, which leaves the
71+
// file empty, or holding only the part of the content that was written before
72+
// the failure.
73+
//
74+
// The content is flushed to stable storage before the file is renamed, so that
75+
// a write failure cannot make path visible with content that was never
76+
// written. The containing directory is flushed afterwards, on a best-effort
77+
// basis, so that the rename is not lost on a crash.
78+
//
79+
// The file is replaced rather than written through, which differs from
80+
// os.WriteFile in ways that matter only outside the layout of a content store.
81+
// path is replaced even if it is a symbolic link, a hard link, or not writable
82+
// by the caller; the mode bits beyond the permission bits, and the ownership,
83+
// access control lists and extended attributes of the file, are not carried
84+
// over; and on Windows the rename fails while any other process holds the file
85+
// open, where a write in place would have succeeded.
86+
func writeFileAtomic(path string, data []byte) (writeErr error) {
87+
// os.WriteFile applies its permission argument only when it creates the
88+
// file, leaving the permission of an existing file unchanged. Reproduce
89+
// both behaviors: a file that is being created is created with 0666, so
90+
// that the umask is applied to it in the same way, and the permission of
91+
// an existing file is restored onto the temporary file before the rename.
92+
var perm os.FileMode
93+
var replacing bool
94+
if fi, err := os.Stat(path); err == nil {
95+
perm, replacing = fi.Mode().Perm(), true
96+
} else if !errors.Is(err, fs.ErrNotExist) {
97+
return fmt.Errorf("failed to stat %s: %w", path, err)
98+
}
99+
100+
dir := filepath.Dir(path)
101+
// the temporary file is created no wider than the file it replaces, so
102+
// that it is never briefly more permissive than the target, and its
103+
// permission is then set exactly, since the umask only clears bits.
104+
createPerm := os.FileMode(0666)
105+
if replacing {
106+
createPerm = perm
107+
}
108+
tempFile, err := createTempFile(dir, filepath.Base(path), createPerm)
109+
if err != nil {
110+
return err
111+
}
112+
tempPath := tempFile.Name()
113+
defer func() {
114+
// remove the temporary file in case of error
115+
if writeErr != nil {
116+
tempFile.Close()
117+
os.Remove(tempPath)
118+
}
119+
}()
120+
121+
if replacing {
122+
if err := tempFile.Chmod(perm); err != nil {
123+
return fmt.Errorf("failed to set permission of temporary file: %w", err)
124+
}
125+
}
126+
if _, err := fileWrite(tempFile, data); err != nil {
127+
return fmt.Errorf("failed to write temporary file: %w", err)
128+
}
129+
// flush the content before closing the file. With delayed allocation, a
130+
// write can be accepted against space that is never allocated, and the
131+
// resulting failure is reported neither by Write nor by Close.
132+
if err := tempFile.Sync(); err != nil {
133+
return fmt.Errorf("failed to sync temporary file: %w", err)
134+
}
135+
if err := tempFile.Close(); err != nil {
136+
return fmt.Errorf("failed to close temporary file: %w", err)
137+
}
138+
139+
if err := os.Rename(tempPath, path); err != nil {
140+
return fmt.Errorf("failed to rename temporary file: %w", err)
141+
}
142+
// The rename has already made the content visible, so the durability of
143+
// the directory entry is all that is left to gain here. Reporting a
144+
// failure would describe an operation that did take effect.
145+
syncDir(dir)
146+
return nil
147+
}
148+
149+
// isTempFileOf reports whether name is the name of a temporary file created by
150+
// createTempFile for the file named base.
151+
//
152+
// The random part of the name is matched against the alphabet and the minimum
153+
// length of rand.Text, so that a file that merely shares the prefix, such as a
154+
// copy of index.json that someone has kept, is not mistaken for one of ours.
155+
func isTempFileOf(name, base string) bool {
156+
suffix, ok := strings.CutPrefix(name, base+"_")
157+
if !ok || len(suffix) < tempFileSuffixMinLen {
158+
return false
159+
}
160+
// rand.Text returns the RFC 4648 base32 alphabet without padding.
161+
return !strings.ContainsFunc(suffix, func(r rune) bool {
162+
return (r < 'A' || r > 'Z') && (r < '2' || r > '7')
163+
})
164+
}

content/oci/dirsync_other.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//go:build !windows
2+
3+
/*
4+
Copyright The ORAS Authors.
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package oci
19+
20+
import "os"
21+
22+
// syncDir flushes the directory entries of dir to stable storage, so that a
23+
// file that has been renamed within dir is not lost on a crash.
24+
//
25+
// The operation is best-effort and reports nothing: its callers reach it only
26+
// once a rename has already taken effect, so a failure here describes an
27+
// operation that did happen and that cannot be undone. Directory
28+
// synchronization is also unsupported by some file systems, which report it in
29+
// ways that vary between them.
30+
func syncDir(dir string) {
31+
dirFile, err := os.Open(dir)
32+
if err != nil {
33+
return
34+
}
35+
defer dirFile.Close()
36+
_ = dirFile.Sync()
37+
}

content/oci/dirsync_windows.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
Copyright The ORAS Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
*/
15+
16+
package oci
17+
18+
// syncDir does nothing on Windows, where flushing a directory handle is not
19+
// supported. The rename that its callers perform still replaces the target in
20+
// a single operation there, although the Go API does not promise the same
21+
// atomicity that it does on Unix; only the additional durability against a
22+
// crash is unavailable.
23+
func syncDir(dir string) {}

content/oci/oci.go

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,19 @@ type Store struct {
8181
}
8282

8383
// New creates a new OCI store with context.Background().
84+
//
85+
// If `index.json` is present but empty, which is what a write interrupted by a
86+
// full file system leaves behind, it is reinitialized rather than reported as
87+
// an error. The blobs of the store are kept, but the tags recorded in the lost
88+
// index are not recoverable, and the blobs they referenced become unreferenced
89+
// and are removed by the next call to GC.
8490
func New(root string) (*Store, error) {
8591
return NewWithContext(context.Background(), root)
8692
}
8793

8894
// NewWithContext creates a new OCI store.
95+
//
96+
// See New for the handling of an empty `index.json`.
8997
func NewWithContext(ctx context.Context, root string) (*Store, error) {
9098
rootAbs, err := filepath.Abs(root)
9199
if err != nil {
@@ -362,26 +370,55 @@ func (s *Store) ensureOCILayoutFile() error {
362370
if !os.IsNotExist(err) {
363371
return fmt.Errorf("failed to open OCI layout file: %w", err)
364372
}
373+
return writeOCILayoutFile(layoutFilePath)
374+
}
375+
defer layoutFile.Close()
365376

366-
layout := ocispec.ImageLayout{
367-
Version: ocispec.ImageLayoutVersion,
368-
}
369-
layoutJSON, err := json.Marshal(layout)
370-
if err != nil {
371-
return fmt.Errorf("failed to marshal OCI layout file: %w", err)
377+
// An empty file is what an interrupted write leaves behind. It carries no
378+
// information, and it describes the same state as a missing file, so it is
379+
// treated in the same way and rewritten, rather than failing the store for
380+
// as long as it is there.
381+
empty, err := isEmptyFile(layoutFile)
382+
if err != nil {
383+
return fmt.Errorf("failed to stat OCI layout file: %w", err)
384+
}
385+
if empty {
386+
// the file is closed before it is replaced, since on Windows a file
387+
// cannot be renamed over while it is open.
388+
if err := layoutFile.Close(); err != nil {
389+
return fmt.Errorf("failed to close OCI layout file: %w", err)
372390
}
373-
return os.WriteFile(layoutFilePath, layoutJSON, 0666)
391+
return writeOCILayoutFile(layoutFilePath)
374392
}
375-
defer layoutFile.Close()
376393

377394
var layout ocispec.ImageLayout
378-
err = json.NewDecoder(layoutFile).Decode(&layout)
379-
if err != nil {
395+
if err := json.NewDecoder(layoutFile).Decode(&layout); err != nil {
380396
return fmt.Errorf("failed to decode OCI layout file: %w", err)
381397
}
382398
return validateOCILayout(&layout)
383399
}
384400

401+
// writeOCILayoutFile writes the `oci-layout` file at the given path.
402+
func writeOCILayoutFile(layoutFilePath string) error {
403+
layout := ocispec.ImageLayout{
404+
Version: ocispec.ImageLayoutVersion,
405+
}
406+
layoutJSON, err := json.Marshal(layout)
407+
if err != nil {
408+
return fmt.Errorf("failed to marshal OCI layout file: %w", err)
409+
}
410+
return writeFileAtomic(layoutFilePath, layoutJSON)
411+
}
412+
413+
// isEmptyFile reports whether the open file is zero-length.
414+
func isEmptyFile(file *os.File) (bool, error) {
415+
fi, err := file.Stat()
416+
if err != nil {
417+
return false, err
418+
}
419+
return fi.Size() == 0, nil
420+
}
421+
385422
// loadIndexFile reads index.json from the file system.
386423
// Create index.json if it does not exist.
387424
func (s *Store) loadIndexFile(ctx context.Context) error {
@@ -392,17 +429,26 @@ func (s *Store) loadIndexFile(ctx context.Context) error {
392429
}
393430

394431
// write index.json if it does not exist
395-
s.index = &ocispec.Index{
396-
Versioned: specs.Versioned{
397-
SchemaVersion: 2, // historical value
398-
},
399-
MediaType: ocispec.MediaTypeImageIndex,
400-
Manifests: []ocispec.Descriptor{},
401-
}
402-
return s.writeIndexFile()
432+
return s.resetIndexFile()
403433
}
404434
defer indexFile.Close()
405435

436+
// An empty file is what an interrupted write leaves behind. It is not a
437+
// valid index, and it describes the same state as a missing index file, so
438+
// it is treated in the same way and rewritten.
439+
empty, err := isEmptyFile(indexFile)
440+
if err != nil {
441+
return fmt.Errorf("failed to stat index file: %w", err)
442+
}
443+
if empty {
444+
// the file is closed before it is replaced, since on Windows a file
445+
// cannot be renamed over while it is open.
446+
if err := indexFile.Close(); err != nil {
447+
return fmt.Errorf("failed to close index file: %w", err)
448+
}
449+
return s.resetIndexFile()
450+
}
451+
406452
var index ocispec.Index
407453
if err := json.NewDecoder(indexFile).Decode(&index); err != nil {
408454
return fmt.Errorf("failed to decode index file: %w", err)
@@ -411,6 +457,19 @@ func (s *Store) loadIndexFile(ctx context.Context) error {
411457
return loadIndex(ctx, s.index, s.storage, s.tagResolver, s.graph)
412458
}
413459

460+
// resetIndexFile sets the index to an empty index and writes it to the file
461+
// system, replacing the `index.json` file if it is already present.
462+
func (s *Store) resetIndexFile() error {
463+
s.index = &ocispec.Index{
464+
Versioned: specs.Versioned{
465+
SchemaVersion: 2, // historical value
466+
},
467+
MediaType: ocispec.MediaTypeImageIndex,
468+
Manifests: []ocispec.Descriptor{},
469+
}
470+
return s.writeIndexFile()
471+
}
472+
414473
// SaveIndex writes the `index.json` file to the file system.
415474
// - If AutoSaveIndex is set to true (default value),
416475
// the OCI store will automatically save the changes to `index.json`
@@ -463,7 +522,7 @@ func (s *Store) writeIndexFile() error {
463522
if err != nil {
464523
return fmt.Errorf("failed to marshal index file: %w", err)
465524
}
466-
return os.WriteFile(s.indexPath, indexJSON, 0666)
525+
return writeFileAtomic(s.indexPath, indexJSON)
467526
}
468527

469528
// GC removes garbage from Store. Unsaved index will be lost. To prevent unexpected

0 commit comments

Comments
 (0)