v0.66.0: userdata layout + shared-storage ownership convention

appbackup/userdata.go: EnsureUserdataDir (MkdirAll + explicit setgid Chmod 2775 +
chown gid 1000), UserdataSkeleton, EnsureUserdataSkeleton; linux chown/StatGID +
non-linux stubs. stackEnv injects USERDATA_PATH=<HDD_PATH>/userdata. Skeleton
pre-created on register + FileBrowser sync; deploy belt (composeExecCustomEnv on
'up') pre-creates every ${USERDATA_PATH} bind source. FileBrowser mounts userdata
(was appdata) — uid 1000 can now write into 2775 setgid. #8: migrate merge walk +
copyFile preserve source setgid+group so the convention survives MigrateAll.
Non-hollow tests incl. Linux setgid assertions + migration-preserve companion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 21:58:49 +02:00
parent cbaa53f565
commit c48f95fe06
17 changed files with 523 additions and 34 deletions
+29 -2
View File
@@ -848,7 +848,13 @@ func walkMerge(lg *log.Logger, srcNS, dstNS string, skip map[string]bool, assert
if assertOnly {
return nil
}
return os.MkdirAll(dst, 0o755)
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
// #8 (v0.66.0): preserve the SOURCE dir's full mode (incl. setgid) + group, so the userdata
// ownership convention (2775 setgid, gid 1000) survives a whole-drive migration. MkdirAll's
// mode is umask-masked + drops setgid, so re-stamp explicitly from the source.
return preserveDirOwnership(dst, d)
}
// Symlink: recreate-if-absent (copy mode); ignored in assert mode.
@@ -994,10 +1000,15 @@ func copyFile(src, dst string) (int64, error) {
os.Remove(tmp)
return 0, err
}
if err := os.Chmod(tmp, fi.Mode().Perm()); err != nil {
// #8 (v0.66.0): preserve the SOURCE file's FULL mode (incl. setgid/setuid/sticky — not .Perm(),
// which masks them off) + group, so the userdata convention survives a whole-drive migration.
if err := os.Chmod(tmp, fi.Mode()); err != nil {
os.Remove(tmp)
return 0, err
}
if gid, ok := appbackup.StatGID(fi); ok {
_ = os.Chown(tmp, -1, gid) // best-effort; needs root for an arbitrary group (the controller is)
}
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return 0, err
@@ -1005,6 +1016,22 @@ func copyFile(src, dst string) (int64, error) {
return n, nil
}
// preserveDirOwnership re-stamps a freshly-created target dir with the SOURCE dir's full mode (incl.
// setgid) and group — part of the #8 fix so the userdata convention survives a migration.
func preserveDirOwnership(dst string, d fs.DirEntry) error {
info, err := d.Info()
if err != nil {
return err
}
if err := os.Chmod(dst, info.Mode()); err != nil {
return err
}
if gid, ok := appbackup.StatGID(info); ok {
_ = os.Chown(dst, -1, gid) // best-effort; root sets an arbitrary group (the controller is root)
}
return nil
}
// fileSum returns the hex sha256 of a file (streaming).
func fileSum(path string) (string, error) {
f, err := os.Open(path)