Skip to content

Commit 19c8aa5

Browse files
committed
fix(repository): harden database writes
1 parent ded4db9 commit 19c8aa5

2 files changed

Lines changed: 123 additions & 3 deletions

File tree

internal/repository/repository.go

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7+
"path/filepath"
78
"strings"
89

910
"github.com/slobbe/appimage-manager/internal/config"
@@ -93,15 +94,67 @@ func isSupportedUpdateKind(kind models.UpdateKind) bool {
9394
}
9495

9596
func SaveDB(path string, db *DB) error {
96-
tmp := path + ".tmp"
9797
b, err := json.MarshalIndent(db, "", " ")
9898
if err != nil {
9999
return err
100100
}
101-
if err := os.WriteFile(tmp, b, 0o644); err != nil {
101+
102+
dir := filepath.Dir(path)
103+
if err := os.MkdirAll(dir, 0o755); err != nil {
104+
return err
105+
}
106+
107+
perm := os.FileMode(0o644)
108+
if info, err := os.Stat(path); err == nil {
109+
perm = info.Mode().Perm()
110+
} else if !os.IsNotExist(err) {
111+
return err
112+
}
113+
114+
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
115+
if err != nil {
116+
return err
117+
}
118+
tmpPath := tmp.Name()
119+
cleanup := true
120+
defer func() {
121+
if cleanup {
122+
_ = os.Remove(tmpPath)
123+
}
124+
}()
125+
126+
if err := tmp.Chmod(perm); err != nil {
127+
_ = tmp.Close()
128+
return err
129+
}
130+
if _, err := tmp.Write(b); err != nil {
131+
_ = tmp.Close()
102132
return err
103133
}
104-
return os.Rename(tmp, path)
134+
if err := tmp.Sync(); err != nil {
135+
_ = tmp.Close()
136+
return err
137+
}
138+
if err := tmp.Close(); err != nil {
139+
return err
140+
}
141+
142+
if err := os.Rename(tmpPath, path); err != nil {
143+
return err
144+
}
145+
cleanup = false
146+
147+
return syncDir(dir)
148+
}
149+
150+
func syncDir(path string) error {
151+
dir, err := os.Open(path)
152+
if err != nil {
153+
return err
154+
}
155+
defer dir.Close()
156+
157+
return dir.Sync()
105158
}
106159

107160
func AddApp(appData *models.App, overwrite bool) error {

internal/repository/repository_batch_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,80 @@ package repo
33
import (
44
"os"
55
"path/filepath"
6+
"runtime"
67
"strings"
78
"testing"
89

910
"github.com/slobbe/appimage-manager/internal/config"
1011
models "github.com/slobbe/appimage-manager/internal/types"
1112
)
1213

14+
func TestSaveDBCreatesParentDirectory(t *testing.T) {
15+
tmp := t.TempDir()
16+
dbPath := filepath.Join(tmp, "nested", "state", "aim", "apps.json")
17+
18+
if err := SaveDB(dbPath, &DB{SchemaVersion: 1, Apps: map[string]*models.App{}}); err != nil {
19+
t.Fatalf("SaveDB returned error: %v", err)
20+
}
21+
22+
if _, err := os.Stat(dbPath); err != nil {
23+
t.Fatalf("expected database file to exist: %v", err)
24+
}
25+
26+
db, err := LoadDB(dbPath)
27+
if err != nil {
28+
t.Fatalf("LoadDB returned error: %v", err)
29+
}
30+
if db.SchemaVersion != 1 {
31+
t.Fatalf("db.SchemaVersion = %d, want 1", db.SchemaVersion)
32+
}
33+
if len(db.Apps) != 0 {
34+
t.Fatalf("len(db.Apps) = %d, want 0", len(db.Apps))
35+
}
36+
}
37+
38+
func TestSaveDBPreservesExistingPermissions(t *testing.T) {
39+
if runtime.GOOS == "windows" {
40+
t.Skip("permission bits are not portable on Windows")
41+
}
42+
43+
tmp := t.TempDir()
44+
dbPath := filepath.Join(tmp, "apps.json")
45+
46+
if err := os.WriteFile(dbPath, []byte(`{"schemaVersion":1,"apps":{}}`), 0o600); err != nil {
47+
t.Fatalf("failed to seed db file: %v", err)
48+
}
49+
50+
if err := SaveDB(dbPath, &DB{SchemaVersion: 1, Apps: map[string]*models.App{}}); err != nil {
51+
t.Fatalf("SaveDB returned error: %v", err)
52+
}
53+
54+
info, err := os.Stat(dbPath)
55+
if err != nil {
56+
t.Fatalf("failed to stat db file: %v", err)
57+
}
58+
if got := info.Mode().Perm(); got != 0o600 {
59+
t.Fatalf("db mode = %o, want 600", got)
60+
}
61+
}
62+
63+
func TestSaveDBUsesUniqueTempFilesAndCleansUp(t *testing.T) {
64+
tmp := t.TempDir()
65+
dbPath := filepath.Join(tmp, "apps.json")
66+
67+
if err := SaveDB(dbPath, &DB{SchemaVersion: 1, Apps: map[string]*models.App{}}); err != nil {
68+
t.Fatalf("SaveDB returned error: %v", err)
69+
}
70+
71+
matches, err := filepath.Glob(filepath.Join(tmp, ".apps.json.*.tmp"))
72+
if err != nil {
73+
t.Fatalf("failed to glob temp files: %v", err)
74+
}
75+
if len(matches) != 0 {
76+
t.Fatalf("expected no temp files after successful save, found %v", matches)
77+
}
78+
}
79+
1380
func TestUpdateCheckMetadataBatch(t *testing.T) {
1481
tmp := t.TempDir()
1582
dbPath := filepath.Join(tmp, "apps.json")

0 commit comments

Comments
 (0)