Skip to content

Commit 24ea31f

Browse files
authored
Write manifest file updates to a temp file first (#1442)
1 parent fab01ec commit 24ea31f

File tree

2 files changed

+112
-13
lines changed

2 files changed

+112
-13
lines changed

internal/file/file_operator.go

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ func (fo *FileOperator) ReadChunk(
155155

156156
func (fo *FileOperator) WriteManifestFile(
157157
ctx context.Context, updatedFiles map[string]*model.ManifestFile, manifestDir, manifestPath string,
158-
) (writeError error) {
158+
) error {
159159
slog.DebugContext(ctx, "Writing manifest file", "updated_files", updatedFiles)
160160
manifestJSON, err := json.MarshalIndent(updatedFiles, "", " ")
161161
if err != nil {
@@ -164,28 +164,50 @@ func (fo *FileOperator) WriteManifestFile(
164164

165165
fo.manifestLock.Lock()
166166
defer fo.manifestLock.Unlock()
167+
167168
// 0755 allows read/execute for all, write for owner
168169
if err = os.MkdirAll(manifestDir, dirPerm); err != nil {
169170
return fmt.Errorf("unable to create directory %s: %w", manifestDir, err)
170171
}
171172

172-
// 0600 ensures only root can read/write
173-
newFile, err := os.OpenFile(manifestPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePerm)
173+
// Write to a temporary file first to ensure atomicity
174+
tempManifestFilePath := manifestPath + ".tmp"
175+
tempFile, err := os.OpenFile(tempManifestFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePerm)
174176
if err != nil {
175-
return fmt.Errorf("failed to read manifest file: %w", err)
177+
return fmt.Errorf("failed to open file %s: %w", tempManifestFilePath, err)
176178
}
177-
defer func() {
178-
if closeErr := newFile.Close(); closeErr != nil {
179-
writeError = closeErr
180-
}
181-
}()
182179

183-
_, err = newFile.Write(manifestJSON)
180+
if _, err = tempFile.Write(manifestJSON); err != nil {
181+
closeFile(ctx, tempFile)
182+
183+
return fmt.Errorf("failed to write to file %s: %w", tempManifestFilePath, err)
184+
}
185+
186+
closeFile(ctx, tempFile)
187+
188+
// Verify the contents of the temporary file is JSON
189+
file, err := os.ReadFile(tempManifestFilePath)
184190
if err != nil {
185-
return fmt.Errorf("failed to write manifest file: %w", err)
191+
return fmt.Errorf("failed to read file %s: %w", tempManifestFilePath, err)
186192
}
187193

188-
return writeError
194+
var manifestFiles map[string]*model.ManifestFile
195+
196+
err = json.Unmarshal(file, &manifestFiles)
197+
if err != nil {
198+
if len(file) == 0 {
199+
return fmt.Errorf("file %s is empty: %w", tempManifestFilePath, err)
200+
}
201+
202+
return fmt.Errorf("failed to parse file %s: %w", tempManifestFilePath, err)
203+
}
204+
205+
// Rename the temporary file to the actual manifest file path
206+
if renameError := os.Rename(tempManifestFilePath, manifestPath); renameError != nil {
207+
return fmt.Errorf("failed to file %s to %s: %w", tempManifestFilePath, manifestPath, renameError)
208+
}
209+
210+
return nil
189211
}
190212

191213
func (fo *FileOperator) MoveFile(ctx context.Context, sourcePath, destPath string) error {

internal/file/file_operator_test.go

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package file
77

88
import (
99
"context"
10+
"encoding/json"
1011
"os"
1112
"path"
1213
"path/filepath"
@@ -44,13 +45,89 @@ func TestFileOperator_Write(t *testing.T) {
4445
assert.Equal(t, fileContent, data)
4546
}
4647

48+
func TestFileOperator_WriteManifestFile(t *testing.T) {
49+
tempDir := t.TempDir()
50+
manifestPath := path.Join(tempDir, "manifest.json")
51+
52+
manifestFiles := map[string]*model.ManifestFile{
53+
"/etc/nginx/nginx.conf": {
54+
ManifestFileMeta: &model.ManifestFileMeta{
55+
Name: "/etc/nginx/nginx.conf",
56+
Size: 1024,
57+
Hash: "6d232d32d44",
58+
Referenced: true,
59+
Unmanaged: false,
60+
},
61+
},
62+
"/etc/nginx/conf.d/default.conf": {
63+
ManifestFileMeta: &model.ManifestFileMeta{
64+
Name: "/etc/nginx/conf.d/default.conf",
65+
Size: 32342,
66+
Hash: "1eh32hd3792hd329",
67+
Referenced: true,
68+
Unmanaged: false,
69+
},
70+
},
71+
}
72+
73+
fileOperator := NewFileOperator(&sync.RWMutex{})
74+
err := fileOperator.WriteManifestFile(t.Context(), manifestFiles, tempDir, manifestPath)
75+
require.NoError(t, err)
76+
77+
assert.FileExists(t, manifestPath)
78+
assert.NoFileExists(t, manifestPath+".tmp")
79+
80+
// Verify the contents can be read back
81+
data, err := os.ReadFile(manifestPath)
82+
require.NoError(t, err)
83+
84+
var readBack map[string]*model.ManifestFile
85+
err = json.Unmarshal(data, &readBack)
86+
require.NoError(t, err)
87+
assert.Equal(t, manifestFiles, readBack)
88+
}
89+
90+
func TestFileOperator_WriteManifestFile_directoryCreationError(t *testing.T) {
91+
manifestPath := "/unknown/manifest.json"
92+
manifestDir := "/unknown"
93+
94+
fileOperator := NewFileOperator(&sync.RWMutex{})
95+
err := fileOperator.WriteManifestFile(t.Context(), make(map[string]*model.ManifestFile), manifestDir, manifestPath)
96+
require.Error(t, err)
97+
assert.Contains(t, err.Error(), "unable to create directory")
98+
}
99+
100+
func TestFileOperator_WriteManifestFile_tempFileCreationError(t *testing.T) {
101+
tempDir := t.TempDir()
102+
103+
// Create a file where we want to write the manifest
104+
manifestPath := path.Join(tempDir, "manifest.json")
105+
err := os.WriteFile(manifestPath, []byte("existing"), 0o400) // readonly
106+
require.NoError(t, err)
107+
108+
// Create readonly directory to prevent temp file creation
109+
err = os.Chmod(tempDir, 0o444)
110+
require.NoError(t, err)
111+
defer func() {
112+
revertPermissionsError := os.Chmod(tempDir, 0o755)
113+
require.NoError(t, revertPermissionsError)
114+
}()
115+
116+
fileOperator := NewFileOperator(&sync.RWMutex{})
117+
err = fileOperator.WriteManifestFile(t.Context(), make(map[string]*model.ManifestFile), tempDir, manifestPath)
118+
require.Error(t, err)
119+
assert.Contains(t, err.Error(), "failed to open file")
120+
}
121+
47122
func TestFileOperator_WriteManifestFile_fileMissing(t *testing.T) {
48123
tempDir := t.TempDir()
49124
manifestPath := "/unknown/manifest.json"
50125

51126
fileOperator := NewFileOperator(&sync.RWMutex{})
52127
err := fileOperator.WriteManifestFile(t.Context(), make(map[string]*model.ManifestFile), tempDir, manifestPath)
53-
assert.Error(t, err)
128+
require.Error(t, err)
129+
130+
assert.NoFileExists(t, manifestPath+".tmp")
54131
}
55132

56133
func TestFileOperator_MoveFile_fileExists(t *testing.T) {

0 commit comments

Comments
 (0)