package model import ( "errors" "fmt" "net/url" "path/filepath" "regexp" "strings" ) var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) var snapshotPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$`) var safePathPattern = regexp.MustCompile(`^/[a-zA-Z0-9_./-]+$`) func ValidateConfig(c Config) error { if c.SchemaVersion != SchemaVersion { return fmt.Errorf("unsupported schemaVersion %d", c.SchemaVersion) } repos := make(map[string]Repository, len(c.Repositories)) mountPoints := make(map[string]string) for _, repo := range c.Repositories { if err := ValidateRepository(repo); err != nil { return fmt.Errorf("repository %q: %w", repo.ID, err) } if _, exists := repos[repo.ID]; exists { return fmt.Errorf("duplicate repository id %q", repo.ID) } repos[repo.ID] = repo if repo.Mount != nil && repo.Mount.Managed { point := filepath.Clean(repo.Mount.MountPoint) if previous, exists := mountPoints[point]; exists { return fmt.Errorf("repositories %q and %q use the same managed mountPoint %q", previous, repo.ID, point) } mountPoints[point] = repo.ID } } jobs := make(map[string]struct{}, len(c.Jobs)) for _, job := range c.Jobs { if err := ValidateJob(job); err != nil { return fmt.Errorf("job %q: %w", job.ID, err) } if _, exists := jobs[job.ID]; exists { return fmt.Errorf("duplicate job id %q", job.ID) } jobs[job.ID] = struct{}{} if job.Type != JobRsync { if _, exists := repos[job.RepositoryID]; !exists { return fmt.Errorf("job %q references unknown repository %q", job.ID, job.RepositoryID) } } } notifications := make(map[string]struct{}, len(c.Notifications)) for _, target := range c.Notifications { if err := ValidateNotification(target); err != nil { return fmt.Errorf("notification %q: %w", target.ID, err) } if _, exists := notifications[target.ID]; exists { return fmt.Errorf("duplicate notification id %q", target.ID) } notifications[target.ID] = struct{}{} } restoreRoot := filepath.Clean(c.Settings.RestoreRoot) if !filepath.IsAbs(restoreRoot) || !belowStorageRoot(restoreRoot) { return errors.New("restoreRoot must be below /mnt/user, /mnt/disks, or /mnt/remotes") } if strings.TrimSpace(c.Settings.ResticPath) == "" || !filepath.IsAbs(c.Settings.ResticPath) { return errors.New("resticPath must be an absolute path") } if strings.TrimSpace(c.Settings.RsyncPath) == "" || !filepath.IsAbs(c.Settings.RsyncPath) { return errors.New("rsyncPath must be an absolute path") } if c.Settings.CatchUpWindowHrs < 0 || c.Settings.CatchUpWindowHrs > 24*31 { return errors.New("catchUpWindowHours must be between 0 and 744") } if c.Settings.PersistentLogDir != "" { logDir := filepath.Clean(c.Settings.PersistentLogDir) if !filepath.IsAbs(logDir) || (!pathWithin(logDir, "/mnt/user") && !pathWithin(logDir, "/mnt/disks") && !pathWithin(logDir, "/mnt/remotes")) { return errors.New("persistentLogDir must be below /mnt/user, /mnt/disks, or /mnt/remotes") } } return nil } func ValidateRestoreTask(task RestoreTask) error { if !idPattern.MatchString(task.RepositoryID) { return errors.New("restore repositoryId is invalid") } if err := ValidateSnapshotID(task.SnapshotID); err != nil { return err } if strings.ContainsRune(task.Target, '\x00') { return errors.New("restore target contains a forbidden control character") } if task.InPlace && len(task.Includes) == 0 { return errors.New("in-place restore requires at least one included path") } for _, include := range task.Includes { if strings.ContainsAny(include, "\x00\r\n") || !filepath.IsAbs(include) || filepath.Clean(include) == "/" { return fmt.Errorf("invalid restore include %q", include) } } return nil } func ValidateSnapshotID(value string) error { if !snapshotPattern.MatchString(value) { return errors.New("restore snapshotId is invalid") } return nil } func ValidateNotification(target NotificationTarget) error { if target.SchemaVersion != SchemaVersion || !idPattern.MatchString(target.ID) { return errors.New("invalid schemaVersion or id") } if strings.TrimSpace(target.Name) == "" { return errors.New("name is required") } switch target.Type { case "unraid": case "gotify": parsed, err := url.Parse(target.URL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { return errors.New("Gotify URL must be an absolute HTTP or HTTPS URL") } if !idPattern.MatchString(target.TokenRef) { return errors.New("Gotify tokenRef must be a valid secret ID") } default: return fmt.Errorf("unsupported notification type %q", target.Type) } allowed := map[string]bool{"success": true, "warning": true, "failed": true} if target.Enabled && len(target.Events) == 0 { return errors.New("enabled notification requires at least one event") } for _, event := range target.Events { if !allowed[event] { return fmt.Errorf("unsupported event %q", event) } } return nil } func ValidateJob(j Job) error { if j.SchemaVersion != SchemaVersion || !idPattern.MatchString(j.ID) { return errors.New("invalid schemaVersion or id") } if strings.TrimSpace(j.Name) == "" { return errors.New("name is required") } switch j.Type { case JobShare, JobAppdata, JobDocker, JobVM, JobFlash, JobRsync: default: return fmt.Errorf("unsupported job type %q", j.Type) } if len(j.Sources) == 0 && j.Type != JobFlash { return errors.New("at least one source is required") } if j.Type == JobFlash && len(j.Sources) == 0 { j.Sources = []Source{{Path: "/boot"}} } for _, source := range j.Sources { if source.Path != "" && !filepath.IsAbs(source.Path) { return fmt.Errorf("source path %q must be absolute", source.Path) } if source.Path == "" && source.WorkloadID == "" { return errors.New("source requires path or workloadId") } } if j.Type == JobDocker || j.Type == JobVM { for _, source := range j.Sources { if source.WorkloadID == "" { return errors.New("docker and vm jobs require workload selections") } } } for _, exclude := range j.Excludes { if strings.ContainsAny(exclude, "\x00\r\n") { return errors.New("exclude contains forbidden control characters") } } if j.Type == JobRsync { return validateRsyncJob(j) } if strings.TrimSpace(j.RepositoryID) == "" { return errors.New("repositoryId is required") } if j.Compression != "auto" && j.Compression != "off" && j.Compression != "max" { return errors.New("compression must be auto, off, or max") } if j.CPUCores < 0 || j.CPUCores > 256 { return errors.New("cpuCores must be between 0 and 256") } if j.ReadConcurrency < 0 || j.ReadConcurrency > 64 { return errors.New("readConcurrency must be between 0 and 64") } if j.Consistency.Mode != "live" && j.Consistency.Mode != "stop" { return errors.New("consistency mode must be live or stop") } if j.Retention.KeepWithinDays < 0 || j.Retention.Daily < 0 || j.Retention.Weekly < 0 || j.Retention.Monthly < 0 { return errors.New("retention values cannot be negative") } if j.Retention.KeepWithinDays == 0 && j.Retention.Daily == 0 && j.Retention.Weekly == 0 && j.Retention.Monthly == 0 { return errors.New("retention must keep at least one age or calendar policy") } return nil } func validateRsyncJob(j Job) error { target := filepath.Clean(strings.TrimSpace(j.Rsync.Target)) if !filepath.IsAbs(target) { return errors.New("rsync target must be absolute") } if !pathWithin(target, "/mnt/user") && !pathWithin(target, "/mnt/disks") && !pathWithin(target, "/mnt/remotes") { return errors.New("rsync target must be below /mnt/user, /mnt/disks, or /mnt/remotes") } for _, source := range j.Sources { if source.WorkloadID != "" || source.Path == "" { return errors.New("rsync jobs require filesystem paths") } cleanSource := filepath.Clean(source.Path) if pathWithin(target, cleanSource) || pathWithin(cleanSource, target) { return fmt.Errorf("rsync source %q and target %q must not contain each other", cleanSource, target) } } return nil } func pathWithin(path, root string) bool { rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } func belowStorageRoot(path string) bool { for _, root := range []string{"/mnt/user", "/mnt/disks", "/mnt/remotes"} { if filepath.Clean(path) != root && pathWithin(path, root) { return true } } return false } func ValidateRepository(r Repository) error { if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) { return errors.New("invalid schemaVersion or id") } if r.Name == "" || r.Location == "" || r.PasswordRef == "" { return errors.New("name, location, and passwordRef are required") } if !idPattern.MatchString(r.PasswordRef) || (r.CredentialRef != "" && !idPattern.MatchString(r.CredentialRef)) { return errors.New("secret references must be valid IDs") } switch r.Type { case RepositoryLocal, RepositorySMB, RepositoryNFS, RepositorySFTP: default: return fmt.Errorf("unsupported repository type %q", r.Type) } if r.Type == RepositoryLocal && !filepath.IsAbs(r.Location) { return errors.New("local repository location must be absolute") } if (r.Type == RepositorySMB || r.Type == RepositoryNFS) && r.Mount != nil && r.Mount.Managed { if r.Mount.Remote == "" || !filepath.IsAbs(r.Mount.MountPoint) { return errors.New("managed mount requires remote and absolute mountPoint") } if strings.HasPrefix(r.Mount.Remote, "-") || strings.ContainsAny(r.Mount.Remote, "\x00\r\n") { return errors.New("managed mount remote contains forbidden characters") } } if r.Type == RepositorySFTP && r.CredentialRef != "" { knownHosts := r.Options["knownHostsPath"] if !safePathPattern.MatchString(knownHosts) || filepath.Clean(knownHosts) != knownHosts { return errors.New("SFTP key authentication requires a clean absolute knownHostsPath containing only letters, digits, dot, underscore, slash, and hyphen") } } return nil }