init
This commit is contained in:
55
config/atomic.go
Normal file
55
config/atomic.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// AtomicWrite writes data to the target path atomically by writing to a temp file
|
||||
// in the same directory, fsyncing, then renaming over the target.
|
||||
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
|
||||
cleanup := func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := tmp.Sync(); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("fsync temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpName, perm); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("chmod temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("rename temp to target: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
156
config/backup.go
Normal file
156
config/backup.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const backupDirName = ".backups"
|
||||
|
||||
// BackupInfo holds metadata about a single backup.
|
||||
type BackupInfo struct {
|
||||
Version string `json:"version"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// CreateBackup copies the current file into a timestamped backup before modification.
|
||||
// Returns the backup version string. If the source file doesn't exist, returns "" with no error.
|
||||
func CreateBackup(baseDir, relPath string, maxBackups int) (string, error) {
|
||||
srcPath, err := FullPath(baseDir, relPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read source for backup: %w", err)
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath)))
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create backup dir: %w", err)
|
||||
}
|
||||
|
||||
version := time.Now().UTC().Format("20060102T150405Z")
|
||||
base := filepath.Base(relPath)
|
||||
ext := filepath.Ext(base)
|
||||
name := strings.TrimSuffix(base, ext)
|
||||
backupName := fmt.Sprintf("%s.%s%s", name, version, ext)
|
||||
backupPath := filepath.Join(backupDir, backupName)
|
||||
|
||||
if err := AtomicWrite(backupPath, data, 0o644); err != nil {
|
||||
return "", fmt.Errorf("write backup: %w", err)
|
||||
}
|
||||
|
||||
if err := pruneBackups(backupDir, name, ext, maxBackups); err != nil {
|
||||
return "", fmt.Errorf("prune backups: %w", err)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// ListBackups returns all backup versions for a given config file, newest first.
|
||||
func ListBackups(baseDir, relPath string) ([]BackupInfo, error) {
|
||||
if err := ValidatePath(relPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath)))
|
||||
base := filepath.Base(relPath)
|
||||
ext := filepath.Ext(base)
|
||||
name := strings.TrimSuffix(base, ext)
|
||||
prefix := name + "."
|
||||
|
||||
entries, err := os.ReadDir(backupDir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read backup dir: %w", err)
|
||||
}
|
||||
|
||||
var backups []BackupInfo
|
||||
for _, e := range entries {
|
||||
fname := e.Name()
|
||||
if !strings.HasPrefix(fname, prefix) || !strings.HasSuffix(fname, ext) {
|
||||
continue
|
||||
}
|
||||
version := strings.TrimSuffix(strings.TrimPrefix(fname, prefix), ext)
|
||||
backups = append(backups, BackupInfo{
|
||||
Version: version,
|
||||
Timestamp: version,
|
||||
Path: filepath.Join(backupDir, fname),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(backups, func(i, j int) bool {
|
||||
return backups[i].Version > backups[j].Version
|
||||
})
|
||||
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
// Rollback restores a config file to the given backup version.
|
||||
func Rollback(baseDir, relPath, version string) error {
|
||||
if err := ValidatePath(relPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath)))
|
||||
base := filepath.Base(relPath)
|
||||
ext := filepath.Ext(base)
|
||||
name := strings.TrimSuffix(base, ext)
|
||||
backupName := fmt.Sprintf("%s.%s%s", name, version, ext)
|
||||
backupPath := filepath.Join(backupDir, backupName)
|
||||
|
||||
data, err := os.ReadFile(backupPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read backup %q: %w", version, err)
|
||||
}
|
||||
|
||||
targetPath, err := FullPath(baseDir, relPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return AtomicWrite(targetPath, data, 0o644)
|
||||
}
|
||||
|
||||
// pruneBackups removes oldest backups exceeding maxBackups.
|
||||
func pruneBackups(backupDir, name, ext string, maxBackups int) error {
|
||||
if maxBackups <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefix := name + "."
|
||||
entries, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var matching []string
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ext) {
|
||||
matching = append(matching, e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(matching)
|
||||
|
||||
if len(matching) > maxBackups {
|
||||
for _, old := range matching[:len(matching)-maxBackups] {
|
||||
os.Remove(filepath.Join(backupDir, old))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
83
config/parser.go
Normal file
83
config/parser.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Format represents a config file format.
|
||||
type Format int
|
||||
|
||||
const (
|
||||
FormatJSON Format = iota
|
||||
FormatYAML
|
||||
)
|
||||
|
||||
// DetectFormat returns the format based on file extension.
|
||||
func DetectFormat(path string) (Format, error) {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".json":
|
||||
return FormatJSON, nil
|
||||
case ".yaml", ".yml":
|
||||
return FormatYAML, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported format: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse deserializes raw bytes into a map based on the given format.
|
||||
func Parse(data []byte, format Format) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
switch format {
|
||||
case FormatJSON:
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
case FormatYAML:
|
||||
if err := yaml.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("invalid YAML: %w", err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown format")
|
||||
}
|
||||
if result == nil {
|
||||
result = make(map[string]any)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Serialize converts a map back to bytes in the given format.
|
||||
func Serialize(data map[string]any, format Format) ([]byte, error) {
|
||||
switch format {
|
||||
case FormatJSON:
|
||||
return json.MarshalIndent(data, "", " ")
|
||||
case FormatYAML:
|
||||
return yaml.Marshal(data)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown format")
|
||||
}
|
||||
}
|
||||
|
||||
// DeepMerge merges src into dst recursively. Values in src override those in dst.
|
||||
// Nested maps are merged; all other types are replaced.
|
||||
func DeepMerge(dst, src map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(dst))
|
||||
for k, v := range dst {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range src {
|
||||
if srcMap, ok := v.(map[string]any); ok {
|
||||
if dstMap, ok := out[k].(map[string]any); ok {
|
||||
out[k] = DeepMerge(dstMap, srcMap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
49
config/validation.go
Normal file
49
config/validation.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var allowedExtensions = map[string]bool{
|
||||
".json": true,
|
||||
".yaml": true,
|
||||
".yml": true,
|
||||
}
|
||||
|
||||
// ValidatePath checks that the given relative path is safe and points to a supported file type.
|
||||
// It prevents directory traversal and rejects unsupported extensions.
|
||||
func ValidatePath(relPath string) error {
|
||||
if relPath == "" {
|
||||
return fmt.Errorf("empty path")
|
||||
}
|
||||
|
||||
cleaned := filepath.Clean(relPath)
|
||||
|
||||
if filepath.IsAbs(cleaned) {
|
||||
return fmt.Errorf("absolute paths not allowed")
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(cleaned, string(filepath.Separator)) {
|
||||
if part == ".." {
|
||||
return fmt.Errorf("path traversal not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(cleaned))
|
||||
if !allowedExtensions[ext] {
|
||||
return fmt.Errorf("unsupported file extension %q; allowed: .json, .yaml, .yml", ext)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FullPath joins the base config directory with the validated relative path.
|
||||
func FullPath(baseDir, relPath string) (string, error) {
|
||||
if err := ValidatePath(relPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
full := filepath.Join(baseDir, filepath.Clean(relPath))
|
||||
return full, nil
|
||||
}
|
||||
Reference in New Issue
Block a user