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