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:
2026-02-17 21:10:55 +01:00
parent 20b3a22c88
commit 93d9b474f1
17 changed files with 390 additions and 164 deletions
+15 -25
View File
@@ -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.
+37 -21
View File
@@ -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 = &copyDump
}
if m.lastBackup != nil {
copyBackup := *m.lastBackup
status.LastBackup = &copyBackup
}
// Update snapshot history
status.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory))
copy(status.SnapshotHistory, m.snapshotHistory)
+32 -7
View File
@@ -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
+38 -49
View File
@@ -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).
+2 -2
View File
@@ -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))
}
}
+11 -15
View File
@@ -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