ParseIdentitySelection (strict written form or base-10 decimal key, optional [k] or [a-b]) and ParseNameSelection (name part may be empty, no whitespace or brackets) apply the shared selection grammar and check order (5.16 syntax, 5.14 width, 5.17 marked decimal without [k], 5.8 alignment only with [...], 5.7 element bound, 5.5 reversed), reporting only the first failure with a rule tag and a message quoting the selection. Runs the shared selection-cases table plus REST-only rows. Not yet wired into the handlers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
220 lines
6.8 KiB
Go
220 lines
6.8 KiB
Go
package controllers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"apiserver/backend"
|
|
)
|
|
|
|
// REST element selections (job-array-one-based-index, design "ApiSel and
|
|
// ApiMatch"). ParseIdentitySelection and ParseNameSelection are pure parsers:
|
|
// they depend only on the backend ID helpers and the standard library.
|
|
|
|
// SelForm is the shape of a parsed selection.
|
|
type SelForm int
|
|
|
|
const (
|
|
SelBare SelForm = iota // no "[...]"
|
|
SelSingle // "[k]" (Lo == Hi == k)
|
|
SelRange // "[a-b]" (a == b allowed)
|
|
)
|
|
|
|
// Selection is one parsed identity or name selection.
|
|
type Selection struct {
|
|
Raw string // as the user wrote it (trimmed)
|
|
Name string // name selections only (job_name / -J)
|
|
Key uint64 // identity selections: exact ID (bare) or array ID
|
|
Form SelForm
|
|
Lo, Hi int // element numbers, Lo == Hi for SelSingle
|
|
}
|
|
|
|
// SelError is a rejected selection. Rule is the requirement it breaks
|
|
// ("5.16", "5.14", "5.17", "5.8", "5.7", "5.5"); Msg names the selection as
|
|
// written (trimmed).
|
|
type SelError struct {
|
|
Rule string
|
|
Msg string
|
|
}
|
|
|
|
func (e *SelError) Error() string { return e.Msg }
|
|
|
|
// maxElementNumber is the largest element number a selection may name (5.7):
|
|
// the 15-bit array-index field.
|
|
const maxElementNumber = 32767
|
|
|
|
func selErr(rule, format string, args ...any) (Selection, *SelError) {
|
|
return Selection{}, &SelError{Rule: rule, Msg: fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
// selIndex is the element part of a selection.
|
|
type selIndex struct {
|
|
present bool
|
|
isRange bool
|
|
lo, hi int // saturated at exprNumSaturate
|
|
loDigits, hiDigits string // leading zeros stripped
|
|
}
|
|
|
|
// splitSelection splits a trimmed selection into the part before the
|
|
// element part and the element part. ok is false when the text breaks the
|
|
// 5.16 grammar of the element part: a ']' before any '[', an element part that
|
|
// is not "[num]" or "[num-num]" (num = one or more decimal digits), or any
|
|
// character after the closing ']'.
|
|
func splitSelection(s string) (head string, idx selIndex, ok bool) {
|
|
open := strings.IndexAny(s, "[]")
|
|
if open < 0 {
|
|
return s, idx, true
|
|
}
|
|
if s[open] == ']' {
|
|
return "", idx, false
|
|
}
|
|
head = s[:open]
|
|
idx.present = true
|
|
var good bool
|
|
pos := open + 1
|
|
idx.lo, idx.loDigits, pos, good = readExprNum(s, pos)
|
|
if !good {
|
|
return "", idx, false
|
|
}
|
|
idx.hi, idx.hiDigits = idx.lo, idx.loDigits
|
|
if pos < len(s) && s[pos] == '-' {
|
|
idx.hi, idx.hiDigits, pos, good = readExprNum(s, pos+1)
|
|
if !good {
|
|
return "", idx, false
|
|
}
|
|
idx.isRange = true
|
|
}
|
|
if pos != len(s)-1 || s[pos] != ']' {
|
|
return "", idx, false
|
|
}
|
|
return head, idx, true
|
|
}
|
|
|
|
// checkIndex applies 5.7 and then 5.5 to an element part and fills in the
|
|
// selection's form and numbers.
|
|
func checkIndex(sel Selection, idx selIndex) (Selection, *SelError) {
|
|
if !idx.present {
|
|
sel.Form = SelBare
|
|
return sel, nil
|
|
}
|
|
// 5.7 (a saturated number is always above the bound)
|
|
if idx.lo > maxElementNumber || idx.hi > maxElementNumber {
|
|
return selErr("5.7",
|
|
"invalid element selection '%s': element numbers must be at most %d",
|
|
sel.Raw, maxElementNumber)
|
|
}
|
|
// 5.5 (compared on the digit strings, so the order is exact)
|
|
if digitsLess(idx.hiDigits, idx.loDigits) {
|
|
return selErr("5.5",
|
|
"invalid element selection '%s': the range is reversed (last element number is less than the first)",
|
|
sel.Raw)
|
|
}
|
|
sel.Lo, sel.Hi = idx.lo, idx.hi
|
|
sel.Form = SelSingle
|
|
if idx.isRange {
|
|
sel.Form = SelRange
|
|
}
|
|
return sel, nil
|
|
}
|
|
|
|
func isDecimal(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] < '0' || s[i] > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ParseIdentitySelection parses a job identity, optionally followed by "[k]"
|
|
// or "[a-b]". The identity is the written form c-a-ts-seq (strict field
|
|
// widths) or a decimal uint64 (base 10). Ranges are always allowed at the
|
|
// server. Whitespace before the identity or after the closing ']' is ignored;
|
|
// whitespace anywhere else is invalid. Checks run in the order of 5.12 --
|
|
// 5.16, 5.14, 5.17, 5.8, 5.7, 5.5 -- and only the first failure is reported.
|
|
// Numbers may have leading zeros (1.5).
|
|
func ParseIdentitySelection(raw string) (Selection, *SelError) {
|
|
sel := Selection{Raw: strings.TrimSpace(raw)}
|
|
|
|
// 5.16: grammar of the element part and of the identity itself.
|
|
head, idx, ok := splitSelection(sel.Raw)
|
|
if !ok {
|
|
return selErr("5.16",
|
|
"invalid job identity or element selection '%s': expected <job-id>, <array-id>[k] or <array-id>[a-b]",
|
|
sel.Raw)
|
|
}
|
|
decimal := isDecimal(head)
|
|
var key uint64
|
|
var err error
|
|
if decimal {
|
|
key, err = strconv.ParseUint(head, 10, 64)
|
|
if err != nil {
|
|
// all digits, so the only failure is a value above uint64 (5.14)
|
|
return selErr("5.14", "invalid job identity '%s': the job number is too large", sel.Raw)
|
|
}
|
|
} else {
|
|
key, err = backend.ParseHumanJobIDStrict(head)
|
|
if errors.Is(err, backend.ErrJobIDWidth) {
|
|
// 5.14
|
|
return selErr("5.14",
|
|
"invalid job identity '%s': a field is wider than allowed (cluster <= 63, API server <= 15, timestamp < 2^32, sequence < 2^20)",
|
|
sel.Raw)
|
|
}
|
|
if err != nil {
|
|
return selErr("5.16",
|
|
"invalid job identity or element selection '%s': expected <job-id>, <array-id>[k] or <array-id>[a-b]",
|
|
sel.Raw)
|
|
}
|
|
}
|
|
sel.Key = key
|
|
|
|
// 5.17: a decimal key carrying the array marking, without [k]. The
|
|
// written form never carries the marking.
|
|
if decimal && !idx.present && backend.IsArrayJob(key) {
|
|
return selErr("5.17",
|
|
"invalid job identity '%s': array elements must be named as <array-id>[k]", sel.Raw)
|
|
}
|
|
// 5.8: only with an element part.
|
|
if idx.present && !backend.IsArrayCapableKey(key) {
|
|
return selErr("5.8",
|
|
"invalid job identity '%s': '%s' is not an array identity", sel.Raw, head)
|
|
}
|
|
return checkIndex(sel, idx)
|
|
}
|
|
|
|
// ParseNameSelection parses a job-name selection "name[k]" or "name[a-b]".
|
|
// Only 5.16, 5.7 and 5.5 apply (in that order). The name part is zero or more
|
|
// characters, none of them whitespace, '[' or ']'; it is not checked against
|
|
// the submission array-name rule (a name no array can have selects no job).
|
|
// Whitespace before the name or after the closing ']' is ignored.
|
|
//
|
|
// A value with no '[' or ']' is not an element selection: it is returned as a
|
|
// SelBare selection whose Name is the value exactly as given, so a caller
|
|
// converting a whole filter entry keeps today's plain-name match for it.
|
|
func ParseNameSelection(raw string) (Selection, *SelError) {
|
|
sel := Selection{Raw: strings.TrimSpace(raw)}
|
|
if !strings.ContainsAny(raw, "[]") {
|
|
sel.Name = raw
|
|
sel.Form = SelBare
|
|
return sel, nil
|
|
}
|
|
|
|
// 5.16
|
|
head, idx, ok := splitSelection(sel.Raw)
|
|
if ok && strings.IndexFunc(head, unicode.IsSpace) >= 0 {
|
|
ok = false
|
|
}
|
|
if !ok {
|
|
return selErr("5.16",
|
|
"invalid element selection '%s': expected name[k] or name[a-b] (no whitespace in the name or brackets)",
|
|
sel.Raw)
|
|
}
|
|
sel.Name = head
|
|
return checkIndex(sel, idx)
|
|
}
|