-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathatomicwrite.go
More file actions
168 lines (129 loc) · 4.46 KB
/
Copy pathatomicwrite.go
File metadata and controls
168 lines (129 loc) · 4.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
// Package atomicwrite provides TOCTOU-safe file writes using xxhash64
// fingerprint verification, cross-platform file locking via gofrs/flock,
// atomic rename, and fsync for crash durability.
package atomicwrite
import (
"crypto/rand"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"os"
"github.com/cespare/xxhash/v2"
"github.com/gofrs/flock"
)
// ErrConcurrentModification indicates the file was modified by another
// process between the fingerprint read and the write attempt.
var ErrConcurrentModification = errors.New("file was modified concurrently since read")
// Fingerprint is an xxhash64 digest of file content at read time.
// A zero-value Fingerprint indicates no prior file existed.
type Fingerprint [8]byte
// IsZero returns true if the fingerprint represents no prior file.
func (fp Fingerprint) IsZero() bool {
return fp == Fingerprint{}
}
// Matches returns true if the given content produces the same fingerprint.
func (fp Fingerprint) Matches(content []byte) bool {
return FingerprintFromBytes(content) == fp
}
// FingerprintFromBytes computes an xxhash64 Fingerprint from raw content.
func FingerprintFromBytes(data []byte) Fingerprint {
var fp Fingerprint
binary.BigEndian.PutUint64(fp[:], xxhash.Sum64(data))
return fp
}
// FingerprintFile computes an xxhash64 Fingerprint from a file's current content.
// Returns a zero-value Fingerprint if the file does not exist.
func FingerprintFile(path string) (Fingerprint, error) {
data, err := os.ReadFile(path) //nolint:gosec // path is caller-controlled
if err != nil {
if os.IsNotExist(err) {
return Fingerprint{}, nil
}
return Fingerprint{}, fmt.Errorf("reading %s for fingerprint: %w", path, err)
}
return FingerprintFromBytes(data), nil
}
// Write writes data to path with TOCTOU protection and crash durability.
// Data is staged to a unique temp file, fsync'd, then atomically renamed
// over the target. The target directory is fsync'd after rename (POSIX).
// If fingerprint is non-zero, it verifies the file hasn't changed since the
// fingerprint was computed, using cross-platform file locking (flock on Unix,
// LockFileEx on Windows) and atomic rename.
// A zero-value fingerprint skips verification (first run).
func Write(path string, data []byte, fingerprint Fingerprint) error {
const defaultFilePerm = fs.FileMode(0o644)
perm := defaultFilePerm
info, err := os.Stat(path)
if err == nil {
perm = info.Mode().Perm()
}
suffix, suffixErr := randomSuffix()
if suffixErr != nil {
return fmt.Errorf("generating temp file suffix: %w", suffixErr)
}
tmpPath := path + "." + suffix + ".tmp"
stageErr := writeAndSync(tmpPath, data, perm)
if stageErr != nil {
return stageErr
}
if !fingerprint.IsZero() {
return commitWithVerification(path, tmpPath, fingerprint)
}
return atomicRename(path, tmpPath)
}
func commitWithVerification(path, tmpPath string, fingerprint Fingerprint) error {
fileLock := flock.New(path)
lockErr := fileLock.Lock()
if lockErr != nil {
cleanupTmp(tmpPath)
return fmt.Errorf("acquiring exclusive lock on %s: %w", path, lockErr)
}
defer func() { _ = fileLock.Close() }()
current, err := os.ReadFile(path) //nolint:gosec // path is caller-controlled
if err != nil {
cleanupTmp(tmpPath)
return fmt.Errorf("re-reading %s for verification: %w", path, err)
}
if !fingerprint.Matches(current) {
cleanupTmp(tmpPath)
return fmt.Errorf("%w: %s was modified since read", ErrConcurrentModification, path)
}
return atomicRename(path, tmpPath)
}
func randomSuffix() (string, error) {
var buf [4]byte
_, err := rand.Read(buf[:])
if err != nil {
return "", fmt.Errorf("reading random bytes: %w", err)
}
return hex.EncodeToString(buf[:]), nil
}
func writeAndSync(tmpPath string, data []byte, perm fs.FileMode) error {
file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) //nolint:gosec // caller-controlled
if err != nil {
return fmt.Errorf("creating temp file %s: %w", tmpPath, err)
}
_, writeErr := file.Write(data)
if writeErr != nil {
_ = file.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("writing temp file %s: %w", tmpPath, writeErr)
}
syncErr := file.Sync()
if syncErr != nil {
_ = file.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("syncing temp file %s: %w", tmpPath, syncErr)
}
closeErr := file.Close()
if closeErr != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("closing temp file %s: %w", tmpPath, closeErr)
}
return nil
}
func cleanupTmp(tmpPath string) {
_ = os.Remove(tmpPath)
}