Tighten disk safety checks
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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,8 +147,10 @@ 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 info, _ := disk.Probe(root); info.State != disk.DiskAbsent {
|
if disk.IsMountPoint(root) {
|
||||||
disks[root] = info
|
if info, _ := disk.Probe(root); info.State != disk.DiskAbsent {
|
||||||
|
disks[root] = info
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return disks
|
return disks
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user