Tighten disk safety checks

This commit is contained in:
2026-04-24 07:18:17 +03:00
parent b8eabee393
commit a8dc1d9e00
5 changed files with 92 additions and 8 deletions
+4
View File
@@ -65,6 +65,10 @@ func (s *Server) handleDiskInit(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusConflict, "disk already initialized") jsonErr(w, http.StatusConflict, "disk already initialized")
return return
} }
if err := disk.CheckWritable(info.MountPath); err != nil {
jsonErr(w, http.StatusUnprocessableEntity, "disk is not writable: "+err.Error())
return
}
diskID, err := disk.InitDisk(info.MountPath) diskID, err := disk.InitDisk(info.MountPath)
if err != nil { if err != nil {
+39 -1
View File
@@ -5,12 +5,17 @@ import (
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"jukebox_maker/internal/disk"
) )
type OverwriteMode string type OverwriteMode string
type FileSelectMode string type FileSelectMode string
const ( const (
DefaultDestFolder = "media"
OverwriteSkip OverwriteMode = "skip" OverwriteSkip OverwriteMode = "skip"
OverwriteDelete OverwriteMode = "delete" OverwriteDelete OverwriteMode = "delete"
@@ -30,12 +35,14 @@ type Config struct {
OverwriteMode OverwriteMode `json:"overwrite_mode"` OverwriteMode OverwriteMode `json:"overwrite_mode"`
FileSelectMode FileSelectMode `json:"file_select_mode"` FileSelectMode FileSelectMode `json:"file_select_mode"`
AutoCopy bool `json:"auto_copy"` AutoCopy bool `json:"auto_copy"`
FileReplicaCounts map[string]int `json:"file_replica_counts,omitempty"`
DiskReplicaFiles map[string][]string `json:"disk_replica_files,omitempty"`
} }
func defaults() Config { func defaults() Config {
return Config{ return Config{
ReserveFreeGB: 2.0, ReserveFreeGB: 2.0,
DestFolder: "media", DestFolder: DefaultDestFolder,
OverwriteMode: OverwriteSkip, OverwriteMode: OverwriteSkip,
FileSelectMode: SelectNew, FileSelectMode: SelectNew,
AutoCopy: false, AutoCopy: false,
@@ -55,6 +62,11 @@ func Load(path string) (*Config, error) {
if err := json.Unmarshal(data, &cfg); err != nil { if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err return nil, err
} }
if destFolder, err := NormalizeDestFolder(cfg.DestFolder); err == nil {
cfg.DestFolder = destFolder
} else {
cfg.DestFolder = defaults().DestFolder
}
return &cfg, nil return &cfg, nil
} }
@@ -77,6 +89,9 @@ func (c *Config) Validate() error {
if c.ReserveFreeGB < 0 { if c.ReserveFreeGB < 0 {
return errors.New("reserve_free_gb must be >= 0") return errors.New("reserve_free_gb must be >= 0")
} }
if _, err := NormalizeDestFolder(c.DestFolder); err != nil {
return err
}
switch c.OverwriteMode { switch c.OverwriteMode {
case OverwriteSkip, OverwriteDelete: case OverwriteSkip, OverwriteDelete:
default: default:
@@ -89,3 +104,26 @@ func (c *Config) Validate() error {
} }
return nil return nil
} }
func NormalizeDestFolder(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return DefaultDestFolder, nil
}
clean := filepath.ToSlash(filepath.Clean(value))
clean = strings.TrimPrefix(clean, "./")
clean = strings.TrimPrefix(clean, "/")
switch clean {
case "", ".", "..":
return "", errors.New("dest_folder must be a subfolder on disk, not the disk root")
}
if strings.HasPrefix(clean, "../") {
return "", errors.New("dest_folder must stay inside the disk")
}
if clean == disk.MarkerDir || strings.HasPrefix(clean, disk.MarkerDir+"/") {
return "", errors.New("dest_folder conflicts with internal disk metadata")
}
return clean, nil
}
+41 -4
View File
@@ -26,7 +26,7 @@ type DiskInfo struct {
MountPath string `json:"mount_path"` MountPath string `json:"mount_path"`
} }
const markerDir = ".jukebox" const MarkerDir = ".jukebox"
const idFile = "disk.id" const idFile = "disk.id"
func Probe(mountPath string) (DiskInfo, error) { func Probe(mountPath string) (DiskInfo, error) {
@@ -43,7 +43,7 @@ func Probe(mountPath string) (DiskInfo, error) {
info.TotalBytes = total info.TotalBytes = total
info.FreeBytes = free info.FreeBytes = free
idPath := filepath.Join(mountPath, markerDir, idFile) idPath := filepath.Join(mountPath, MarkerDir, idFile)
data, err := os.ReadFile(idPath) data, err := os.ReadFile(idPath)
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
info.State = DiskForeign info.State = DiskForeign
@@ -59,8 +59,45 @@ func Probe(mountPath string) (DiskInfo, error) {
return info, nil return info, nil
} }
func IsMountPoint(path string) bool {
pathInfo, err := os.Stat(path)
if err != nil {
return false
}
parent := filepath.Dir(filepath.Clean(path))
parentInfo, err := os.Stat(parent)
if err != nil {
return false
}
pathStat, ok := pathInfo.Sys().(*syscall.Stat_t)
if !ok {
return false
}
parentStat, ok := parentInfo.Sys().(*syscall.Stat_t)
if !ok {
return false
}
return pathStat.Dev != parentStat.Dev
}
func CheckWritable(path string) error {
f, err := os.CreateTemp(path, ".jukebox-writecheck-*")
if err != nil {
return err
}
name := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(name)
return err
}
return os.Remove(name)
}
func InitDisk(mountPath string) (string, error) { func InitDisk(mountPath string) (string, error) {
dir := filepath.Join(mountPath, markerDir) dir := filepath.Join(mountPath, MarkerDir)
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err return "", err
} }
@@ -73,7 +110,7 @@ func InitDisk(mountPath string) (string, error) {
} }
func DBPath(mountPath string) string { func DBPath(mountPath string) string {
return filepath.Join(mountPath, markerDir, "history.db") return filepath.Join(mountPath, MarkerDir, "history.db")
} }
func DiskUsage(mountPath string) (total, free int64, err error) { func DiskUsage(mountPath string) (total, free int64, err error) {
+5
View File
@@ -135,6 +135,9 @@ func discoverDisks(root string) map[string]disk.DiskInfo {
continue continue
} }
mountPath := filepath.Join(root, entry.Name()) mountPath := filepath.Join(root, entry.Name())
if !disk.IsMountPoint(mountPath) {
continue
}
info, _ := disk.Probe(mountPath) info, _ := disk.Probe(mountPath)
if info.State == disk.DiskAbsent { if info.State == disk.DiskAbsent {
continue continue
@@ -144,9 +147,11 @@ func discoverDisks(root string) map[string]disk.DiskInfo {
// If no child mountpoints were detected, the disk may be mounted directly at root. // If no child mountpoints were detected, the disk may be mounted directly at root.
if len(disks) == 0 { if len(disks) == 0 {
if disk.IsMountPoint(root) {
if info, _ := disk.Probe(root); info.State != disk.DiskAbsent { if info, _ := disk.Probe(root); info.State != disk.DiskAbsent {
disks[root] = info disks[root] = info
} }
} }
}
return disks return disks
} }
+1 -1
View File
@@ -38,7 +38,7 @@
<div class="form-group"> <div class="form-group">
<label class="form-label" for="destFolder">Destination folder on disk</label> <label class="form-label" for="destFolder">Destination folder on disk</label>
<input class="form-input" type="text" id="destFolder" placeholder="media" style="width:200px"> <input class="form-input" type="text" id="destFolder" placeholder="media" style="width:200px">
<span class="form-hint">Files will be copied into this subfolder while preserving the selected source structure.</span> <span class="form-hint">Files will be copied into this subfolder while preserving the selected source structure. The disk root and <code>.jukebox</code> are never allowed here.</span>
</div> </div>
<div class="form-group"> <div class="form-group">