package appbackup import ( "os" "path/filepath" ) // Customer-facing userdata layout + the shared-storage ownership convention (v0.66.0). // // userdata/ is a sibling of appdata/ and backups/ under a drive's felhom-data namespace. It is the // ONLY customer-browsable tree (FileBrowser mounts it). Apps that handle customer content write here. // // Ownership convention: every userdata dir is group-owned by SharedContentGID, mode 2775 (setgid + // group-rwx). Setgid makes new files/dirs inherit the shared group regardless of which app (or // FileBrowser) created them, so members collaborate without permission collisions. FileBrowser // (uid/gid 1000) and the content apps (PUID/PGID 1000, or pinned user 1000:1000) are all members. // SharedContentGID is the group that owns the userdata tree. const SharedContentGID = 1000 // userdataDirMode is the on-disk mode for every userdata dir: setgid + group-rwx. os.ModeSetgid (NOT // the raw 0o2000) is how Go's Chmod requests S_ISGID. MkdirAll's mode is umask-masked AND drops the // setgid bit, so an explicit Chmod is mandatory after MkdirAll. const userdataDirMode = os.ModeSetgid | 0o775 // UserdataDir returns the customer-facing userdata root under a namespace root. func UserdataDir(nsRoot string) string { return filepath.Join(nsRoot, "userdata") } // UserdataSkeleton is the standard subtree created on every storage path (relative to UserdataDir). // ASCII, no spaces (flows through ${} interpolation, shell, and the rsync merge walk). func UserdataSkeleton() []string { return []string{ "media", "media/movies", "media/tv", "media/music", "media/audiobooks", "media/books", "media/comics", "media/photos", "downloads", "import", "import/paperless", "import/calibre", "roms", "documents", } } // EnsureDirOwned creates path (idempotent) and enforces the convention: mode 2775 via an explicit // Chmod incl. setgid (MkdirAll cannot) + group = gid. Setting an arbitrary group needs CAP_CHOWN — // the in-guest controller runs as root, so this succeeds in production. Returns the first hard error. func EnsureDirOwned(path string, gid int) error { if err := os.MkdirAll(path, 0o755); err != nil { return err } if err := os.Chmod(path, userdataDirMode); err != nil { return err } return chownGID(path, gid) } // EnsureUserdataDir applies the convention with the shared content group (GID 1000). Idempotent. func EnsureUserdataDir(path string) error { return EnsureDirOwned(path, SharedContentGID) } // EnsureUserdataSkeleton creates the full userdata tree under a namespace root with the convention. // It creates ALL dirs even if one errors (so a single chown/chmod hiccup doesn't truncate the tree), // returning the first error seen for the caller to log. func EnsureUserdataSkeleton(nsRoot string) error { base := UserdataDir(nsRoot) var firstErr error rec := func(e error) { if e != nil && firstErr == nil { firstErr = e } } rec(EnsureUserdataDir(base)) for _, sub := range UserdataSkeleton() { rec(EnsureUserdataDir(filepath.Join(base, sub))) } return firstErr }