v0.75.0: gate userdata MkdirAll on a live mountpoint (no writes into an absent drive)

Belt (ensureUserdataMounts) + FileBrowser sync skip ensure/mount when an external
drive root is not a live mountpoint -> no 'mkdir userdata: permission denied' + no
rootfs-shadow during a drive-absent window. System/local path never gated. Reuses
system.IsMountPoint; matches planDriveGates external-only rule. T1-T4 + red-proofs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 20:47:08 +02:00
parent 9d47232d7f
commit 0b2605c5a8
5 changed files with 150 additions and 4 deletions
@@ -0,0 +1,25 @@
package web
import "testing"
// T4: skipFileBrowserPath skips ONLY an external drive path (under StableParentDir) that is not a live
// mountpoint. A mounted external path and any system/local path are never skipped.
// Companion red-proof: dropping the gate (always-false) makes the absent-usb case fail.
func TestSkipFileBrowserPath(t *testing.T) {
// flash is mounted; usb is not.
isMount := func(p string) bool { return p == StableParentDir+"/flash" }
cases := []struct {
path string
want bool
}{
{StableParentDir + "/flash", false}, // external + mounted → keep
{StableParentDir + "/usb", true}, // external + NOT mounted → skip
{"/mnt/sys_drive/felhom-data", false}, // system path (not under StableParentDir) → never skip
}
for _, c := range cases {
if got := skipFileBrowserPath(c.path, isMount); got != c.want {
t.Errorf("skipFileBrowserPath(%q) = %v, want %v", c.path, got, c.want)
}
}
}
+17
View File
@@ -1433,6 +1433,15 @@ func (s *Server) SyncFileBrowserMountsReset() {
s.syncFileBrowserMounts(true)
}
// skipFileBrowserPath reports whether a registered storage path should be skipped this FileBrowser
// sync pass: an EXTERNAL drive path (under StableParentDir) that is not currently a live mountpoint is
// detached, so its userdata skeleton must not be created (would land on the rootfs) and it must not be
// mounted into FileBrowser until it returns. System/local paths (not under StableParentDir) are never
// skipped. Pure + isMount-injected for testability.
func skipFileBrowserPath(path string, isMount func(string) bool) bool {
return strings.HasPrefix(path, StableParentDir+"/") && !isMount(path)
}
func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
// Prevent concurrent syncs — multiple callers can race on the same files (H5 fix).
s.fileBrowserMu.Lock()
@@ -1465,6 +1474,14 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
var storageMounts []string
for _, sp := range paths {
mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1"
// Drive-absent gate: an external drive path that isn't currently a live mountpoint is detached —
// don't create its userdata skeleton (would write onto the rootfs) and don't mount it into
// FileBrowser this pass. It returns on the next sync after reconnect. Matches planDriveGates'
// external-only rule (system paths, not under StableParentDir, are never skipped).
if skipFileBrowserPath(sp.Path, system.IsMountPoint) {
s.logger.Printf("[INFO] [web] FileBrowser: drive %s not mounted — skipping userdata skeleton", sp.Path)
continue
}
if err := appbackup.EnsureUserdataSkeleton(sp.Path); err != nil {
s.logger.Printf("[WARN] [web] FileBrowser: could not ensure userdata skeleton on %s: %v", sp.Path, err)
}