package platform import ( "errors" "os" "path/filepath" "strings" "git.casaderoll.de/michael/urbm/internal/model" ) func ValidateRsyncPaths(job model.Job) error { target, err := filepath.EvalSymlinks(job.Rsync.Target) if err != nil { return errors.New("validation: resolve rsync target: " + err.Error()) } for _, source := range job.Sources { resolved, err := filepath.EvalSymlinks(source.Path) if err != nil { return errors.New("validation: resolve rsync source: " + err.Error()) } if pathsContainEachOther(resolved, target) { return errors.New("validation: resolved rsync source and target must not contain each other") } } return nil } func pathsContainEachOther(first, second string) bool { within := func(path, root string) bool { rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } return within(first, second) || within(second, first) } func ValidateRestoreTarget(target, restoreRoot string, inPlace, confirmed bool) (string, error) { if inPlace { if !confirmed { return "", errors.New("validation: in-place restore requires explicit confirmation") } return string(os.PathSeparator), nil } if !filepath.IsAbs(target) { return "", errors.New("validation: restore target must be absolute") } clean := filepath.Clean(target) if clean == "/" || clean == "/boot" || clean == "/etc" || clean == "/usr" || clean == "/var" { return "", errors.New("validation: protected system restore target") } if !inPlace { if err := validateStagingRestoreTarget(clean, restoreRoot); err != nil { return "", err } } return clean, nil } func validateStagingRestoreTarget(target, restoreRoot string) error { _ = restoreRoot // Configuration validation keeps this below one of the storage roots. allowedRoots := []string{"/mnt/user", "/mnt/disks", "/mnt/remotes"} for _, root := range allowedRoots { if root == "." || root == "" { continue } if target == root || strings.HasPrefix(target, root+string(os.PathSeparator)) { return rejectSymlinks(root, target) } } return errors.New("validation: staging restore target must be below restoreRoot, /mnt/user, /mnt/disks, or /mnt/remotes") } func rejectSymlinks(root, target string) error { if info, err := os.Lstat(root); err == nil { if info.Mode()&os.ModeSymlink != 0 { return errors.New("validation: restore target traverses a symlink") } } else if !errors.Is(err, os.ErrNotExist) { return err } relative, err := filepath.Rel(root, target) if err != nil { return err } current := root for _, component := range strings.Split(relative, string(os.PathSeparator)) { if component == "." || component == "" { continue } current = filepath.Join(current, component) info, err := os.Lstat(current) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 { return errors.New("validation: restore target traverses a symlink") } } return nil }