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