package catalogue // A five-field cron validator, enough to refuse a malformed schedule near its author // (novox/hq ADR 0053). // // **Validation only, and written rather than pulled in.** The control plane's part in a scheduled // step is small and exact: carry the field to the host unchanged, and refuse a `schedule` that is // not a well-formed cron here rather than on the machine — the same near-versus-far discipline the // manifest keeps for an action a module may not declare and a run-once that is not a boolean. What // *when to run it again* means is the host's to evaluate; the control plane only checks the string // is one it could. A cron library would be a dependency carried for evaluation nobody does here. // // The ordinary five fields — minute, hour, day-of-month, month, day-of-week — with `*`, lists // (`,`), ranges (`-`) and steps (`/`). No names (jan, mon): a numeric cron is what the host // evaluates, and refusing a name here is refusing it near whoever wrote it. import ( "fmt" "strconv" "strings" ) // validateCron reports why a string is not a five-field cron expression, or nil if it is one. func validateCron(expr string) error { fields := strings.Fields(expr) if len(fields) != 5 { return fmt.Errorf( "a schedule is a five-field cron expression (minute hour day-of-month month "+ "day-of-week), and this has %d field(s)", len(fields)) } bounds := []struct { name string min, max int }{ {"minute", 0, 59}, {"hour", 0, 23}, {"day-of-month", 1, 31}, {"month", 1, 12}, {"day-of-week", 0, 7}, // 0 and 7 are both Sunday, the convention every cron keeps } for i, b := range bounds { if err := validateCronField(fields[i], b.min, b.max); err != nil { return fmt.Errorf("%s field: %w", b.name, err) } } return nil } // validateCronField checks one field's elements are within range and well-formed. func validateCronField(spec string, min, max int) error { if spec == "" { return fmt.Errorf("is empty") } for _, part := range strings.Split(spec, ",") { if part == "" { return fmt.Errorf("%q has an empty element between commas", spec) } rangePart := part if slash := strings.IndexByte(part, '/'); slash >= 0 { rangePart = part[:slash] n, err := strconv.Atoi(part[slash+1:]) if err != nil || n < 1 { return fmt.Errorf("step in %q is not a positive number", part) } } switch { case rangePart == "*": // Any value; nothing to bound. case strings.IndexByte(rangePart, '-') >= 0: dash := strings.IndexByte(rangePart, '-') lo, errLo := strconv.Atoi(rangePart[:dash]) hi, errHi := strconv.Atoi(rangePart[dash+1:]) if errLo != nil || errHi != nil { return fmt.Errorf("range %q is not two numbers", rangePart) } if lo < min || hi > max || lo > hi { return fmt.Errorf("range %q is outside the allowed %d-%d", part, min, max) } default: v, err := strconv.Atoi(rangePart) if err != nil { return fmt.Errorf("%q is not a number", rangePart) } if v < min || v > max { return fmt.Errorf("%q is outside the allowed range %d-%d", part, min, max) } } } return nil }