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