package backup import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io/fs" "os" "path/filepath" "sort" "strings" "time" ) type Entry struct { Name string `json:"name"` CreatedAt time.Time `json:"created_at"` Reason string `json:"reason"` Actor string `json:"actor"` SHA256 string `json:"sha256"` Size int64 `json:"size"` } type Store struct { Dir string MaxBackups int Now func() time.Time } func (s Store) Create(sourcePath, reason, actor string) (Entry, error) { data, err := os.ReadFile(sourcePath) if err != nil { return Entry{}, err } if s.Now == nil { s.Now = time.Now } now := s.Now().UTC() sum := sha256.Sum256(data) hash := hex.EncodeToString(sum[:]) reason = safe(reason) name := fmt.Sprintf("%s_%s_%s.env", now.Format("20060102T150405.000000000Z"), reason, hash[:12]) if err := os.MkdirAll(s.Dir, 0o700); err != nil { return Entry{}, err } path := filepath.Join(s.Dir, name) if err := writeExclusive(path, data, 0o600); err != nil { return Entry{}, err } entry := Entry{Name: name, CreatedAt: now, Reason: reason, Actor: actor, SHA256: hash, Size: int64(len(data))} meta, _ := json.MarshalIndent(entry, "", " ") if err := writeExclusive(path+".json", append(meta, '\n'), 0o600); err != nil { return Entry{}, err } _ = s.appendAudit(entry) _ = s.prune() return entry, nil } func (s Store) List() ([]Entry, error) { items, err := os.ReadDir(s.Dir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } var out []Entry for _, item := range items { if item.IsDir() || !strings.HasSuffix(item.Name(), ".env.json") { continue } data, err := os.ReadFile(filepath.Join(s.Dir, item.Name())) if err != nil { continue } var entry Entry if json.Unmarshal(data, &entry) == nil { out = append(out, entry) } } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) return out, nil } func (s Store) Path(name string) (string, error) { if filepath.Base(name) != name || !strings.HasSuffix(name, ".env") { return "", fmt.Errorf("invalid backup name") } path := filepath.Join(s.Dir, name) if _, err := os.Stat(path); err != nil { return "", err } return path, nil } func (s Store) Read(name string) ([]byte, error) { path, err := s.Path(name) if err != nil { return nil, err } return os.ReadFile(path) } func (s Store) appendAudit(entry Entry) error { if err := os.MkdirAll(s.Dir, 0o700); err != nil { return err } f, err := os.OpenFile(filepath.Join(s.Dir, "audit.jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return err } defer f.Close() data, _ := json.Marshal(entry) _, err = f.Write(append(data, '\n')) return err } func (s Store) prune() error { if s.MaxBackups <= 0 { return nil } entries, err := s.List() if err != nil { return err } if len(entries) <= s.MaxBackups { return nil } for _, entry := range entries[s.MaxBackups:] { _ = os.Remove(filepath.Join(s.Dir, entry.Name)) _ = os.Remove(filepath.Join(s.Dir, entry.Name+".json")) } return nil } func safe(value string) string { value = strings.ToLower(value) var b strings.Builder for _, r := range value { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { b.WriteRune(r) } } if b.Len() == 0 { return "change" } return b.String() } func writeExclusive(path string, data []byte, mode fs.FileMode) error { f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) if err != nil { return err } defer f.Close() if _, err := f.Write(data); err != nil { return err } return f.Sync() }