v0.12.3 — Security & correctness bug fixes (33 bugs)
CRITICAL: 10 data race and security fixes — backup.go mutex coverage (C1-C4), IsSystemDisk 12-bit major/minor (C5), /dev/ path validation (C6), extractName traversal (C7), TargetPath/DestinationPath against registered paths (C8-C9), ParseComposeHDDMounts Clean-before-prefix (C10). HIGH: 17 logic/resource fixes — ValidateDump bufio.Scanner (H1), single appDirSize() with 30s timeout (H2/H3), snapshot ID regex (H4), cross-drive restic prune (H5), temp file order (H6), dirSizeBytes errors (H7), atomic fstab (H8), IsDeviceMounted suffix check (H9), eMMC partition mapping (H10), bytesCopied mutex (H11), separator-aware migrate prefix (H13), DeleteStack error on compose-down (H14), docker 60s timeout (H16), NotificationPrefs deep-copy (H17), wipefs warning (H18), fstab rollback on mount fail (H19). MEDIUM: 7 code quality fixes — formatBytes dedup (M1), .tmp filter order (M2), sizeBytes string type (M3), elapsed in message (M6), LoadLocation fallback (M7), pathCovers separator (M10), cancelEditLabel textContent (M11). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -76,8 +78,7 @@ func DiscoverAppData(provider StackDataProvider, backupPrefs map[string]bool, di
|
||||
path := AppDataPath{HostPath: mount}
|
||||
if fi, err := os.Stat(mount); err == nil && fi.IsDir() {
|
||||
path.Exists = true
|
||||
path.SizeBytes = appDirSizeBytes(mount)
|
||||
path.SizeHuman = appDirSizeHuman(mount)
|
||||
path.SizeBytes, path.SizeHuman = appDirSize(mount)
|
||||
}
|
||||
info.HDDPaths = append(info.HDDPaths, path)
|
||||
info.HDDTotalSize += path.SizeBytes
|
||||
@@ -131,34 +132,23 @@ func parseComposeNamedVolumes(composePath string) []AppDockerVolume {
|
||||
return volumes
|
||||
}
|
||||
|
||||
// appDirSizeHuman returns a human-readable size string for a directory using du.
|
||||
func appDirSizeHuman(path string) string {
|
||||
cmd := exec.Command("du", "-sh", path)
|
||||
// appDirSize returns the total byte count and a human-readable string for a directory.
|
||||
// H2/H3: Single du invocation with 30s timeout replaces two separate calls.
|
||||
func appDirSize(path string) (int64, string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "du", "-sb", path)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "?"
|
||||
return 0, "?"
|
||||
}
|
||||
fields := strings.Fields(string(output))
|
||||
if len(fields) > 0 {
|
||||
return fields[0]
|
||||
if len(fields) == 0 {
|
||||
return 0, "?"
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// appDirSizeBytes returns the total size in bytes for a directory.
|
||||
func appDirSizeBytes(path string) int64 {
|
||||
cmd := exec.Command("du", "-sb", path)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(output))
|
||||
if len(fields) > 0 {
|
||||
var size int64
|
||||
fmt.Sscanf(fields[0], "%d", &size)
|
||||
return size
|
||||
}
|
||||
return 0
|
||||
var size int64
|
||||
fmt.Sscanf(fields[0], "%d", &size)
|
||||
return size, humanizeBytes(size)
|
||||
}
|
||||
|
||||
// humanizeBytes converts bytes to a human-readable string.
|
||||
|
||||
@@ -179,7 +179,7 @@ func (m *Manager) RunDBDumps(ctx context.Context) error {
|
||||
m.logger.Printf("[ERROR] DB dump failed for %s: %v", r.DB.ContainerName, r.Error)
|
||||
} else {
|
||||
totalSize += r.Size
|
||||
summary = append(summary, fmt.Sprintf("OK %s (%s)", r.DB.ContainerName, formatBytes(r.Size)))
|
||||
summary = append(summary, fmt.Sprintf("OK %s (%s)", r.DB.ContainerName, humanizeBytes(r.Size)))
|
||||
|
||||
// Persist validation result to settings.json
|
||||
if m.settings != nil && r.FilePath != "" {
|
||||
@@ -212,12 +212,12 @@ func (m *Manager) RunDBDumps(ctx context.Context) error {
|
||||
// Ping healthcheck
|
||||
uuid := m.cfg.Monitoring.PingUUIDs.DBDump
|
||||
body := fmt.Sprintf("DB dump: %d databases, %s total\n%s",
|
||||
len(results), formatBytes(totalSize), strings.Join(summary, "\n"))
|
||||
len(results), humanizeBytes(totalSize), strings.Join(summary, "\n"))
|
||||
|
||||
if allOK {
|
||||
m.pinger.Ping(uuid, body)
|
||||
m.logger.Printf("[INFO] DB dump completed: %d databases, %s total (%s)",
|
||||
len(results), formatBytes(totalSize), duration.Round(time.Millisecond))
|
||||
len(results), humanizeBytes(totalSize), duration.Round(time.Millisecond))
|
||||
} else {
|
||||
m.pinger.Fail(uuid, body)
|
||||
return fmt.Errorf("some database dumps failed")
|
||||
@@ -410,8 +410,11 @@ func (m *Manager) ListSnapshots(limit int) ([]SnapshotInfo, error) {
|
||||
}
|
||||
|
||||
// SetStackProvider sets the stack data provider for app data discovery.
|
||||
// C3: Write is protected by mutex since stackProvider is read by concurrent goroutines.
|
||||
func (m *Manager) SetStackProvider(provider StackDataProvider) {
|
||||
m.mu.Lock()
|
||||
m.stackProvider = provider
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetStackHDDMounts returns HDD mount paths for the named stack via the stack provider.
|
||||
@@ -551,8 +554,19 @@ func (m *Manager) RefreshCache(nextDBDump, nextBackup time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-check: if LastDBDump results have empty validation but files exist,
|
||||
// re-validate from disk. This handles controller restarts and race conditions.
|
||||
// Fill in dynamic fields under lock.
|
||||
// C1: lastDBDump mutation also happens here to prevent data races with GetFullStatus.
|
||||
// C2: snapshot history reversal happens before cachedStatus assignment (inside lock).
|
||||
m.mu.Lock()
|
||||
status.Running = m.running
|
||||
status.LastDBDump = m.lastDBDump
|
||||
status.LastBackup = m.lastBackup
|
||||
status.LastCheckTime = m.lastCheckTime
|
||||
status.LastCheckOK = m.lastCheckOK
|
||||
status.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory))
|
||||
copy(status.SnapshotHistory, m.snapshotHistory)
|
||||
|
||||
// C1: Cross-check lastDBDump results inside lock to prevent torn writes.
|
||||
if m.lastDBDump != nil && filesErr == nil {
|
||||
fileValidation := make(map[string]DumpValidation) // keyed by filename
|
||||
for _, f := range files {
|
||||
@@ -570,24 +584,15 @@ func (m *Manager) RefreshCache(nextDBDump, nextBackup time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fill in dynamic fields under lock
|
||||
m.mu.Lock()
|
||||
status.Running = m.running
|
||||
status.LastDBDump = m.lastDBDump
|
||||
status.LastBackup = m.lastBackup
|
||||
status.LastCheckTime = m.lastCheckTime
|
||||
status.LastCheckOK = m.lastCheckOK
|
||||
status.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory))
|
||||
copy(status.SnapshotHistory, m.snapshotHistory)
|
||||
m.cachedStatus = status
|
||||
m.cacheTime = time.Now()
|
||||
m.mu.Unlock()
|
||||
|
||||
// Reverse so newest first
|
||||
// C2: Reverse snapshot history before assigning to cachedStatus (inside lock).
|
||||
for i, j := 0, len(status.SnapshotHistory)-1; i < j; i, j = i+1, j-1 {
|
||||
status.SnapshotHistory[i], status.SnapshotHistory[j] = status.SnapshotHistory[j], status.SnapshotHistory[i]
|
||||
}
|
||||
|
||||
m.cachedStatus = status
|
||||
m.cacheTime = time.Now()
|
||||
m.mu.Unlock()
|
||||
|
||||
m.logger.Printf("[INFO] Backup status cache refreshed")
|
||||
}
|
||||
|
||||
@@ -616,8 +621,19 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
|
||||
status.Running = m.running
|
||||
status.NextDBDump = nextDBDump
|
||||
status.NextBackup = nextBackup
|
||||
status.LastDBDump = m.lastDBDump
|
||||
status.LastBackup = m.lastBackup
|
||||
// C4: Deep-copy lastDBDump and lastBackup so callers cannot mutate shared state.
|
||||
if m.lastDBDump != nil {
|
||||
copyDump := *m.lastDBDump
|
||||
if len(m.lastDBDump.Results) > 0 {
|
||||
copyDump.Results = make([]DumpResult, len(m.lastDBDump.Results))
|
||||
copy(copyDump.Results, m.lastDBDump.Results)
|
||||
}
|
||||
status.LastDBDump = ©Dump
|
||||
}
|
||||
if m.lastBackup != nil {
|
||||
copyBackup := *m.lastBackup
|
||||
status.LastBackup = ©Backup
|
||||
}
|
||||
// Update snapshot history
|
||||
status.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory))
|
||||
copy(status.SnapshotHistory, m.snapshotHistory)
|
||||
|
||||
@@ -227,27 +227,29 @@ func (r *CrossDriveRunner) runResticBackup(ctx context.Context, stackName, destB
|
||||
return fmt.Errorf("getting restic password: %w", err)
|
||||
}
|
||||
|
||||
// Write password to a temp file (restic requires --password-file or env var)
|
||||
// H6: Write password to temp file with safe cleanup order (close before deferred remove).
|
||||
pwFile, err := os.CreateTemp("", "felhom-crossdrive-pw-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating password file: %w", err)
|
||||
}
|
||||
defer os.Remove(pwFile.Name())
|
||||
pwPath := pwFile.Name()
|
||||
if _, err := pwFile.WriteString(password); err != nil {
|
||||
pwFile.Close()
|
||||
os.Remove(pwPath)
|
||||
return fmt.Errorf("writing password file: %w", err)
|
||||
}
|
||||
pwFile.Close()
|
||||
defer os.Remove(pwPath)
|
||||
|
||||
// Ensure repo is initialized
|
||||
if err := r.ensureResticRepo(ctx, repoPath, pwFile.Name()); err != nil {
|
||||
if err := r.ensureResticRepo(ctx, repoPath, pwPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Run restic backup
|
||||
args := []string{
|
||||
"backup", "--repo", repoPath,
|
||||
"--password-file", pwFile.Name(),
|
||||
"--password-file", pwPath,
|
||||
"--tag", stackName,
|
||||
"--tag", "cross-drive",
|
||||
}
|
||||
@@ -258,6 +260,26 @@ func (r *CrossDriveRunner) runResticBackup(ctx context.Context, stackName, destB
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("restic backup failed: %v (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// H5: Prune old snapshots to prevent unbounded accumulation.
|
||||
return r.pruneResticRepo(ctx, repoPath, pwPath)
|
||||
}
|
||||
|
||||
// pruneResticRepo forgets old snapshots in a cross-drive restic repo, keeping recent ones.
|
||||
func (r *CrossDriveRunner) pruneResticRepo(ctx context.Context, repoPath, pwPath string) error {
|
||||
args := []string{
|
||||
"forget", "--repo", repoPath,
|
||||
"--password-file", pwPath,
|
||||
"--keep-daily", "7",
|
||||
"--keep-weekly", "4",
|
||||
"--prune",
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "restic", args...)
|
||||
r.logger.Printf("[DEBUG] restic forget (prune): %s", repoPath)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
// Non-fatal: log warning but don't fail the backup
|
||||
r.logger.Printf("[WARN] restic forget failed for %s: %v (%s)", repoPath, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -294,13 +316,16 @@ func (r *CrossDriveRunner) updateStatus(stackName, status, errMsg string, durati
|
||||
}
|
||||
|
||||
// dirSizeBytes returns the total byte size of all files under path.
|
||||
// H7: Walk errors are now propagated instead of silently swallowed.
|
||||
func dirSizeBytes(path string) (int64, error) {
|
||||
var total int64
|
||||
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
if err != nil {
|
||||
return err // propagate permission/IO errors
|
||||
}
|
||||
if !info.IsDir() {
|
||||
total += info.Size()
|
||||
}
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
return total, err
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -224,7 +225,7 @@ func DumpOne(ctx context.Context, db DiscoveredDB, dumpDir string, logger *log.L
|
||||
result.Validation = ValidateDump(finalPath, db.DBType)
|
||||
|
||||
logger.Printf("[INFO] DB dump: %s → %s (%s, %s, %d tables)", db.ContainerName, filename,
|
||||
formatBytes(stat.Size()), result.Duration.Round(time.Millisecond), result.Validation.TableCount)
|
||||
humanizeBytes(stat.Size()), result.Duration.Round(time.Millisecond), result.Validation.TableCount)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -248,18 +249,45 @@ func ValidateDump(filePath string, dbType DBType) DumpValidation {
|
||||
return v
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
// H1: Use bufio.Scanner to read line-by-line instead of loading entire file into memory.
|
||||
// Large dumps (500MB+) would cause massive allocations on every 5-min cache refresh.
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
v.Error = fmt.Sprintf("read failed: %v", err)
|
||||
log.Printf("[WARN] ValidateDump FAIL: %s — %s", filePath, v.Error)
|
||||
return v
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
content := string(data)
|
||||
scanner := bufio.NewScanner(f)
|
||||
// Increase token buffer for very long lines (some SQL lines can be large)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
|
||||
// Count CREATE TABLE statements
|
||||
lineNum := 0
|
||||
headerFound := false
|
||||
tableCount := 0
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
lineNum++
|
||||
|
||||
// Header check — scan first 10 lines for expected dump header
|
||||
// MariaDB 11.4+ prepends a sandbox comment before the header line
|
||||
if lineNum <= 10 && !headerFound {
|
||||
switch dbType {
|
||||
case DBTypeMariaDB:
|
||||
if strings.HasPrefix(line, "-- MariaDB dump") ||
|
||||
strings.HasPrefix(line, "-- MySQL dump") ||
|
||||
strings.HasPrefix(line, "-- mysqldump") {
|
||||
headerFound = true
|
||||
}
|
||||
case DBTypePostgres:
|
||||
if strings.HasPrefix(line, "-- PostgreSQL database dump") {
|
||||
headerFound = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count CREATE TABLE statements
|
||||
upper := strings.ToUpper(strings.TrimSpace(line))
|
||||
if strings.HasPrefix(upper, "CREATE TABLE") {
|
||||
tableCount++
|
||||
@@ -267,30 +295,6 @@ func ValidateDump(filePath string, dbType DBType) DumpValidation {
|
||||
}
|
||||
v.TableCount = tableCount
|
||||
|
||||
// Header check — scan first 10 lines for expected dump header
|
||||
// MariaDB 11.4+ prepends a sandbox comment before the header line
|
||||
headerFound := false
|
||||
lines := strings.SplitN(content, "\n", 11) // at most 11 parts = 10 lines
|
||||
for i, line := range lines {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
switch dbType {
|
||||
case DBTypeMariaDB:
|
||||
if strings.HasPrefix(line, "-- MariaDB dump") ||
|
||||
strings.HasPrefix(line, "-- MySQL dump") ||
|
||||
strings.HasPrefix(line, "-- mysqldump") {
|
||||
headerFound = true
|
||||
}
|
||||
case DBTypePostgres:
|
||||
if strings.HasPrefix(line, "-- PostgreSQL database dump") {
|
||||
headerFound = true
|
||||
}
|
||||
}
|
||||
if headerFound {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !headerFound {
|
||||
switch dbType {
|
||||
case DBTypeMariaDB:
|
||||
@@ -304,7 +308,7 @@ func ValidateDump(filePath string, dbType DBType) DumpValidation {
|
||||
|
||||
if tableCount == 0 {
|
||||
v.Error = "no CREATE TABLE statements found"
|
||||
log.Printf("[WARN] ValidateDump FAIL: %s — %s (header was found, scanned %d lines)", filePath, v.Error, len(strings.Split(content, "\n")))
|
||||
log.Printf("[WARN] ValidateDump FAIL: %s — %s (header was found, scanned %d lines)", filePath, v.Error, lineNum)
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -325,10 +329,11 @@ func ListDumpFiles(dumpDir string) ([]DumpFileInfo, error) {
|
||||
|
||||
var files []DumpFileInfo
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||
// M2: Check .tmp before .sql to correctly skip ".sql.tmp" temp files (was dead code before).
|
||||
if e.IsDir() || strings.HasSuffix(e.Name(), ".tmp") {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(e.Name(), ".tmp") {
|
||||
if !strings.HasSuffix(e.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -464,20 +469,4 @@ func cleanupTmpFiles(dumpDir string, logger *log.Logger) {
|
||||
}
|
||||
}
|
||||
|
||||
func formatBytes(b int64) string {
|
||||
const (
|
||||
kb = 1024
|
||||
mb = 1024 * kb
|
||||
gb = 1024 * mb
|
||||
)
|
||||
switch {
|
||||
case b >= gb:
|
||||
return fmt.Sprintf("%.1f GB", float64(b)/float64(gb))
|
||||
case b >= mb:
|
||||
return fmt.Sprintf("%.1f MB", float64(b)/float64(mb))
|
||||
case b >= kb:
|
||||
return fmt.Sprintf("%.1f KB", float64(b)/float64(kb))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
}
|
||||
// M1: formatBytes removed — use humanizeBytes() from appdata.go (same package, no duplication).
|
||||
|
||||
@@ -173,7 +173,7 @@ func (r *ResticManager) Snapshot(paths []string, tags []string) (*SnapshotResult
|
||||
result.SnapshotID = msg.SnapshotID
|
||||
result.FilesNew = msg.FilesNew
|
||||
result.FilesChanged = msg.FilesChanged
|
||||
result.DataAdded = formatBytes(msg.DataAdded)
|
||||
result.DataAdded = humanizeBytes(msg.DataAdded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ func (r *ResticManager) Stats() (*RepoStats, error) {
|
||||
TotalSize uint64 `json:"total_size"`
|
||||
}
|
||||
if json.Unmarshal(out, &raw) == nil {
|
||||
stats.TotalSize = formatBytes(int64(raw.TotalSize))
|
||||
stats.TotalSize = humanizeBytes(int64(raw.TotalSize))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package backup
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// snapshotIDRe validates restic snapshot IDs: 8-64 lowercase hex characters.
|
||||
var snapshotIDRe = regexp.MustCompile(`^[0-9a-f]{8,64}$`)
|
||||
|
||||
// RestoreApp restores an app's HDD data from a restic snapshot.
|
||||
func (m *Manager) RestoreApp(stackName, snapshotID string) error {
|
||||
@@ -18,20 +24,10 @@ func (m *Manager) RestoreApp(stackName, snapshotID string) error {
|
||||
return fmt.Errorf("no HDD data paths found for %s", stackName)
|
||||
}
|
||||
|
||||
// Validate snapshot exists
|
||||
snapshots, err := m.restic.ListSnapshots(100)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing snapshots: %w", err)
|
||||
}
|
||||
found := false
|
||||
for _, s := range snapshots {
|
||||
if s.ID == snapshotID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("snapshot %s not found", snapshotID)
|
||||
// H4: Validate snapshot ID format by regex instead of listing all snapshots (list caps at 100).
|
||||
// restic restore will return a clear error if the snapshot ID doesn't exist.
|
||||
if !snapshotIDRe.MatchString(snapshotID) {
|
||||
return fmt.Errorf("invalid snapshot ID: must be 8-64 lowercase hex characters")
|
||||
}
|
||||
|
||||
// Use the running flag to prevent concurrent backup/restore
|
||||
|
||||
Reference in New Issue
Block a user