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
+44
View File
@@ -1,5 +1,49 @@
## Changelog
### What was just completed (2026-02-17 session 39)
- **v0.12.3 — Security & Correctness Bug Fixes (TASK.md — 33 bugs fixed):**
**CRITICAL fixes (data races, security vulnerabilities):**
- **C1: Data race in RefreshCache** — Moved `m.lastDBDump.Results` mutation inside `m.mu.Lock()`. Was previously mutating shared state without the lock, causing potential torn writes visible to `GetFullStatus()` goroutines. (`internal/backup/backup.go`)
- **C2: SnapshotHistory reversed after unlock** — Moved snapshot reversal loop before `m.cachedStatus = status` (inside the lock). Previously reversed after `Unlock()`, so `m.cachedStatus.SnapshotHistory` was reversed without protection. (`internal/backup/backup.go`)
- **C3: SetStackProvider write without lock** — `m.stackProvider = provider` now wrapped in `m.mu.Lock()`. Read by `resolveAppBackupPaths()` concurrently. (`internal/backup/backup.go`)
- **C4: GetFullStatus shallow-copies mutable pointers** — `LastDBDump` and `LastBackup` are now deep-copied (struct + Results slice) so callers cannot mutate shared manager state. (`internal/backup/backup.go`)
- **C5: IsSystemDisk 8-bit major mask** — Replaced `>> 8 & 0xff` with `unix.Major()`/`unix.Minor()` (12-bit extraction). Also compares disk-portion of minor (groups of 16) to correctly distinguish physical disks of the same type. Adds `golang.org/x/sys/unix` import. (`internal/storage/safety_linux.go`)
- **C6: No /dev/ prefix validation on DevicePath** — `FormatAndMount` now validates `DevicePath` starts with `/dev/` and does not contain `..` before any disk operations. (`internal/storage/format_linux.go`)
- **C7: Path traversal in extractName** — `extractName()` now rejects empty string, `.`, `..`, and names containing `/` or `\`. (`internal/api/router.go`)
- **C8: Path traversal in TargetPath** — Migration API validates `TargetPath` against registered storage paths from settings before starting migration job. (`internal/web/storage_handlers.go`)
- **C9: Path traversal in DestinationPath** — Cross-drive backup config API validates `DestinationPath` against registered storage paths when `enabled=true`. (`internal/api/router.go`)
- **C10: Path traversal in ParseComposeHDDMounts** — `filepath.Clean()` applied before prefix check; uses separator-aware check `cleanHDD + string(filepath.Separator)` to prevent `${HDD_PATH}/../../etc/passwd` escaping. (`internal/stacks/delete.go`)
**HIGH fixes (logic errors, resource leaks):**
- **H1: ValidateDump reads entire file into memory** — Replaced `os.ReadFile` with `bufio.Scanner` reading line-by-line. 256KB per-line buffer prevents OOM on large (500MB+) SQL dumps during 5-min cache refresh. (`internal/backup/dbdump.go`)
- **H2/H3: Double du invocation per mount + no timeout** — Replaced `appDirSizeHuman()`+`appDirSizeBytes()` with single `appDirSize()` function using `exec.CommandContext` with 30s timeout. Halves subprocess calls per mount point. (`internal/backup/appdata.go`)
- **H4: Snapshot validation only checks first 100** — Replaced `ListSnapshots(100)` existence check with regex validation (`^[0-9a-f]{8,64}$`). Allows restoring any snapshot; `restic restore` returns a clear error for non-existent IDs. (`internal/backup/restore.go`)
- **H5: No pruning for cross-drive restic repos** — Added `pruneResticRepo()` called after each successful cross-drive restic backup (`forget --keep-daily 7 --keep-weekly 4 --prune`). Non-fatal — logs warning on failure. (`internal/backup/crossdrive.go`)
- **H6: Temp password file management** — Reorganized temp file lifecycle: close before deferred remove, remove-on-write-error cleanup. (`internal/backup/crossdrive.go`)
- **H7: dirSizeBytes swallows walk errors** — `filepath.Walk` callback now returns errors instead of `nil`, propagating permission/IO issues. (`internal/backup/crossdrive.go`)
- **H8: Non-atomic fstab write** — `AppendFstabEntry` now reads existing fstab, writes to `.tmp`, then atomically renames. Crash-safe. (`internal/storage/safety_linux.go`)
- **H9: IsDeviceMounted naive prefix matching** — After prefix check, next character must be digit (`0-9`) or `p` (partition marker). Prevents `/dev/sdb` matching `/dev/sdba`. (`internal/storage/safety_linux.go`)
- **H10: eMMC device mapping bug** — `partitionToParentDisk` now handles `mmcblk0p1 → mmcblk0` and `nvme0n1p1 → nvme0n1` patterns. Uses `LastIndex("p")` with digit-suffix check before falling back to `TrimRight("0-9")`. (`internal/storage/scan_linux.go`)
- **H11: Data race on bytesCopied in rsync error path** — Error return path in `runRsync` now reads `bytesCopied` under mutex lock. (`internal/storage/migrate.go`)
- **H13: Path prefix match without separator** — Migration source path check now uses `srcPath == req.CurrentHDDPath || strings.HasPrefix(srcPath, req.CurrentHDDPath+"/")`. Prevents `/mnt/hdd` matching `/mnt/hdd_backup/data`. (`internal/storage/migrate.go`)
- **H14: DeleteStack continues after failed compose down** — `docker compose down` failure now returns an error immediately, preventing deletion of files while containers are still running. (`internal/stacks/delete.go`)
- **H16: exec.Command("docker") without timeout** — `syncFileBrowserMounts()` now uses `exec.CommandContext` with 60s timeout. (`internal/web/handlers.go`)
- **H17: SetNotificationPrefs stores caller's pointer** — Deep-copies `NotificationPrefs` struct and `EnabledEvents` slice before storing. (`internal/settings/settings.go`)
- **H18: wipefs error silently discarded** — wipefs failure logged as warning via progress channel; continues (wipefs may not be installed). (`internal/storage/format_linux.go`)
- **H19: Orphaned fstab entry on mount failure** — New `RemoveFstabEntry()` function atomically removes UUID entry. Called as rollback on `mount` failure and `findmnt` verify failure. (`internal/storage/safety_linux.go`, `format_linux.go`)
**MEDIUM fixes (edge cases, code quality):**
- **M1: formatBytes duplicate in dbdump.go** — Removed `formatBytes()` from `dbdump.go`; all callers (backup.go, restic.go, dbdump.go) now use `humanizeBytes()` from appdata.go. (`internal/backup/dbdump.go`, `backup.go`, `restic.go`)
- **M2: Dead code .tmp suffix check** — Reordered filter in `ListDumpFiles`: `.tmp` check now comes before `.sql` check to correctly skip `.sql.tmp` temp files (was unreachable before). (`internal/backup/dbdump.go`)
- **M3: sizeBytes() returns 0 for string types** — Added `case string:` to `sizeBytes()` using `strconv.ParseUint`. (`internal/storage/scan_linux.go`)
- **M6: Dead elapsed variable** — Removed `_ = elapsed`; elapsed time now shown inline in the "done" progress message. (`internal/storage/migrate.go`)
- **M7: time.LoadLocation error silently discarded** — Two locations in handlers.go now handle `LoadLocation` error, falling back to `time.UTC`. (`internal/web/handlers.go`)
- **M10: filterSnapshotsByPaths imprecise prefix** — Added `pathCovers()` helper using separator-aware prefix check. Prevents `/mnt/hdd_1` matching `/mnt/hdd_10/data`. (`internal/api/router.go`)
- **M11: XSS in editStorageLabel innerHTML** — `cancelEditLabel()` in settings.html now uses DOM manipulation (`document.createElement`, `.textContent`) instead of `innerHTML` for the label text. (`internal/web/templates/settings.html`)
**Files modified (15):** `internal/backup/backup.go`, `internal/backup/appdata.go`, `internal/backup/dbdump.go`, `internal/backup/restore.go`, `internal/backup/crossdrive.go`, `internal/backup/restic.go`, `internal/storage/safety_linux.go`, `internal/storage/format_linux.go`, `internal/storage/scan_linux.go`, `internal/storage/migrate.go`, `internal/stacks/delete.go`, `internal/api/router.go`, `internal/web/handlers.go`, `internal/web/storage_handlers.go`, `internal/settings/settings.go`, `internal/web/templates/settings.html`
### What was just completed (2026-02-17 session 38)
- **v0.12.2 — Restore Section Simplification (Bug 4 from v0.12.1 TASK.md):**
- **Feature: Snapshot filtering by app** — `GET /api/backup/snapshots?stack={name}` now filters snapshots to those whose `Paths` overlap with the app's HDD mount paths. Uses prefix matching (snapshot path is prefix of required, or vice versa). New `filterSnapshotsByPaths()` helper in `internal/api/router.go`. Manager gains `GetStackHDDMounts()` method to expose stackProvider's mount resolution.
+31 -2
View File
@@ -473,13 +473,14 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
// filterSnapshotsByPaths returns only snapshots whose Paths overlap with requiredPaths.
// A snapshot matches if any of its paths is a prefix of (or prefixed by) any required path.
// M10: Uses separator-aware prefix check to prevent /mnt/hdd_1 matching /mnt/hdd_10/data.
func filterSnapshotsByPaths(snapshots []backup.SnapshotInfo, requiredPaths []string) []backup.SnapshotInfo {
var filtered []backup.SnapshotInfo
outer:
for _, snap := range snapshots {
for _, required := range requiredPaths {
for _, sp := range snap.Paths {
if strings.HasPrefix(required, sp) || strings.HasPrefix(sp, required) {
if pathCovers(required, sp) || pathCovers(sp, required) {
filtered = append(filtered, snap)
continue outer
}
@@ -489,6 +490,14 @@ outer:
return filtered
}
// pathCovers returns true if base is equal to or a directory-prefix of target.
func pathCovers(base, target string) bool {
if base == target {
return true
}
return strings.HasPrefix(target, strings.TrimRight(base, "/")+"/")
}
// --- Metrics handlers ---
func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
@@ -612,6 +621,21 @@ func (r *Router) saveCrossBackupConfig(w http.ResponseWriter, req *http.Request,
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "schedule must be 'daily', 'weekly', or 'manual'"})
return
}
// C9: Validate DestinationPath against registered storage paths to prevent path traversal.
if body.Enabled && body.DestinationPath != "" {
registeredPaths := r.sett.GetStoragePaths()
validDest := false
for _, sp := range registeredPaths {
if body.DestinationPath == sp.Path {
validDest = true
break
}
}
if !validDest {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "destination_path must be a registered storage path"})
return
}
}
// Preserve existing runtime status
existing := r.sett.GetCrossDriveConfig(name)
@@ -768,7 +792,12 @@ func trimSegment(path, prefix string) string {
func extractName(path, suffix string) string {
s := strings.TrimPrefix(path, "/stacks/")
return strings.TrimSuffix(s, suffix)
name := strings.TrimSuffix(s, suffix)
// C7: Reject path traversal characters — name is used in file paths and Docker commands.
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") {
return ""
}
return name
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
+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
+9 -1
View File
@@ -213,10 +213,18 @@ func (s *Settings) GetNotificationPrefs() *NotificationPrefs {
}
// SetNotificationPrefs updates notification preferences and saves to disk.
// H17: Deep-copies prefs so caller mutations after the call don't affect stored state.
func (s *Settings) SetNotificationPrefs(prefs *NotificationPrefs) error {
s.mu.Lock()
defer s.mu.Unlock()
s.Notifications = prefs
copy := *prefs
if len(prefs.EnabledEvents) > 0 {
copy.EnabledEvents = make([]string, len(prefs.EnabledEvents))
for i, e := range prefs.EnabledEvents {
copy.EnabledEvents[i] = e
}
}
s.Notifications = &copy
return s.save()
}
+9 -6
View File
@@ -84,11 +84,12 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
hddMounts := ParseComposeHDDMounts(stack.ComposePath, hddPath)
// Step 2: Run docker compose down --rmi local --volumes
// H14: Return error if docker compose down fails — continuing would leave orphaned containers.
env := m.stackEnv(stackDir)
output, err := m.composeExecCustomEnv(stackDir, env, "down", "--rmi", "local", "--volumes")
if err != nil {
m.logger.Printf("[WARN] docker compose down for %s had errors: %v (output: %s)", name, err, truncateStr(output, 200))
// Continue anyway — the stack dir will be removed
m.logger.Printf("[ERROR] docker compose down for %s failed: %v (output: %s)", name, err, truncateStr(output, 200))
return resp, fmt.Errorf("docker compose down failed for %s: %w", name, err)
}
// Step 3: Identify removed volumes from compose output
@@ -244,12 +245,14 @@ func ParseComposeHDDMounts(composePath, hddPath string) []string {
// Resolve ${HDD_PATH} variable reference
hostPath = strings.ReplaceAll(hostPath, "${HDD_PATH}", hddPath)
// Check if this is an HDD mount
if !strings.HasPrefix(hostPath, hddPath) {
// C10: Clean path BEFORE prefix check to prevent traversal like ${HDD_PATH}/../../etc/passwd.
cleanPath := filepath.Clean(hostPath)
cleanHDD := filepath.Clean(hddPath)
// Check if this is an HDD mount (must be cleanHDD itself or a direct subpath)
if cleanPath != cleanHDD && !strings.HasPrefix(cleanPath, cleanHDD+string(filepath.Separator)) {
continue
}
cleanPath := filepath.Clean(hostPath)
if !seen[cleanPath] {
seen[cleanPath] = true
mounts = append(mounts, cleanPath)
+16 -1
View File
@@ -36,6 +36,13 @@ func FormatAndMount(req FormatRequest, progress chan<- FormatProgress) (string,
if err := ValidateMountName(req.MountName); err != nil {
return "", fail("validating", "Érvénytelen csatlakoztatási név", err)
}
// C6: Validate DevicePath to prevent path traversal from user-supplied input.
if !strings.HasPrefix(req.DevicePath, "/dev/") {
return "", fail("validating", "Érvénytelen eszközútvonal: /dev/-vel kell kezdődnie", fmt.Errorf("invalid device path: must start with /dev/"))
}
if strings.Contains(req.DevicePath, "..") {
return "", fail("validating", "Érvénytelen eszközútvonal: nem tartalmazhat ..-t", fmt.Errorf("invalid device path: must not contain .."))
}
if _, err := os.Stat(HostDevicePath(req.DevicePath)); err != nil {
return "", fail("validating", "Az eszköz nem létezik: "+req.DevicePath, err)
}
@@ -70,8 +77,12 @@ func FormatAndMount(req FormatRequest, progress chan<- FormatProgress) (string,
partDev := req.DevicePath
if req.CreatePartition {
// Wipe existing partition table and filesystem signatures first
// H18: Log wipefs errors instead of silently discarding them.
send("partitioning", fmt.Sprintf("wipefs -a %s ...", HostDevicePath(req.DevicePath)), 12)
_ = exec.Command("wipefs", "-a", HostDevicePath(req.DevicePath)).Run()
if err := exec.Command("wipefs", "-a", HostDevicePath(req.DevicePath)).Run(); err != nil {
// Non-fatal: some systems don't have wipefs; continue anyway
send("partitioning", fmt.Sprintf("[WARN] wipefs sikertelen %s: %v (folytatás)", req.DevicePath, err), 13)
}
time.Sleep(500 * time.Millisecond)
// Create GPT with single partition spanning whole disk.
@@ -146,12 +157,16 @@ func FormatAndMount(req FormatRequest, progress chan<- FormatProgress) (string,
send("mounting", fmt.Sprintf("mount -t ext4 %s %s ...", HostDevicePath(partDev), mountPath), 70)
if out, err := exec.Command("mount", "-t", "ext4", "-o", "defaults,noatime",
HostDevicePath(partDev), mountPath).CombinedOutput(); err != nil {
// H19: Roll back fstab entry to prevent orphaned entry that hangs system on reboot.
_ = RemoveFstabEntry(FstabPath, uuid)
return "", fail("mounting", "Csatlakoztatás sikertelen: "+string(out), err)
}
// Verify mount actually worked (don't just trust exit code)
verifyOut, verifyErr := exec.Command("findmnt", "-n", "-o", "SOURCE", "--target", mountPath).Output()
if verifyErr != nil || strings.TrimSpace(string(verifyOut)) == "" {
// H19: Also roll back fstab if mount verify fails.
_ = RemoveFstabEntry(FstabPath, uuid)
return "", fail("mounting", "A csatlakoztatás nem ellenőrizhető: mount sikerült, de a meghajtó nem látható",
fmt.Errorf("mount point %s not found after mount", mountPath))
}
+10 -6
View File
@@ -124,8 +124,9 @@ func MigrateAppData(
// --- Step 3: rsync ---
var bytesCopied int64
for i, srcPath := range req.HDDMounts {
// Determine destination path: replace CurrentHDDPath prefix with TargetPath
if !strings.HasPrefix(srcPath, req.CurrentHDDPath) {
// Determine destination path: replace CurrentHDDPath prefix with TargetPath.
// H13: Require trailing separator to prevent /mnt/hdd matching /mnt/hdd_backup/data.
if srcPath != req.CurrentHDDPath && !strings.HasPrefix(srcPath, req.CurrentHDDPath+"/") {
continue
}
relPath := strings.TrimPrefix(srcPath, req.CurrentHDDPath)
@@ -173,11 +174,10 @@ func MigrateAppData(
return fail("starting", "Alkalmazás indítása sikertelen az új tárolóról", err)
}
elapsed := int(time.Since(start).Seconds())
send("done",
fmt.Sprintf("Áthelyezés kész! Az alkalmazás az új tárolóról fut. (Régi adat: %s)", req.CurrentHDDPath),
fmt.Sprintf("Áthelyezés kész! Az alkalmazás az új tárolóról fut. (Régi adat: %s, idő: %ds)",
req.CurrentHDDPath, int(time.Since(start).Seconds())),
100, bytesCopied, totalBytes)
_ = elapsed
return nil
}
@@ -240,7 +240,11 @@ func runRsync(srcPath, dstPath string, totalBytes, prevCopied int64, basePct int
io.Copy(&stderrBuf, stderr)
if err := cmd.Wait(); err != nil {
return bytesCopied, fmt.Errorf("rsync failed: %w — %s", err, stderrBuf.String())
// H11: Read bytesCopied under lock to avoid data race with the progress goroutine.
mu.Lock()
copied := bytesCopied
mu.Unlock()
return copied, fmt.Errorf("rsync failed: %w — %s", err, stderrBuf.String())
}
mu.Lock()
+71 -16
View File
@@ -9,6 +9,8 @@ import (
"strings"
"syscall"
"time"
"golang.org/x/sys/unix"
)
// IsSystemDisk checks if the given device path overlaps with the root filesystem device.
@@ -26,14 +28,22 @@ func IsSystemDisk(devicePath string) (bool, error) {
return false, fmt.Errorf("cannot stat %s: %w", devicePath, err)
}
// Compare major device numbers
rootMajor := rootStat.Dev >> 8 & 0xff
devMajor := devStat.Rdev >> 8 & 0xff
if rootMajor == devMajor {
return true, nil
}
// C5: Use unix.Major/Minor for correct 12-bit extraction (old 0xff mask truncated high bits).
// Also compare the disk portion of the minor to distinguish separate physical disks of the
// same type (e.g., sda and sdb both have major 8, but different disk-minor groups of 16).
rootMajor := unix.Major(rootStat.Dev)
rootMinor := unix.Minor(rootStat.Dev)
devMajor := unix.Major(devStat.Rdev)
devMinor := unix.Minor(devStat.Rdev)
return false, nil
if rootMajor != devMajor {
return false, nil
}
// Same major — compare disk groups (each disk gets 16 minor numbers on SCSI/SATA,
// e.g., sda=0-15, sdb=16-31; NVMe uses similar grouping).
rootDiskGroup := rootMinor / 16
devDiskGroup := devMinor / 16
return rootDiskGroup == devDiskGroup, nil
}
// IsDeviceMounted checks if a device or any of its partitions is currently mounted.
@@ -51,9 +61,17 @@ func IsDeviceMounted(devicePath string) (bool, error) {
}
dev := fields[0]
devBase := filepath.Base(dev)
if devBase == base || strings.HasPrefix(devBase, base) {
// H9: Require exact match or that the suffix after base is a digit or 'p' (partition marker).
// Prevents /dev/sdb matching /dev/sdba (hypothetical device) or /dev/sdb_backup (bind).
if devBase == base {
return true, nil
}
if strings.HasPrefix(devBase, base) {
next := devBase[len(base)]
if next >= '0' && next <= '9' || next == 'p' {
return true, nil
}
}
}
return false, nil
}
@@ -87,16 +105,53 @@ func BackupFstab(fstabPath string) error {
return os.WriteFile(backupPath, data, 0644)
}
// AppendFstabEntry appends a UUID-based fstab entry.
// AppendFstabEntry appends a UUID-based fstab entry atomically (write tmp + rename).
// H8: Direct write to /etc/fstab risks corruption on crash — use atomic write pattern.
func AppendFstabEntry(fstabPath, uuid, mountPoint, fsType, options string) error {
entry := fmt.Sprintf("\nUUID=%s\t%s\t%s\t%s\t0 2\n", uuid, mountPoint, fsType, options)
f, err := os.OpenFile(fstabPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("cannot open fstab for writing: %w", err)
// Read existing content
existing, err := os.ReadFile(fstabPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("cannot read fstab: %w", err)
}
defer f.Close()
if _, err := f.WriteString(entry); err != nil {
return fmt.Errorf("cannot write fstab entry: %w", err)
entry := fmt.Sprintf("\nUUID=%s\t%s\t%s\t%s\t0 2\n", uuid, mountPoint, fsType, options)
newContent := append(existing, []byte(entry)...)
// Write to .tmp then rename — atomic on same filesystem
tmpPath := fstabPath + ".tmp"
if err := os.WriteFile(tmpPath, newContent, 0644); err != nil {
return fmt.Errorf("cannot write fstab tmp file: %w", err)
}
if err := os.Rename(tmpPath, fstabPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("cannot rename fstab tmp file: %w", err)
}
return nil
}
// RemoveFstabEntry removes any line containing the given UUID from fstab, atomically.
// H19: Called as rollback if mount fails after fstab was written.
func RemoveFstabEntry(fstabPath, uuid string) error {
data, err := os.ReadFile(fstabPath)
if err != nil {
return fmt.Errorf("cannot read fstab: %w", err)
}
var kept []string
for _, line := range strings.Split(string(data), "\n") {
if !strings.Contains(line, "UUID="+uuid) {
kept = append(kept, line)
}
}
newContent := strings.Join(kept, "\n")
tmpPath := fstabPath + ".tmp"
if err := os.WriteFile(tmpPath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("cannot write fstab tmp file: %w", err)
}
if err := os.Rename(tmpPath, fstabPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("cannot rename fstab tmp file: %w", err)
}
return nil
}
+22 -7
View File
@@ -34,6 +34,10 @@ func (d *lsblkDevice) sizeBytes() int64 {
switch v := d.Size.(type) {
case float64:
return int64(v)
case string:
// M3: lsblk can return size as a string on some kernel versions.
n, _ := strconv.ParseUint(v, 10, 64)
return int64(n)
}
return 0
}
@@ -157,18 +161,29 @@ func getSystemDiskNames() map[string]bool {
}
// partitionToParentDisk extracts the parent disk name from a partition device path.
// "/dev/sda2" → "sda", "/dev/nvme0n1p2" → "nvme0n1"
// "/dev/sda2" → "sda", "/dev/nvme0n1p2" → "nvme0n1", "/dev/mmcblk0p1" → "mmcblk0"
func partitionToParentDisk(devPath string) string {
name := filepath.Base(devPath)
// NVMe: nvme0n1p2 → nvme0n1
if strings.Contains(name, "nvme") {
if idx := strings.LastIndex(name, "p"); idx > 0 {
if _, err := strconv.Atoi(name[idx+1:]); err == nil {
return name[:idx]
// H10: Handle mmcblk0p1 and nvme0n1p1 patterns where 'p' separates disk# from partition#.
// The prefix before 'p' must end with a digit (e.g., mmcblk0, nvme0n1) to be a disk number.
if idx := strings.LastIndex(name, "p"); idx > 0 {
prefix := name[:idx]
suffix := name[idx+1:]
if len(suffix) > 0 && suffix[0] >= '0' && suffix[0] <= '9' &&
len(prefix) > 0 && prefix[len(prefix)-1] >= '0' && prefix[len(prefix)-1] <= '9' {
// Verify suffix is all digits (partition number, not part of device name)
allDigits := true
for _, c := range suffix {
if c < '0' || c > '9' {
allDigits = false
break
}
}
if allDigits {
return prefix // e.g., mmcblk0, nvme0n1
}
}
return name
}
// Standard: sda2 → sda, sdb1 → sdb
+15 -4
View File
@@ -1,6 +1,7 @@
package web
import (
"context"
"fmt"
"net/http"
"net/url"
@@ -446,7 +447,11 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
}
if cfg.LastRun != "" {
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
loc, _ := time.LoadLocation("Europe/Budapest")
// M7: Handle LoadLocation error — fall back to UTC if tzdata missing.
loc, err := time.LoadLocation("Europe/Budapest")
if err != nil {
loc = time.UTC
}
item.LastRunShort = t.In(loc).Format("01-02 15:04")
}
}
@@ -538,7 +543,11 @@ func (s *Server) buildAppBackupRows(
crossConfigs map[string]*settings.CrossDriveBackup,
destLabels map[string]string,
) []AppBackupRow {
loc, _ := time.LoadLocation("Europe/Budapest")
// M7: Handle LoadLocation error — fall back to UTC if tzdata missing.
loc, err := time.LoadLocation("Europe/Budapest")
if err != nil {
loc = time.UTC
}
// Build a quick lookup: which stacks have a DB dump?
dbStacks := make(map[string]bool)
@@ -1243,8 +1252,10 @@ func (s *Server) syncFileBrowserMounts() {
return
}
// Recreate container
cmd := exec.Command("docker", "compose", "up", "-d", "--remove-orphans")
// Recreate container — H16: use 60s timeout to prevent hanging indefinitely.
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "compose", "up", "-d", "--remove-orphans")
cmd.Dir = filepath.Dir(composePath)
if out, err := cmd.CombinedOutput(); err != nil {
s.logger.Printf("[ERROR] Failed to recreate FileBrowser: %s — %v", string(out), err)
@@ -409,6 +409,20 @@ func (s *Server) storageMigrateAPIHandler(w http.ResponseWriter, r *http.Request
return
}
// C8: Validate TargetPath against registered storage paths to prevent path traversal.
registeredPaths := s.settings.GetStoragePaths()
validTarget := false
for _, sp := range registeredPaths {
if req.TargetPath == sp.Path {
validTarget = true
break
}
}
if !validTarget {
jsonError(w, "Érvénytelen célútvonal: nem regisztrált adattároló", http.StatusBadRequest)
return
}
mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, currentHDDPath)
if len(mounts) == 0 {
jsonError(w, "Az alkalmazáshoz nem találhatók HDD csatlakozások", http.StatusBadRequest)
@@ -291,8 +291,20 @@ function editStorageLabel(path, currentLabel) {
function cancelEditLabel(path, label) {
var wrap = document.getElementById('label-wrap-' + path);
if (!wrap) return;
wrap.innerHTML = '<span class="storage-path-label" id="label-display-' + path + '">' + label + '</span>' +
' <button class="btn btn-xs btn-ghost" onclick="editStorageLabel(\'' + path + '\', \'' + label.replace(/'/g, "\\'") + '\')" title="Átnevezés">✏️</button>';
// M11: Use DOM manipulation with textContent to prevent XSS if label contains HTML.
wrap.innerHTML = '';
var span = document.createElement('span');
span.className = 'storage-path-label';
span.id = 'label-display-' + path;
span.textContent = label;
var btn = document.createElement('button');
btn.className = 'btn btn-xs btn-ghost';
btn.setAttribute('title', 'Átnevezés');
btn.textContent = '✏️';
btn.addEventListener('click', function() { editStorageLabel(path, label); });
wrap.appendChild(span);
wrap.appendChild(document.createTextNode(' '));
wrap.appendChild(btn);
}
</script>
{{template "layout_end" .}}