1package filesystem
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "path/filepath"
12 "sort"
13 "strings"
14 "syscall"
15 "time"
16
17 "github.com/bnema/zerowrap"
18
19 "github.com/bnema/gordon/internal/domain"
20)
21
22const backupTimestampLayout = "20060102T150405Z"
23
24// BackupStorage implements backup artifact persistence on local filesystem.
25type BackupStorage struct {
26 rootDir string
27 log zerowrap.Logger
28}
29
30// NewBackupStorage creates a new filesystem backup storage.
31func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) {
32 rootDir = expandTilde(rootDir)
33
34 if err := os.MkdirAll(rootDir, 0750); err != nil {
35 return nil, fmt.Errorf("failed to create backup directory: %w", err)
36 }
37
38 return &BackupStorage{rootDir: rootDir, log: log}, nil
39}
40
41// expandTilde replaces a leading "~/" with the user's home directory.
42func expandTilde(path string) string {
43 if !strings.HasPrefix(path, "~/") {
44 return path
45 }
46 if home, err := os.UserHomeDir(); err == nil {
47 return filepath.Join(home, path[2:])
48 }
49 return path
50}
51
52// Store saves backup data and returns the absolute storage path.
53func (s *BackupStorage) Store(_ context.Context, app, service, database string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) {
54 appPart := sanitizeBackupPathComponent(app)
55 servicePart := sanitizeBackupPathComponent(service)
56 dbPart := sanitizeBackupPathComponent(database)
57 schedulePart := string(schedule)
58 if schedulePart == "" {
59 schedulePart = "manual"
60 }
61 schedulePart = sanitizeBackupPathComponent(schedulePart)
62
63 backupDir := filepath.Join(s.rootDir, appPart, servicePart, dbPart, schedulePart)
64 if err := os.MkdirAll(backupDir, 0750); err != nil {
65 return "", fmt.Errorf("failed to create backup path: %w", err)
66 }
67
68 fileName := timestamp.UTC().Format(time.RFC3339Nano) + "-" + randomBackupSuffix() + ".bak"
69 fileName = sanitizeBackupFileName(fileName)
70 finalPath := filepath.Join(backupDir, fileName)
71 tmpPath := finalPath + ".tmp"
72
73 f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
74 if err != nil {
75 return "", fmt.Errorf("failed to create temp backup file: %w", err)
76 }
77
78 if _, err := io.Copy(f, data); err != nil {
79 _ = f.Close()
80 _ = os.Remove(tmpPath)
81 return "", fmt.Errorf("failed to write backup data: %w", err)
82 }
83
84 if err := f.Close(); err != nil {
85 _ = os.Remove(tmpPath)
86 return "", fmt.Errorf("failed to close temp backup file: %w", err)
87 }
88
89 if err := os.Rename(tmpPath, finalPath); err != nil {
90 _ = os.Remove(tmpPath)
91 return "", fmt.Errorf("failed to finalize backup file: %w", err)
92 }
93
94 return finalPath, nil
95}
96
97// Get retrieves a backup file by path.
98func (s *BackupStorage) Get(_ context.Context, path string) (io.ReadCloser, error) {
99 if !pathWithinRoot(s.rootDir, path) {
100 return nil, fmt.Errorf("backup path escapes storage root")
101 }
102
103 f, err := os.Open(path)
104 if err != nil {
105 return nil, err
106 }
107 return f, nil
108}
109
110// List returns backups for a domain, optionally filtered by schedule.
111func (s *BackupStorage) List(_ context.Context, app string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error) {
112 appRoot := filepath.Join(s.rootDir, sanitizeBackupPathComponent(app))
113 if _, err := os.Stat(appRoot); err != nil {
114 if os.IsNotExist(err) {
115 return []domain.BackupJob{}, nil
116 }
117 return nil, err
118 }
119
120 jobs := make([]domain.BackupJob, 0)
121 err := filepath.WalkDir(appRoot, func(path string, d os.DirEntry, walkErr error) error {
122 if walkErr != nil {
123 return walkErr
124 }
125 if d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") {
126 return nil
127 }
128
129 rel, err := filepath.Rel(appRoot, path)
130 if err != nil {
131 return nil
132 }
133 parts := strings.Split(rel, string(filepath.Separator))
134 if len(parts) != 4 {
135 // Pre-cutover app/database/schedule records do not contain a
136 // canonical service identity, so they cannot safely join a
137 // current app/service/database history.
138 return nil
139 }
140
141 service := parts[0]
142 dbName := parts[1]
143 sched := domain.BackupSchedule(parts[2])
144 if schedule != nil && *schedule != sched {
145 return nil
146 }
147
148 info, err := d.Info()
149 if err != nil {
150 return nil
151 }
152
153 startedAt := info.ModTime().UTC()
154 base := filepath.Base(path)
155 if ts, ok := parseBackupStartedAt(base); ok {
156 startedAt = ts.UTC()
157 }
158
159 jobs = append(jobs, domain.BackupJob{
160 ID: base,
161 App: app,
162 Service: service,
163 DBName: dbName,
164 Schedule: sched,
165 Type: domain.BackupTypeLogical,
166 Status: domain.BackupStatusCompleted,
167 StartedAt: startedAt,
168 SizeBytes: info.Size(),
169 FilePath: path,
170 })
171
172 return nil
173 })
174 if err != nil {
175 return nil, err
176 }
177
178 sort.Slice(jobs, func(i, j int) bool {
179 return jobs[i].StartedAt.After(jobs[j].StartedAt)
180 })
181
182 return jobs, nil
183}
184
185// Delete removes a backup file.
186func (s *BackupStorage) Delete(_ context.Context, path string) error {
187 if !pathWithinRoot(s.rootDir, path) {
188 return fmt.Errorf("backup path escapes storage root")
189 }
190 return os.Remove(path)
191}
192
193// ApplyRetention removes old backups according to the schedule policy.
194func (s *BackupStorage) ApplyRetention(ctx context.Context, app string, policy domain.RetentionPolicy) (int, error) {
195 jobs, err := s.List(ctx, app, nil)
196 if err != nil {
197 return 0, err
198 }
199
200 groups := make(map[string][]domain.BackupJob)
201 for _, job := range jobs {
202 key := fmt.Sprintf("%s|%s|%s", job.Service, job.DBName, job.Schedule)
203 groups[key] = append(groups[key], job)
204 }
205
206 deleted := 0
207 for _, group := range groups {
208 sort.Slice(group, func(i, j int) bool {
209 return group[i].StartedAt.After(group[j].StartedAt)
210 })
211
212 keep := retentionKeepCount(policy, group[0].Schedule)
213 if keep < 0 {
214 continue
215 }
216
217 for idx := keep; idx < len(group); idx++ {
218 if err := s.Delete(ctx, group[idx].FilePath); err != nil {
219 if os.IsNotExist(err) {
220 continue
221 }
222 return deleted, err
223 }
224 if err := s.removeEmptyBackupDirs(group[idx].FilePath, app); err != nil {
225 return deleted, err
226 }
227 deleted++
228 }
229 }
230
231 return deleted, nil
232}
233
234func (s *BackupStorage) removeEmptyBackupDirs(filePath, domainName string) error {
235 backupRoot := s.rootDir
236 domainRoot := filepath.Join(backupRoot, sanitizeBackupPathComponent(domainName))
237
238 dir := filepath.Dir(filePath)
239 for {
240 if !pathWithinRoot(backupRoot, dir) {
241 return nil
242 }
243 if dir == domainRoot || dir == backupRoot {
244 break
245 }
246
247 err := os.Remove(dir)
248 if err == nil {
249 dir = filepath.Dir(dir)
250 continue
251 }
252 if os.IsNotExist(err) {
253 return nil
254 }
255 if errors.Is(err, syscall.ENOTEMPTY) || errors.Is(err, syscall.EEXIST) {
256 return nil
257 }
258 return err
259 }
260
261 return nil
262}
263
264func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSchedule) int {
265 switch schedule {
266 case domain.ScheduleHourly:
267 return policy.Hourly
268 case domain.ScheduleDaily:
269 return policy.Daily
270 case domain.ScheduleWeekly:
271 return policy.Weekly
272 case domain.ScheduleMonthly:
273 return policy.Monthly
274 default:
275 return -1
276 }
277}
278
279func parseBackupStartedAt(base string) (time.Time, bool) {
280 if !strings.HasSuffix(base, ".bak") {
281 return time.Time{}, false
282 }
283 if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil {
284 return ts, true
285 }
286 trimmed := strings.TrimSuffix(base, ".bak")
287 if idx := strings.LastIndex(trimmed, "-"); idx >= 0 {
288 trimmed = trimmed[:idx]
289 }
290 if ts, err := time.Parse(time.RFC3339Nano, strings.ReplaceAll(trimmed, "_", ":")); err == nil {
291 return ts, true
292 }
293 return time.Time{}, false
294}
295
296func randomBackupSuffix() string {
297 var b [8]byte
298 if _, err := rand.Read(b[:]); err != nil {
299 return fmt.Sprintf("%d", time.Now().UTC().UnixNano())
300 }
301 return hex.EncodeToString(b[:])
302}
303
304func sanitizeBackupFileName(input string) string {
305 return strings.NewReplacer(":", "_").Replace(input)
306}
307
308func sanitizeBackupPathComponent(input string) string {
309 clean := strings.TrimSpace(input)
310 if clean == "" {
311 return "unknown"
312 }
313 if strings.Trim(clean, ".") == "" {
314 return "unknown"
315 }
316 clean = strings.Trim(clean, ".")
317 if clean == "" {
318 return "unknown"
319 }
320 replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_")
321 return replacer.Replace(clean)
322}
323
324func pathWithinRoot(root, path string) bool {
325 rootAbs, err := filepath.Abs(root)
326 if err != nil {
327 return false
328 }
329 pathAbs, err := filepath.Abs(path)
330 if err != nil {
331 return false
332 }
333
334 rootClean := filepath.Clean(rootAbs)
335 pathClean := filepath.Clean(pathAbs)
336 rel, err := filepath.Rel(rootClean, pathClean)
337 if err != nil {
338 return false
339 }
340 return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
341}