From fba690f1c62842b71ce49e213957c79a1856bc90 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sat, 24 Oct 2026 11:11:40 -0700 Subject: [PATCH] feat: add shared grammar terminals for systemd integer and route-table parsing (Resolves #563) Add SafeAtoTerminal, an exact model of systemd's safe_ato* family at 0f0682b773: signed/unsigned, base 0/8/10/16, the SAFE_ATO_REFUSE_* flags, BigInteger bounds up to the full 64-bit range, mangle_base's 0b/0o prefixes (with the sign and whitespace strtoul accepts after them), the disabled C23 0b support, and strtoul's modulo-2^64 wrap-around. Malformed input is a syntax error located where strtoul stops; out-of-range values are a semantic error on the number token. It works with both grammar engines, and valueOf() returns the stored value for predicates. Add ROUTE_TABLE_FROM_STRING for manager_get_route_table_from_string(): the predefined names, a non-zero safe_atou32, or any value that networkd.conf's RouteTable= could define as a name (non-empty, no ':', not all digits). That leniency is documented. Register SafeAtoTerminal as a LITERAL for colouring. Tests cover every corner case confirmed with the real systemd parsers (sd-check), under both engines, including the highlighted ranges. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../optionvalues/grammar/Coloring.kt | 1 + .../optionvalues/grammar/RouteTable.kt | 70 +++ .../optionvalues/grammar/SafeAtoTerminal.kt | 311 +++++++++++++ .../grammar/GrammarEngineVerdict.kt | 62 +++ .../optionvalues/grammar/RouteTableTest.kt | 84 ++++ .../grammar/SafeAtoTerminalTest.kt | 422 ++++++++++++++++++ 6 files changed, 950 insertions(+) create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/RouteTable.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/SafeAtoTerminal.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/GrammarEngineVerdict.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/RouteTableTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/SafeAtoTerminalTest.kt diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Coloring.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Coloring.kt index 03a8a56e..5f4c78b6 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Coloring.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Coloring.kt @@ -37,6 +37,7 @@ data class Region(val start: Int, val end: Int, val role: Role, val tag: Semanti fun defaultRole(terminal: TerminalCombinator): Role? = when (terminal) { is IntegerTerminal -> Role.LITERAL is UnsignedNumberTerminal -> Role.LITERAL + is SafeAtoTerminal -> Role.LITERAL is ByteSizeTerminal -> Role.LITERAL is LiteralChoiceTerminal -> if (terminal.choices.allPunctuation()) Role.OPERATOR else Role.ENUM is FlexibleLiteralChoiceTerminal -> if (terminal.choices.allPunctuation()) Role.OPERATOR else Role.ENUM diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/RouteTable.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/RouteTable.kt new file mode 100644 index 00000000..0b6e9d11 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/RouteTable.kt @@ -0,0 +1,70 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar + +/* + * Route tables as systemd-networkd reads them, pinned to systemd 0f0682b773 (the plugin's last_commit_hash). + * + * Every route-table setting hands its whole (stripped) value to manager_get_route_table_from_string(): + * [Route] Table=, [RoutingPolicyRule] Table=, [VRF] Table=, [WireGuard] RouteTable=, [WireGuardPeer] RouteTable=, + * and RouteTable= in [DHCPv4], [DHCPv6] and [IPv6AcceptRA]. That function tries, in order + * (https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-route-util.c#L435-L464): + * + * 1. the predefined names `default`, `main`, `local`, compared case-sensitively + * (https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-route-util.c#L427-L433); + * 2. the names an administrator defined with RouteTable=NAME:NUMBER in networkd.conf; + * 3. safe_atou32() on the whole value, refusing 0 with -ERANGE. + * + * THE LENIENCY. Step 2 depends on networkd.conf, which the plugin cannot see, so any value that could be such a + * name is accepted. config_parse_route_table_names() decides what a name may be + * (https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-route-util.c#L496-L616): + * - each word comes from extract_first_word(…, NULL, 0) (#L526). With flags 0 a backslash takes the next + * character literally: https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L142-L144 + * sets the flag and https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L105-L106 copies + * the character. So `my\ table:100` defines the name `my table`, and a name may contain whitespace; + * - the name is the part before the first `:` (#L537-L544), so it never contains `:`; + * - it must not be empty (#L546-L550) or consist only of the digits 0-9 (#L551-L555); + * - it must not be a predefined name (#L556-L561), but those are accepted by step 1 anyway. + * So every non-empty value without a `:` that is not all digits is a possible name. That includes spellings that + * look numeric but are not all digits, such as `0x0`, `-0`, `+5` and `0b 1`, and capitalised forms such as `Main`. + * Without a matching networkd.conf entry systemd rejects most of these (sd-check, which loads no networkd.conf, + * reports `Table=0x0`, `Table=-0` and `Table=mytable`). Flagging "a name that is not defined anywhere we can see" + * would be stricter than the parser and belongs in a separate, weaker inspection. + * + * What is flagged, as systemd does whatever networkd.conf says: a value containing `:`, and an all-digit value that + * safe_atou32 refuses (`0`, `00`, `08`, `4294967296`, `040000000000`). All of these were confirmed with sd-check on + * [Route] Table=, [RoutingPolicyRule] Table=, [DHCPv4] RouteTable= and [VRF] Table=. + * + * Settings that check something else first must add that branch themselves. The WireGuard keys, for example, take + * an empty value or a false boolean to mean "off" before they look up a table (netdev/wireguard.c). + */ + +/** The predefined table names (route_table_table), case-sensitive. */ +val ROUTE_TABLE_PREDEFINED_NAME = LiteralChoiceTerminal("default", "main", "local") + +/** A table number: safe_atou32() on the whole value, with 0 refused by manager_get_route_table_from_string(). */ +val ROUTE_TABLE_NUMBER = SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 1L, + maxInclusive = 4_294_967_295L, + acceptsWhitespace = true, + acceptsMinus = true, +) + +/** + * A word that networkd.conf's RouteTable= could have defined as a table name. It is non-empty, has no `:`, and is + * not made up only of digits (see the file comment for the C rules). The shape is exact, so an all-digit value is + * left to [ROUTE_TABLE_NUMBER] and its errors are located the way safe_atou32 would locate them. + */ +val ROUTE_TABLE_USER_DEFINED_NAME = RegexTerminal("""(?![0-9]+(?::|\z))[^:]+""", """(?![0-9]+(?::|\z))[^:]+""") + +/** + * A complete route-table value, as manager_get_route_table_from_string() reads it. This includes the end of input, + * so use it as the whole grammar of the value or as one alternative of it. Each branch carries its own [EOF], so + * the classic first-match engine can still fall back from the number branch to the name branch on `1abc`. + */ +val ROUTE_TABLE_FROM_STRING = AlternativeCombinator( + SequenceCombinator(ROUTE_TABLE_PREDEFINED_NAME, EOF()), + SequenceCombinator(ROUTE_TABLE_NUMBER, EOF()), + SequenceCombinator(ROUTE_TABLE_USER_DEFINED_NAME, EOF()), +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/SafeAtoTerminal.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/SafeAtoTerminal.kt new file mode 100644 index 00000000..84c220b7 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/SafeAtoTerminal.kt @@ -0,0 +1,311 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar + +import java.math.BigInteger + +/** + * An integer exactly as systemd's `safe_ato*()` family reads it. That is safe_atoi and safe_atolli (and so + * safe_atoi32, safe_atoi64 and safe_atoli), safe_atou_full and safe_atollu_full (and so safe_atou, safe_atou8, + * safe_atou16, safe_atou32, safe_atou64, safe_atolu, safe_atozu, safe_atoux16 and safe_atoux64), including an + * explicit base and the `SAFE_ATO_REFUSE_*` flags. Everything here is pinned to systemd 0f0682b773 (the plugin's + * `last_commit_hash`) and was confirmed against the real parsers with steve-local/systemd-tools/bin/sd-check. + * + * ## What systemd does + * + * Source: [mangle_base](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L403-L427), + * [safe_atou_full](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L429-L478), + * [safe_atoi](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L497-L520), + * [safe_atollu_full](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L522-L558), + * [safe_atolli](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L560-L581), + * [safe_atou8_full / safe_atou16_full / safe_atoi16](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L583-L638) + * and the [wrappers](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.h#L27-L122). + * + * 1. With [Flag.REFUSE_LEADING_WHITESPACE], a first character in WHITESPACE (`" \t\n\r"`) is -EINVAL. + * 2. Skip leading WHITESPACE (strspn). Note that `\v` and `\f` are not in it. + * 3. With [Flag.REFUSE_PLUS_MINUS], a `+` or `-` here is -EINVAL. With [Flag.REFUSE_LEADING_ZERO], a `0` here is + * -EINVAL unless the rest is exactly `0`. + * 4. `mangle_base()`, in base 0 only: a `0b`/`0B` here switches to base 2 and `0o`/`0O` to base 8, and the prefix is + * cut off. The signed helpers always use base 0 and take no flags. + * 5. strtoul/strtoull/strtol/strtoll on the rest. These skip C `isspace()` (which does include `\v` and `\f`) and + * take one optional `+` or `-`. Then, in base 0, they read `0x`/`0X` hex (only if a hex digit follows), a + * leading-`0` octal number, or a decimal one. In base 16 an optional `0x`/`0X` is skipped first. Bases 8 and 10 + * take plain digits. systemd turns off glibc's C23 `0b` support on purpose + * ([src/include/glibc/stdlib.h](https://github.com/systemd/systemd/blob/0f0682b773/src/include/glibc/stdlib.h#L4-L15)), + * so `+0b1` and `-0b0` are read as `+0`/`-0` followed by junk. + * 6. The whole string must be consumed (`*x != 0` is -EINVAL), so trailing whitespace is an error. + * 7. Range checks (-ERANGE). First strto*l's own overflow (more than 64 bits). Then, for the unsigned helpers, + * `l != 0 && s[0] == '-'`, where `s` is the text after step 4, so `-0` is fine and `-1` is not. Then the width + * check of the specific helper (`(unsigned) l != l`, `u > UINT8_MAX`, `(int) l != l`, …). + * + * Some consequences, all confirmed with sd-check: + * - accepted: `0b101`, `0O17`, `+5`, `-0`, `-0x0`, `-00`, `0b+1`, `0b-0`, `0b 1`, `0o 7`. Whitespace and a sign may + * follow the `0b`/`0o` prefix, because strtoul starts afresh there. Also `0x7fffffff` for an int, and `0b-1` and + * `0o -10` for a signed int. + * - malformed (-EINVAL): `08`, `0x`, `0b`, `0b2`, `0o8`, `+0b1`, `-0b0`, `0b0b1`, `0x0x1`, `+-1`, `+`, `- 5`, `5 `. + * - out of range (-ERANGE): `-1` and `0b-1` for any unsigned helper. For a uint16, `65536`, `0200000` and + * `0x10000`. For a uint64, `18446744073709551616`. + * - the strtoul wrap-around. A `-` that is *not* at `s[0]` escapes the minus check, and strtoul negates modulo + * 2^64. So for safe_atou64, `0b -1` and `\v-1` are 18446744073709551615 and accepted. For safe_atou32 they are out + * of range, but `0b -` followed by sixty-four `1`s is 1 and accepted. This terminal reproduces that. It assumes an + * LP64 target such as x86-64 or aarch64, where `unsigned long` is 64 bits. + * - the flags only look at the first character after step 2, so parse_mode()'s REFUSE_PLUS_MINUS still accepts + * `UMask=\v+22`, and parse_uid()'s REFUSE_LEADING_WHITESPACE still accepts a leading `\v`. + * + * ## Syntax versus range + * + * The token this terminal claims is exactly what strtoul() consumes, so a malformed number is a *syntax* error + * located where strtoul stopped (`08` matches `0` and leaves `8`; `0b2` gets stuck at `2`). A well-formed number + * outside `[minInclusive, maxInclusive]`, including the minus check, is a *semantic* error on the number token. + * When leading whitespace is allowed it is reported as a separate whitespace token, so the highlighted bad token is + * the number alone. + * + * ## Choosing the parameters + * + * Every parameter is required. Write them out with named arguments. + * + * @param signed true for safe_atoi, safe_atoi16 and safe_atolli (strtol: no wrap-around and no minus check); + * false for every safe_atou* helper. + * @param base the base the helper passes: 0 for every helper without an explicit base (the usual case, with + * `0b`/`0o`/`0x`/leading-`0`), 16 for safe_atoux16, safe_atoux64 and `safe_atou*_full(…, 16, …)`, 8 for + * parse_mode(), 10 for parse_uid(). Signed helpers only exist with base 0. + * @param flags the `SAFE_ATO_REFUSE_*` flags the helper passes, usually none. Signed helpers take no flags. The + * config-file callers that pass flags are + * [parse_mode](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L90-L110) (base 8, + * REFUSE_PLUS_MINUS), + * [parse_ip_port](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L718-L734) (base 0, + * REFUSE_LEADING_WHITESPACE, then 0 is refused) and + * [parse_uid](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/user-util.c#L66-L95) (base 10, all + * three). + * @param minInclusive smallest accepted value: the type minimum, or a caller's own bound (for example 1 when the + * caller refuses 0 afterwards). + * @param maxInclusive largest accepted value: the helper's type maximum ([UINT8_MAX], [UINT16_MAX], [UINT32_MAX], + * [UINT64_MAX], [INT32_MAX], [INT64_MAX], …) or a tighter caller bound. + * @param acceptsWhitespace true when whitespace can reach the parser. That is the case for a whole value (already + * stripped at both ends, but `0b 1` and a leading `\v` still matter), or for a field the caller cut out on a + * non-whitespace separator such as `:` or `/`. false inside a word that was split on whitespace, where `0b 1` is + * two words. + * @param acceptsMinus true when a `-` can reach the parser. false only where the caller has already cut the text at + * the first `-`, for example the lower bound of parse_range(), so `-0` there is an empty word (-EINVAL). + */ +class SafeAtoTerminal( + val signed: Boolean, + val base: Int, + val flags: Set, + val minInclusive: BigInteger, + val maxInclusive: BigInteger, + val acceptsWhitespace: Boolean, + val acceptsMinus: Boolean, +) : TerminalCombinator { + + /** Same as the primary constructor, for bounds that fit a Long (everything except values above INT64_MAX). */ + constructor( + signed: Boolean, + base: Int, + flags: Set, + minInclusive: Long, + maxInclusive: Long, + acceptsWhitespace: Boolean, + acceptsMinus: Boolean, + ) : this(signed, base, flags, BigInteger.valueOf(minInclusive), BigInteger.valueOf(maxInclusive), acceptsWhitespace, acceptsMinus) + + /** systemd's `SAFE_ATO_REFUSE_*` flags (src/basic/parse-util.h). */ + enum class Flag { + /** `SAFE_ATO_REFUSE_PLUS_MINUS`: a leading `+` or `-` is -EINVAL. */ + REFUSE_PLUS_MINUS, + + /** `SAFE_ATO_REFUSE_LEADING_ZERO`: a leading `0` is -EINVAL unless the whole number is `0`. */ + REFUSE_LEADING_ZERO, + + /** `SAFE_ATO_REFUSE_LEADING_WHITESPACE`: a leading `" \t\n\r"` is -EINVAL. */ + REFUSE_LEADING_WHITESPACE, + } + + init { + require(base == 0 || base == 8 || base == 10 || base == 16) { "systemd's config parsers use base 0, 8, 10 or 16, not $base" } + require(!signed || (base == 0 && flags.isEmpty())) { "systemd's signed safe_ato* helpers take no base and no flags" } + require(minInclusive <= maxInclusive) { "empty range [$minInclusive, $maxInclusive]" } + if (signed) { + require(minInclusive >= INT64_MIN && maxInclusive <= INT64_MAX) { "a signed range must lie within int64" } + } else { + require(minInclusive >= BigInteger.ZERO && maxInclusive <= UINT64_MAX) { "an unsigned range must lie within uint64" } + } + } + + /** What one scan at an offset found: either a number spanning [numberStart, end) or a dead end at [stuckAt]. */ + private sealed interface Scan + + /** + * strtoul consumed [numberStart, end); any whitespace before [numberStart] (from offset) was skipped. [value] is + * what systemd stores, or null when a range check fails. + */ + private data class Number(val numberStart: Int, val end: Int, val value: BigInteger?) : Scan { + val valid: Boolean get() = value != null + } + + /** No number here (-EINVAL); [stuckAt] is where one was expected. */ + private data class NoNumber(val stuckAt: Int) : Scan + + private fun scan(value: String, offset: Int): Scan { + val n = value.length + var p = offset + + if (acceptsWhitespace) { + if (Flag.REFUSE_LEADING_WHITESPACE in flags && p < n && value[p] in WHITESPACE) return NoNumber(p) + // s += strspn(s, WHITESPACE) + while (p < n && value[p] in WHITESPACE) p++ + } + + if (Flag.REFUSE_PLUS_MINUS in flags && p < n && (value[p] == '+' || value[p] == '-')) return NoNumber(p) + + if (Flag.REFUSE_LEADING_ZERO in flags && p < n && value[p] == '0') { + // Only a lone "0" survives the flag, so the number is that one digit and anything after it is junk. + return Number(p, p + 1, checkedValue(BigInteger.ZERO, negative = false, minusAtS = false)) + } + + // mangle_base(): only in base 0, only at the very start of what is left. + if (base == 0 && p + 1 < n && value[p] == '0' && value[p + 1] in "bBoO") { + val radix = if (value[p + 1] == 'b' || value[p + 1] == 'B') 2 else 8 + return strtoul(value, p + 2, numberStart = p, radix = radix) + } + + // strtoul() itself skips isspace() (which, unlike WHITESPACE, includes \v and \f); the token starts after it. + var q = p + if (acceptsWhitespace) { + while (q < n && value[q] in ISSPACE) q++ + } + return strtoul(value, p, numberStart = q, radix = base) + } + + /** + * strtoul()/strtol() on value[s..], with the number token starting at [numberStart] (after any whitespace already + * skipped). [radix] 0 means C literal syntax; 2 and 8 also come from mangle_base(). + */ + private fun strtoul(value: String, s: Int, numberStart: Int, radix: Int): Scan { + val n = value.length + var q = s + if (acceptsWhitespace) { + while (q < n && value[q] in ISSPACE) q++ + } + + var negative = false + val signAt = q + if (q < n && value[q] == '+') { + q++ + } else if (acceptsMinus && q < n && value[q] == '-') { + negative = true + q++ + } + + var digitRadix = radix + if ((radix == 0 || radix == 16) && + q + 2 < n && value[q] == '0' && (value[q + 1] == 'x' || value[q + 1] == 'X') && value[q + 2].isDigitOf(16) + ) { + // "0x" only counts as a prefix when a hex digit follows; otherwise glibc reads the "0" and stops at 'x'. + q += 2 + digitRadix = 16 + } else if (radix == 0) { + digitRadix = if (q < n && value[q] == '0') 8 else 10 + } + + val digitsStart = q + while (q < n && value[q].isDigitOf(digitRadix)) q++ + if (q == digitsStart) return NoNumber(digitsStart) + + val magnitude = BigInteger(value.substring(digitsStart, q), digitRadix) + return Number(numberStart, q, checkedValue(magnitude, negative, minusAtS = negative && signAt == s)) + } + + /** + * The -ERANGE checks: strto*l overflow, safe_atou*'s minus check (with strtoul's wrap-around), the width/bounds. + * Returns the value systemd stores, or null when one of them fails. + */ + private fun checkedValue(magnitude: BigInteger, negative: Boolean, minusAtS: Boolean): BigInteger? { + val v = if (signed) { + if (negative) magnitude.negate() else magnitude + } else { + if (magnitude > UINT64_MAX) return null // strtoul overflow + if (!negative || magnitude.signum() == 0) { + magnitude + } else { + if (minusAtS) return null // l != 0 && s[0] == '-' + TWO_TO_THE_64.subtract(magnitude) // strtoul negates modulo 2^64 + } + } + // For signed values this also covers strtol's own overflow, since the bounds lie within int64. + return if (v >= minInclusive && v <= maxInclusive) v else null + } + + /** + * The value systemd would store for the whole of [text], or null if this terminal would flag it (malformed, + * out of range, or not consuming all of [text]). For predicates that need the number itself, such as a check + * against a set of allowed values or a comparison between two fields. + */ + fun valueOf(text: String): BigInteger? { + val scan = scan(text, 0) + return if (scan is Number && scan.end == text.length) scan.value else null + } + + /** An ASCII digit in [radix] (the C locale; Unicode digits are not digits to strtoul). */ + private fun Char.isDigitOf(radix: Int): Boolean = + (this in '0'..'9' && this - '0' < radix) || (radix == 16 && (this in 'a'..'f' || this in 'A'..'F')) + + override fun SyntacticMatch(value: String, offset: Int): MatchResult = when (val scan = scan(value, offset)) { + is NoNumber -> NoMatch.copy(longestMatch = scan.stuckAt) + is Number -> matched(value, offset, scan) + } + + override fun SemanticMatch(value: String, offset: Int): MatchResult = when (val scan = scan(value, offset)) { + is NoNumber -> NoMatch.copy(longestMatch = scan.stuckAt) + // Point longestMatch at the number (past any leading whitespace) so the classic engine highlights it. + is Number -> if (scan.valid) matched(value, offset, scan) else NoMatch.copy(longestMatch = scan.numberStart) + } + + private fun matched(value: String, offset: Int, scan: Number): MatchResult { + val number = value.substring(scan.numberStart, scan.end) + return if (scan.numberStart == offset) { + MatchResult(listOf(number), scan.end, listOf(this), scan.end) + } else { + MatchResult(listOf(value.substring(offset, scan.numberStart), number), scan.end, listOf(LEADING_WHITESPACE, this), scan.end) + } + } + + override fun parse(value: String, offset: Int): Sequence = when (val scan = scan(value, offset)) { + is NoNumber -> sequenceOf(Stuck(scan.stuckAt, setOf(this))) + is Number -> { + val number = ParsedToken(scan.numberStart, scan.end, value.substring(scan.numberStart, scan.end), this, scan.valid) + val tokens = if (scan.numberStart == offset) { + listOf(number) + } else { + listOf(ParsedToken(offset, scan.numberStart, value.substring(offset, scan.numberStart), LEADING_WHITESPACE, true), number) + } + sequenceOf(Parse(scan.end, tokens)) + } + } + + override fun toString(): String = + "SafeAto(${if (signed) "signed" else "unsigned"},base=$base${flags.joinToString("") { ",${it.name}" }}," + + "[$minInclusive,$maxInclusive]${if (acceptsWhitespace) ",ws" else ""}${if (acceptsMinus) "" else ",no-minus"})" + + companion object { + /** systemd's WHITESPACE, which safe_ato* skip with strspn() before mangle_base(). */ + private const val WHITESPACE = " \t\n\r" + + /** C isspace() in the C locale, which strto*l() skip before the sign. */ + private const val ISSPACE = " \t\n\u000B\u000C\r" + + /** The terminal recorded for skipped leading whitespace, so it is never coloured or highlighted as the number. */ + private val LEADING_WHITESPACE = WhitespaceTerminal() + + private val TWO_TO_THE_64: BigInteger = BigInteger.ONE.shiftLeft(64) + + val UINT8_MAX: BigInteger = BigInteger.valueOf(255) + val UINT16_MAX: BigInteger = BigInteger.valueOf(65_535) + val UINT32_MAX: BigInteger = BigInteger.valueOf(4_294_967_295L) + val UINT64_MAX: BigInteger = TWO_TO_THE_64.subtract(BigInteger.ONE) + val INT16_MIN: BigInteger = BigInteger.valueOf(Short.MIN_VALUE.toLong()) + val INT16_MAX: BigInteger = BigInteger.valueOf(Short.MAX_VALUE.toLong()) + val INT32_MIN: BigInteger = BigInteger.valueOf(Int.MIN_VALUE.toLong()) + val INT32_MAX: BigInteger = BigInteger.valueOf(Int.MAX_VALUE.toLong()) + val INT64_MIN: BigInteger = BigInteger.valueOf(Long.MIN_VALUE) + val INT64_MAX: BigInteger = BigInteger.valueOf(Long.MAX_VALUE) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/GrammarEngineVerdict.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/GrammarEngineVerdict.kt new file mode 100644 index 00000000..3deccc84 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/GrammarEngineVerdict.kt @@ -0,0 +1,62 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar + +/** + * What the InvalidValue inspection would report for a value, computed purely from a whole-value grammar, once + * per grammar engine. Lets combinator tests assert verdicts and highlighted ranges for both engines in one run, + * whatever `-Dsystemd.unit.grammarParseEngine` says. + * + * Each function mirrors the range logic of its engine's branch in [GrammarOptionValue.generateProblemDescriptors]; + * keep them in step with it. + */ +data class GrammarEngineVerdict(val kind: Kind, val start: Int, val end: Int) { + + enum class Kind { VALID, SYNTAX, SEMANTIC } + + override fun toString(): String = if (kind == Kind.VALID) "VALID" else "$kind[$start,$end)" + + companion object { + val VALID = GrammarEngineVerdict(Kind.VALID, 0, 0) + + /** The classic SyntacticMatch / SemanticMatch engine. */ + fun classic(grammar: Combinator, value: String): GrammarEngineVerdict { + val syntactic = grammar.SyntacticMatch(value, 0) + if (syntactic.matchResult == -1) { + return if (syntactic.longestMatch < value.length) { + GrammarEngineVerdict(Kind.SYNTAX, syntactic.longestMatch, value.length) + } else { + GrammarEngineVerdict(Kind.SYNTAX, 0, value.length) + } + } + + val semantic = grammar.SemanticMatch(value, 0) + if (semantic.matchResult == -1) { + if (semantic.tokens.size < syntactic.tokens.size) { + var tokenLength = 0 + var tokenIndex = 0 + for (token in syntactic.tokens) { + tokenLength += token.length + if (tokenLength > semantic.longestMatch) break + tokenIndex++ + } + val start = semantic.longestMatch + return GrammarEngineVerdict(Kind.SEMANTIC, start, start + syntactic.tokens[tokenIndex].length) + } + return GrammarEngineVerdict(Kind.SEMANTIC, 0, value.length) + } + + return VALID + } + + /** The list-of-successes engine ([Combinator.validate]). */ + fun parseEngine(grammar: Combinator, value: String): GrammarEngineVerdict = + when (val outcome = grammar.validate(value)) { + is ParseOutcome.Valid -> VALID + is ParseOutcome.SyntaxError -> if (outcome.furthest < value.length) { + GrammarEngineVerdict(Kind.SYNTAX, outcome.furthest, value.length) + } else { + GrammarEngineVerdict(Kind.SYNTAX, 0, value.length) + } + is ParseOutcome.SemanticError -> GrammarEngineVerdict(Kind.SEMANTIC, outcome.badToken.start, outcome.badToken.end) + } + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/RouteTableTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/RouteTableTest.kt new file mode 100644 index 00000000..c2cd5cd5 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/RouteTableTest.kt @@ -0,0 +1,84 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.GrammarEngineVerdict.Kind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [ROUTE_TABLE_FROM_STRING] against manager_get_route_table_from_string() at systemd 0f0682b773. + * + * Values in [testAcceptedBySystemd] and the rejected tests were confirmed with steve-local/systemd-tools/bin/sd-check + * on [Route] Table=, [RoutingPolicyRule] Table=, [DHCPv4] RouteTable= and [VRF] Table=, which agree on every value. + * sd-check loads no networkd.conf, so the values in [testAcceptedAsPossibleUserDefinedNames] are rejected there; + * they are accepted on purpose, because RouteTable=NAME:NUMBER in networkd.conf could define them. + */ +class RouteTableTest { + + private fun assertVerdict(value: String, expected: GrammarEngineVerdict) { + assertEquals("classic engine on <$value>", expected, GrammarEngineVerdict.classic(ROUTE_TABLE_FROM_STRING, value)) + assertEquals("parse engine on <$value>", expected, GrammarEngineVerdict.parseEngine(ROUTE_TABLE_FROM_STRING, value)) + } + + private fun accepted(vararg values: String) { + for (value in values) assertVerdict(value, GrammarEngineVerdict.VALID) + } + + @Test + fun testAcceptedBySystemd() { + accepted("default", "main", "local") + accepted("1", "253", "254", "255", "4294967295", "010", "037777777777") + // Not all digits, so safe_atou32 reads them, with all its spellings. + accepted("0x10", "+5", "0b101", "0b 1") + } + + @Test + fun testAcceptedAsPossibleUserDefinedNames() { + // Rejected by sd-check, which has no networkd.conf; RouteTable=Main:100 etc. would make each of them valid. + accepted("Main", "mytable", "yes", "0x0", "-0", "-1", "0x100000000") + // extract_first_word(…, 0) unescapes "my\ table:100" to the name "my table". + accepted("my table", "a\tb") + } + + @Test + fun testAllDigitTableNumbersOutOfRange() { + // All digits: never a name, so safe_atou32 decides, and 0 is refused with ERANGE. + for (value in listOf("0", "00", "4294967296", "040000000000", "99999999999999999999999")) { + assertVerdict(value, GrammarEngineVerdict(Kind.SEMANTIC, 0, value.length)) + } + } + + @Test + fun testMalformedAllDigitNumber() { + // "08" is not octal: EINVAL, highlighted where strtoul stopped. + assertVerdict("08", GrammarEngineVerdict(Kind.SYNTAX, 1, 2)) + assertVerdict("0779", GrammarEngineVerdict(Kind.SYNTAX, 3, 4)) + } + + @Test + fun testColonIsNeverPartOfATable() { + // A name is cut at the first ':' in networkd.conf, and safe_atou32 has no ':' either. + assertVerdict("a:5", GrammarEngineVerdict(Kind.SYNTAX, 1, 3)) + assertVerdict("main:5", GrammarEngineVerdict(Kind.SYNTAX, 4, 6)) + assertVerdict("123:5", GrammarEngineVerdict(Kind.SYNTAX, 3, 5)) + assertVerdict("0x0:5", GrammarEngineVerdict(Kind.SYNTAX, 3, 5)) + assertVerdict(":5", GrammarEngineVerdict(Kind.SYNTAX, 0, 2)) + } + + @Test + fun testUserDefinedNameShape() { + // Exactly the networkd.conf rules: non-empty, no ':', not only digits. + val name = SequenceCombinator(ROUTE_TABLE_USER_DEFINED_NAME, EOF()) + for (value in listOf("x", "main", "1a", "a1", "0x0", "-0", "my table", "é")) { + assertEquals(value, ParseOutcome.Valid, name.validate(value)) + } + for (value in listOf("1", "0123456789", "a:b", ":", "1:a")) { + assertTrue(value, name.validate(value) is ParseOutcome.SyntaxError) + } + } + + @Test + fun testPredefinedNamesAreOfferedForCompletion() { + assertEquals(setOf("default", "main", "local"), ROUTE_TABLE_FROM_STRING.nextTokenChoices("")) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/SafeAtoTerminalTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/SafeAtoTerminalTest.kt new file mode 100644 index 00000000..96597971 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/SafeAtoTerminalTest.kt @@ -0,0 +1,422 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.GrammarEngineVerdict.Kind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.math.BigInteger + +/** + * [SafeAtoTerminal] against the verdicts of systemd's real parsers at 0f0682b773. + * + * Every value in the whole-value tests was run through steve-local/systemd-tools/bin/sd-check, one value per file, + * on the key named above its grammar. (The list and range tests near the end model a caller's own splitting and + * are not sd-check probes.) "Malformed" values are the ones systemd rejects with EINVAL ("Invalid argument") and must be + * syntax errors; "out of range" values are the ones it rejects with ERANGE (or a caller's own range check) and + * must be semantic errors on the number token. Each value is checked under both grammar engines, including the + * range the inspection would highlight. + */ +class SafeAtoTerminalTest { + + private fun wholeValue(terminal: SafeAtoTerminal): Combinator = SequenceCombinator(terminal, EOF()) + + private fun assertVerdict(grammar: Combinator, value: String, expected: GrammarEngineVerdict) { + assertEquals("classic engine on <$value>", expected, GrammarEngineVerdict.classic(grammar, value)) + assertEquals("parse engine on <$value>", expected, GrammarEngineVerdict.parseEngine(grammar, value)) + } + + private fun accepted(grammar: Combinator, vararg values: String) { + for (value in values) assertVerdict(grammar, value, GrammarEngineVerdict.VALID) + } + + /** A syntax error highlighted from [from] to the end; [from] 0 means the whole value. */ + private fun malformed(grammar: Combinator, value: String, from: Int) { + assertVerdict(grammar, value, GrammarEngineVerdict(Kind.SYNTAX, from, value.length)) + } + + /** A semantic error highlighting exactly [token] (the number, without leading whitespace). */ + private fun outOfRange(grammar: Combinator, value: String, token: String = value) { + val start = value.indexOf(token) + assertVerdict(grammar, value, GrammarEngineVerdict(Kind.SEMANTIC, start, start + token.length)) + } + + private val ones64 = "1".repeat(64) + + // safe_atou16, as config_parse_uint16 reads [DHCPv4] ListenPort=. + private val uint16 = wholeValue( + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 65_535L, acceptsWhitespace = true, acceptsMinus = true) + ) + + @Test + fun testUint16AcceptsEverySpellingSystemdAccepts() { + accepted(uint16, "5", "65535", "0", "00", "010", "0177777", "0xffff", "0XFF", "+0x10", "+5") + // mangle_base's Python-style prefixes, in either case. + accepted(uint16, "0b101", "0B11", "0o17", "0O17") + // strtoul starts afresh after a 0b/0o prefix: a sign and whitespace may follow it. + accepted(uint16, "0b+1", "0b-0", "0b 1", "0o 7", "0b\t1", "0b +1", "0b -0") + // safe_atou_full only refuses a '-' when the value is not zero. + accepted(uint16, "-0", "-00", "-0x0") + // Leading whitespace: strspn(WHITESPACE), then strtoul's isspace() (which includes \v). + accepted(uint16, " 5", "\u000B5") + } + + @Test + fun testUint16MalformedIsASyntaxErrorWhereStrtoulStopped() { + malformed(uint16, "08", 1) // octal: strtoul reads "0" and stops at '8' + malformed(uint16, "0x", 1) // "0x" without a hex digit is the number 0 followed by 'x' + malformed(uint16, "0x1g", 3) + malformed(uint16, "0x0x1", 3) + malformed(uint16, "0b", 0) // mangle_base strips "0b" and strtoul finds nothing + malformed(uint16, "0o", 0) + malformed(uint16, "0b2", 2) + malformed(uint16, "0o8", 2) + malformed(uint16, "0b0b1", 3) // systemd turns off glibc's C23 "0b" support in strtoul + malformed(uint16, "+0b1", 2) // a sign hides the prefix from mangle_base; strtoul reads "+0" + malformed(uint16, "-0b0", 2) + malformed(uint16, "+-1", 1) + malformed(uint16, "++1", 1) + malformed(uint16, "+", 0) + malformed(uint16, "-", 0) + malformed(uint16, "5 ", 1) // the whole string must be consumed + malformed(uint16, "0 x1", 1) + malformed(uint16, "abc", 0) + malformed(uint16, "٣", 0) // ARABIC-INDIC DIGIT THREE is not a C digit + } + + @Test + fun testUint16OutOfRangeIsASemanticErrorOnTheNumber() { + outOfRange(uint16, "65536") + outOfRange(uint16, "0x10000") + outOfRange(uint16, "0200000") + outOfRange(uint16, "-1") // ERANGE, not EINVAL: strtoul parses it, safe_atou_full refuses the sign + outOfRange(uint16, "0b-1") + outOfRange(uint16, "0b -1") // wraps to 2^64-1, which (unsigned) l != l refuses + outOfRange(uint16, " 65536", "65536") // leading whitespace is its own token, not highlighted + } + + // safe_atou32, as config_parse_uint32 reads [DHCPServer] PoolOffset=. + private val uint32 = wholeValue( + SafeAtoTerminal( + signed = false, base = 0, flags = emptySet(), minInclusive = BigInteger.ZERO, maxInclusive = SafeAtoTerminal.UINT32_MAX, + acceptsWhitespace = true, acceptsMinus = true, + ) + ) + + @Test + fun testUint32BoundsAndWrapAround() { + accepted(uint32, "4294967295", "0xffffffff", "037777777777") + outOfRange(uint32, "4294967296") + outOfRange(uint32, "0x100000000") + outOfRange(uint32, "040000000000") + outOfRange(uint32, "0b -1") + // A '-' not at s[0] escapes the minus check and strtoul negates modulo 2^64: -(2^64-1) is 1, -(2^64-2) is 2. + accepted(uint32, "0b -$ones64", "0o -1777777777777777777777", "0b -${"1".repeat(63)}0") + // ...but -(2^63-2) wraps to 2^63+2, which does not fit, and a '-' right after the prefix is refused outright. + outOfRange(uint32, "0b -${"1".repeat(62)}0") + outOfRange(uint32, "0b-${"1".repeat(63)}0") + } + + // safe_atou64 with the caller refusing 0, as config_parse_dhcp_max_attempts reads [DHCPv4] MaxAttempts=. + private val uint64Positive = wholeValue( + SafeAtoTerminal( + signed = false, base = 0, flags = emptySet(), minInclusive = BigInteger.ONE, maxInclusive = SafeAtoTerminal.UINT64_MAX, + acceptsWhitespace = true, acceptsMinus = true, + ) + ) + + @Test + fun testUint64FullRange() { + accepted(uint64Positive, "18446744073709551615", "0xffffffffffffffff", "01777777777777777777777", "0b$ones64", "\u000B+5") + outOfRange(uint64Positive, "18446744073709551616") + outOfRange(uint64Positive, "0x10000000000000000") + outOfRange(uint64Positive, "02000000000000000000000") + outOfRange(uint64Positive, "0b1$ones64") + outOfRange(uint64Positive, "0") + outOfRange(uint64Positive, "-0") + outOfRange(uint64Positive, "-1") + outOfRange(uint64Positive, " -1", "-1") + outOfRange(uint64Positive, "0b-1") + } + + @Test + fun testUint64WrapAroundIsAccepted() { + // 2^64-1 via strtoul's negation: '-' after whitespace (after the prefix, or after a \v/\f that WHITESPACE + // does not cover) is not at s[0]. + accepted(uint64Positive, "0b -1", "0o -1", "\u000B-1", "\u000C-1", "0b -$ones64", "0b -${"1".repeat(63)}0") + // A magnitude that overflows 64 bits is ERANGE even when negated. + outOfRange(uint64Positive, "0b -1$ones64") + } + + @Test + fun testUint64PrefixAfterVerticalTabIsNotMangled() { + // strspn(WHITESPACE) stops at \v, so mangle_base does not see the "0b"; strtoul then reads "0" and stops. + malformed(uint64Positive, "\u000B0b1", 2) + malformed(uint64Positive, "0x -1", 1) + malformed(uint64Positive, "0b -0x1", 5) + malformed(uint64Positive, "0b-", 0) + malformed(uint64Positive, "0b -", 0) + malformed(uint64Positive, "0b +", 0) + } + + // safe_atou8, as config_parse_uint8 reads [GENEVE] TOS=. + private val uint8 = wholeValue( + SafeAtoTerminal( + signed = false, base = 0, flags = emptySet(), minInclusive = BigInteger.ZERO, maxInclusive = SafeAtoTerminal.UINT8_MAX, + acceptsWhitespace = true, acceptsMinus = true, + ) + ) + + @Test + fun testUint8() { + accepted(uint8, "255", "0xff", "0377", "-0") + outOfRange(uint8, "256") + outOfRange(uint8, "0x100") + outOfRange(uint8, "0400") + outOfRange(uint8, "0b -1") + } + + // safe_atoi, as config_parse_int reads [Socket] Priority=. + private val int32 = wholeValue( + SafeAtoTerminal( + signed = true, base = 0, flags = emptySet(), minInclusive = SafeAtoTerminal.INT32_MIN, maxInclusive = SafeAtoTerminal.INT32_MAX, + acceptsWhitespace = true, acceptsMinus = true, + ) + ) + + @Test + fun testInt32() { + accepted(int32, "5", "-5", "+5", "-0", "-010", "2147483647", "-2147483648", "0x7fffffff", "-0x80000000", " -5", "\u000B-5") + // A sign, and whitespace, may follow the 0b/0o prefix. + accepted(int32, "0b-1", "0b -1", "0b+1", "0b 1", "0o-7", "0o -10", "0o\t-7") + outOfRange(int32, "2147483648") + outOfRange(int32, "-2147483649") + outOfRange(int32, "0x80000000") + outOfRange(int32, "-0x80000001") + outOfRange(int32, "-9223372036854775808") + outOfRange(int32, "0b11111111111111111111111111111111") + outOfRange(int32, "0b -${"1".repeat(63)}0") + malformed(int32, "-0b1", 2) + malformed(int32, "+0o7", 2) + malformed(int32, "08", 1) + malformed(int32, "-08", 2) + malformed(int32, "0x", 1) + malformed(int32, "-0x", 2) + malformed(int32, "-\u000B5", 1) + malformed(int32, "- 5", 1) + } + + // safe_atoli (safe_atolli on LP64), as config_parse_long reads [Socket] MessageQueueMaxMessages=. + private val int64 = wholeValue( + SafeAtoTerminal( + signed = true, base = 0, flags = emptySet(), minInclusive = SafeAtoTerminal.INT64_MIN, maxInclusive = SafeAtoTerminal.INT64_MAX, + acceptsWhitespace = true, acceptsMinus = true, + ) + ) + + @Test + fun testInt64() { + accepted(int64, "9223372036854775807", "-9223372036854775808", "0x7fffffffffffffff", "-0x8000000000000000", "0b -1") + outOfRange(int64, "9223372036854775808") + outOfRange(int64, "-9223372036854775809") + outOfRange(int64, "0x8000000000000000") + } + + // safe_atou32_full(…, 16, …), as config_parse_hierarchy_token_bucket_default_class reads [HierarchyTokenBucket] DefaultClass=. + private val hexUint32 = wholeValue( + SafeAtoTerminal( + signed = false, base = 16, flags = emptySet(), minInclusive = BigInteger.ZERO, maxInclusive = SafeAtoTerminal.UINT32_MAX, + acceptsWhitespace = true, acceptsMinus = true, + ) + ) + + @Test + fun testBase16() { + // No mangle_base in base 16: "0b1" is the hex number 0xb1, and an optional 0x is skipped by strtoul itself. + accepted(hexUint32, "ff", "0xff", "0XFF", "ffffffff", "10", "+a", "-0", "-0x0", "0b1", " a") + outOfRange(hexUint32, "100000000") + outOfRange(hexUint32, "-1") + outOfRange(hexUint32, "\u000B-1", "-1") // wraps to 2^64-1 + malformed(hexUint32, "0x", 1) + malformed(hexUint32, "0o7", 1) + malformed(hexUint32, "0x0x1", 3) + malformed(hexUint32, "g", 0) + } + + // safe_atoux64 with the caller's t <= INT64_MAX check, as config_parse_dhcp_pd_subnet_id reads [DHCPPrefixDelegation] SubnetId=. + private val hexSubnetId = wholeValue( + SafeAtoTerminal( + signed = false, base = 16, flags = emptySet(), minInclusive = BigInteger.ZERO, maxInclusive = SafeAtoTerminal.INT64_MAX, + acceptsWhitespace = true, acceptsMinus = true, + ) + ) + + @Test + fun testBase16Uint64() { + accepted(hexSubnetId, "7fffffffffffffff", "0x7fffffffffffffff", "-0") + outOfRange(hexSubnetId, "8000000000000000") + outOfRange(hexSubnetId, "ffffffffffffffff") + outOfRange(hexSubnetId, "10000000000000000") + outOfRange(hexSubnetId, "\u000B-1", "-1") + } + + // parse_mode(): safe_atou_full(s, 8 | SAFE_ATO_REFUSE_PLUS_MINUS), then m <= 07777, as config_parse_mode reads [Service] UMask=. + private val mode = wholeValue( + SafeAtoTerminal( + signed = false, base = 8, flags = setOf(SafeAtoTerminal.Flag.REFUSE_PLUS_MINUS), minInclusive = 0L, maxInclusive = 4095L, + acceptsWhitespace = true, acceptsMinus = true, + ) + ) + + @Test + fun testBase8WithRefusePlusMinus() { + accepted(mode, "0022", "022", "22", "7777", "07777", " 22") + // The flag only looks at s[0] after strspn(WHITESPACE), and \v is not in WHITESPACE. + accepted(mode, "\u000B+22", "\u000B-0") + outOfRange(mode, "10000") + malformed(mode, "8", 0) + malformed(mode, "0o22", 1) // no mangle_base with an explicit base + malformed(mode, "0x12", 1) + malformed(mode, "0b1", 1) + malformed(mode, "0 22", 1) + malformed(mode, "+22", 0) + malformed(mode, "-0", 0) + } + + // parse_ip_port(): safe_atou16_full(s, SAFE_ATO_REFUSE_LEADING_WHITESPACE), then 0 refused, as config_parse_ip_port + // reads [FooOverUDP] Port=. + private val ipPort = wholeValue( + SafeAtoTerminal( + signed = false, base = 0, flags = setOf(SafeAtoTerminal.Flag.REFUSE_LEADING_WHITESPACE), minInclusive = 1L, + maxInclusive = 65_535L, acceptsWhitespace = true, acceptsMinus = true, + ) + ) + + @Test + fun testRefuseLeadingWhitespace() { + // The masked base is still 0, so mangle_base still runs; \v is not in WHITESPACE, so the flag lets it through. + accepted(ipPort, "5555", "65535", "0x15b3", "0b101", "+5", "\u000B5", "0b 1") + outOfRange(ipPort, "0") + outOfRange(ipPort, "-0") + outOfRange(ipPort, "65536") + malformed(ipPort, "\u000B0b1", 2) + } + + // parse_uid(): safe_atou32_full(s, 10 | REFUSE_PLUS_MINUS | REFUSE_LEADING_ZERO | REFUSE_LEADING_WHITESPACE), then + // uid_is_valid(), as config_parse_private_users reads the shift of .nspawn [Exec] PrivateUsers=. + private val uid = wholeValue( + SafeAtoTerminal( + signed = false, base = 10, + flags = setOf( + SafeAtoTerminal.Flag.REFUSE_PLUS_MINUS, + SafeAtoTerminal.Flag.REFUSE_LEADING_ZERO, + SafeAtoTerminal.Flag.REFUSE_LEADING_WHITESPACE, + ), + minInclusive = 0L, maxInclusive = 4_294_967_294L, acceptsWhitespace = true, acceptsMinus = true, + ) + ) + + @Test + fun testBase10WithEveryFlag() { + // "0" itself survives REFUSE_LEADING_ZERO (sd-check: PrivateUsers=0:65536). uid_is_valid() also refuses 65535, + // a hole a single range cannot express; that is the caller's business, not this terminal's. + accepted(uid, "65536", "\u000B65536", "0") + outOfRange(uid, "4294967295") + malformed(uid, "065536", 1) // a leading zero is refused unless the number is just "0" + malformed(uid, "0x10000", 1) + malformed(uid, "0b10000000000000000", 1) + malformed(uid, "+65536", 0) + malformed(uid, "-0", 0) + } + + @Test + fun testWithoutWhitespaceInsideAWhitespaceSeparatedList() { + // In a word split on whitespace, "0b 1" is the invalid word "0b" followed by "1" (EINVAL). + val number = SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 255L, acceptsWhitespace = false, acceptsMinus = true) + val list = SequenceCombinator(number, ZeroOrMore(SequenceCombinator(WhitespaceTerminal(), number)), EOF()) + accepted(list, "1", "1 2", "0b1 0o7 0x10 +5 -0") + malformed(list, "0b 1", 2) + outOfRange(list, "1 256", "256") + outOfRange(list, "1 -1", "-1") + } + + @Test + fun testWithoutMinusAsTheLowerBoundOfARange() { + // parse_range() cuts at the first '-', so the lower bound never sees one; the upper bound does. + val lower = SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 65_535L, acceptsWhitespace = true, acceptsMinus = false) + val upper = SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 65_535L, acceptsWhitespace = true, acceptsMinus = true) + val range = SequenceCombinator(lower, ZeroOrOne(SequenceCombinator(LiteralChoiceTerminal("-"), upper)), EOF()) + accepted(range, "5", "5-10", "5--0", "0b1-0o7", "+1-+2", "1- 2") + malformed(range, "-0", 0) + malformed(range, "-5", 0) + malformed(range, "0b-1-5", 2) + outOfRange(range, "5--1", "-1") + } + + @Test + fun testColoringSkipsTheLeadingWhitespace() { + val number = SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 255L, acceptsWhitespace = true, acceptsMinus = true) + assertEquals(Role.LITERAL, defaultRole(number)) + assertEquals(listOf(Region(1, 3, Role.LITERAL)), SequenceCombinator(number, EOF()).colorize("\u000B17")) + assertEquals(listOf(Region(0, 4, Role.LITERAL)), SequenceCombinator(number, EOF()).colorize("0b 1")) + } + + @Test + fun testClassicTokensSplitOffLeadingWhitespace() { + val number = SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 255L, acceptsWhitespace = true, acceptsMinus = true) + val match = number.SyntacticMatch("x 0x1f", 1) + assertEquals(7, match.matchResult) + assertEquals(listOf(" ", "0x1f"), match.tokens) + assertEquals(listOf("WhitespaceTerminal", "SafeAtoTerminal"), match.terminals.map { it.javaClass.simpleName }) + } + + @Test + fun testValueOfReturnsWhatSystemdStores() { + val u32 = SafeAtoTerminal( + signed = false, base = 0, flags = emptySet(), minInclusive = BigInteger.ZERO, maxInclusive = SafeAtoTerminal.UINT32_MAX, + acceptsWhitespace = true, acceptsMinus = true, + ) + assertEquals(BigInteger.valueOf(31), u32.valueOf("0x1f")) + assertEquals(BigInteger.valueOf(5), u32.valueOf("0b+101")) + assertEquals(BigInteger.valueOf(8), u32.valueOf("010")) + assertEquals(BigInteger.ZERO, u32.valueOf("-0")) + assertEquals(BigInteger.ONE, u32.valueOf("0b -$ones64")) + assertEquals(BigInteger.valueOf(7), u32.valueOf(" 7")) + assertEquals(null, u32.valueOf("4294967296")) + assertEquals(null, u32.valueOf("-1")) + assertEquals(null, u32.valueOf("08")) + assertEquals(null, u32.valueOf("5 ")) + val i32 = SafeAtoTerminal( + signed = true, base = 0, flags = emptySet(), minInclusive = SafeAtoTerminal.INT32_MIN, maxInclusive = SafeAtoTerminal.INT32_MAX, + acceptsWhitespace = true, acceptsMinus = true, + ) + assertEquals(BigInteger.valueOf(-8), i32.valueOf("0o -10")) + } + + @Test + fun testRejectsParametersSystemdHasNoHelperFor() { + assertThrows(IllegalArgumentException::class.java) { + SafeAtoTerminal(signed = false, base = 2, flags = emptySet(), minInclusive = 0L, maxInclusive = 1L, acceptsWhitespace = true, acceptsMinus = true) + } + assertThrows(IllegalArgumentException::class.java) { + SafeAtoTerminal( + signed = true, base = 0, flags = setOf(SafeAtoTerminal.Flag.REFUSE_PLUS_MINUS), minInclusive = 0L, maxInclusive = 1L, + acceptsWhitespace = true, acceptsMinus = true, + ) + } + assertThrows(IllegalArgumentException::class.java) { + SafeAtoTerminal(signed = true, base = 16, flags = emptySet(), minInclusive = 0L, maxInclusive = 1L, acceptsWhitespace = true, acceptsMinus = true) + } + assertThrows(IllegalArgumentException::class.java) { + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = -1L, maxInclusive = 1L, acceptsWhitespace = true, acceptsMinus = true) + } + assertThrows(IllegalArgumentException::class.java) { + SafeAtoTerminal( + signed = true, base = 0, flags = emptySet(), minInclusive = BigInteger.ZERO, maxInclusive = SafeAtoTerminal.UINT64_MAX, + acceptsWhitespace = true, acceptsMinus = true, + ) + } + assertThrows(IllegalArgumentException::class.java) { + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 2L, maxInclusive = 1L, acceptsWhitespace = true, acceptsMinus = true) + } + } +}