From 3431814eb45b73440d581caceb5d7260c2e495e5 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Fri, 23 Oct 2026 18:22:15 -0700 Subject: [PATCH 1/8] feat: add grammar validators for [Timer] OnCalendar= and monotonic timers (unit-core batch) Models calendar_spec_from_string and parse_sec as structural grammars (shared in BatchUnitCoreCombinators.kt), with a unit-specifier escape hatch for values that use %-specifiers. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../semanticdata/optionvalues/AiGenerated.kt | 9 + .../ai/ConfigParseTimerCalendarOptionValue.kt | 47 +++ .../ConfigParseTimerMonotonicOptionValue.kt | 34 ++ .../grammar/BatchUnitCoreCombinators.kt | 388 ++++++++++++++++++ ...ConfigParseTimerCalendarOptionValueTest.kt | 166 ++++++++ ...onfigParseTimerMonotonicOptionValueTest.kt | 103 +++++ 6 files changed, 747 insertions(+) create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerMonotonicOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerCalendarOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerMonotonicOptionValueTest.kt diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt index 36704512..fa66e5a3 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt @@ -413,6 +413,15 @@ fun getAllAIGeneratedValidators(): Map { Validator("config_parse_socket_service", "0") to ConfigParseSocketServiceOptionValue() as OptionValueInformation, Validator("config_parse_service_sockets", "0") to ConfigParseServiceSocketsOptionValue() as OptionValueInformation, + // Batch 2026-10: unit-core + // config_parse_timer: TIMER_CALENDAR goes to calendar_spec_from_string, every other ltype to parse_sec. + Validator("config_parse_timer", "TIMER_CALENDAR") to ConfigParseTimerCalendarOptionValue() as OptionValueInformation, + Validator("config_parse_timer", "TIMER_ACTIVE") to ConfigParseTimerMonotonicOptionValue() as OptionValueInformation, + Validator("config_parse_timer", "TIMER_BOOT") to ConfigParseTimerMonotonicOptionValue() as OptionValueInformation, + Validator("config_parse_timer", "TIMER_STARTUP") to ConfigParseTimerMonotonicOptionValue() as OptionValueInformation, + Validator("config_parse_timer", "TIMER_UNIT_ACTIVE") to ConfigParseTimerMonotonicOptionValue() as OptionValueInformation, + Validator("config_parse_timer", "TIMER_UNIT_INACTIVE") to ConfigParseTimerMonotonicOptionValue() as OptionValueInformation, + ) return allValidators diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt new file mode 100644 index 00000000..3b9e86b3 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt @@ -0,0 +1,47 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.CALENDAR_SPEC +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.withUnitSpecifiers + +/* + * [Timer] OnCalendar= + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html#OnCalendar= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html#Calendar%20Events + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L2043-L2114 config_parse_timer (TIMER_CALENDAR) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit-printf.c#L179 unit_full_printf_full (specifiers expanded first) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L870-L1103 calendar_spec_from_string_full + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L375 parse_weekdays + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L719 parse_date + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L802 parse_calendar_time + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L619 prepend_component + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L500 parse_component_decimal + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L125 fix_year + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L169 chain_valid + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L212 calendar_spec_valid + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1596 verify_timezone + * tests https://github.com/systemd/systemd/blob/0f0682b773/src/test/test-calendarspec.c#L117 calendar_spec_one (accepted spellings) + * tests https://github.com/systemd/systemd/blob/0f0682b773/src/test/test-calendarspec.c#L231 calendar_spec_from_string (rejected spellings) + */ + +/** + * Validator for `[Timer] OnCalendar=`, the only key using config_parse_timer with TIMER_CALENDAR. + * + * After unit_full_printf() the value goes to calendar_spec_from_string(), which accepts either a + * shorthand keyword (`daily`, `weekly`, `semi-annually`, … — any case) or + * + * [WEEKDAYS] [[YEAR-]MONTH-DAY | [YEAR-]MONTH~DAY | @EPOCH] [HOUR:MINUTE[:SECOND]] + * + * optionally followed by a timezone (` UTC`, the local zone abbreviation or an IANA zone name). Each + * date/time field is `*` or a comma list of `value[..value][/repeat]`; seconds may carry fractions. + * See [CALENDAR_SPEC] for exactly which per-field bounds are checked and which cross-field checks + * (reversed ranges, overshooting repetitions, descending weekday ranges, zone existence) are left to + * systemd. A value that uses a unit specifier is accepted as long as the specifier is a known one. + * + * An empty assignment resets the timer list and is handled before this grammar is consulted. + */ +class ConfigParseTimerCalendarOptionValue : SimpleGrammarOptionValues( + "config_parse_timer", + withUnitSpecifiers(CALENDAR_SPEC) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerMonotonicOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerMonotonicOptionValue.kt new file mode 100644 index 00000000..c848714c --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerMonotonicOptionValue.kt @@ -0,0 +1,34 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.TIMESPAN +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.withUnitSpecifiers + +/* + * [Timer] OnActiveSec= / OnBootSec= / OnStartupSec= / OnUnitActiveSec= / OnUnitInactiveSec= + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html#OnActiveSec= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html#Parsing%20Time%20Spans + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L2043-L2114 config_parse_timer (TIMER_ACTIVE, TIMER_BOOT, TIMER_STARTUP, TIMER_UNIT_ACTIVE, TIMER_UNIT_INACTIVE) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit-printf.c#L179 unit_full_printf_full (specifiers expanded first) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1198-L1287 parse_time (via parse_sec, default unit seconds) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1147 extract_multiplier + * tests https://github.com/systemd/systemd/blob/0f0682b773/src/test/test-time-util.c#L21 TEST(parse_sec) + */ + +/** + * Validator for the five monotonic `[Timer]` settings — `OnActiveSec=`, `OnBootSec=`, + * `OnStartupSec=`, `OnUnitActiveSec=` and `OnUnitInactiveSec=`. They share config_parse_timer, whose + * non-calendar branch hands the specifier-expanded value to parse_sec(). + * + * Accepted: `infinity`, or one or more `number[unit]` elements — `50`, `5min`, `1h30min`, `5s 500ms`, + * `.5y`, `+3.1s`, `3.1s.2` — exactly as described for [TIMESPAN]; any value using a known unit + * specifier. Refused: negative numbers, a dangling `.` (`5.`), unknown or mis-pluralised units + * (`5mins`, `2hs`) and two bare numbers glued together (`12.34.56`). + * + * An empty assignment resets the timer list and is handled before this grammar is consulted. + */ +class ConfigParseTimerMonotonicOptionValue : SimpleGrammarOptionValues( + "config_parse_timer", + withUnitSpecifiers(TIMESPAN) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt new file mode 100644 index 00000000..30b7497d --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt @@ -0,0 +1,388 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar + +import java.math.BigInteger + +/* + * Shared grammars for the unit-core validator batch ([Timer] timers, [Socket] Listen*=, assorted + * [Unit]/[Service] settings and the generic long / iec_size_long / path parsers). + * + * Everything here is modelled on systemd 0f0682b773, the commit recorded in + * systemd-build/build/last_commit_hash: https://github.com/systemd/systemd/blob/0f0682b773 + * + * These were written for one batch and live in their own file so parallel work on Combinators.kt does + * not collide; several of them (the time span, the unit-specifier escape hatch, the safe_ato* number) + * are general enough to be promoted there. + * + * Every grammar here is careful about the classic engine: no repetition wraps something that can match + * the empty string (classic ZeroOrMore/OneOrMore would loop forever), and every alternative that + * could stop on a prefix of another is either ordered after it or terminated with EOF(). + */ + +// --------------------------------------------------------------------------------------------------- +// Unit specifiers — specifier_printf (src/shared/specifier.c) driven by the table in +// unit_full_printf_full (src/core/unit-printf.c), which also pulls in COMMON_SYSTEM_SPECIFIERS, +// COMMON_CREDS_SPECIFIERS and COMMON_TMP_SPECIFIERS (src/shared/specifier.h). +// +// `%%` is a literal percent sign. `%` followed by a letter or digit that is NOT in the table is a hard +// error (-EBADSLT, "Failed to resolve unit specifiers"), so the setting is ignored. `%` followed by +// anything else (or at the very end) is copied through verbatim. + +/** Every specifier letter unit_full_printf() knows about. */ +const val UNIT_SPECIFIER_LETTERS = "aAbBcCdDEfgGhHiIjJlLmMnNopPqrRsStTuUvVwWyY" + +/** One character of specifier-expanded text: anything but a `%`, or a `%` that expands to something. */ +const val UNIT_TEXT_CHAR = """(?:[^%]|%%|%[$UNIT_SPECIFIER_LETTERS]|%(?![A-Za-z0-9%]))""" + +// A run of text that contains at least one real (value-changing) specifier, and no unknown ones. +private const val NON_SPECIFIER_CHAR = """(?:[^%]|%%|%(?![A-Za-z0-9%]))""" + +/** + * A whole value that contains at least one unit specifier (`%i`, `%t`, …) and no unknown ones. + * + * What a specifier expands to is only known when the unit is loaded, so a value that uses one cannot + * be checked against a grammar for the expanded text. This terminal is the escape hatch: it accepts + * such a value as long as every `%X` in it is one systemd knows (an unknown one makes systemd ignore + * the whole assignment, so that is still flagged). + */ +val UNIT_SPECIFIED_VALUE = RegexTerminal( + """[\s\S]*%[A-Za-z0-9][\s\S]*""", + """(?=$NON_SPECIFIER_CHAR*%[$UNIT_SPECIFIER_LETTERS])$UNIT_TEXT_CHAR*""" +) + +/** + * [grammar] (which must itself end in [EOF]), or any value that uses a unit specifier — for settings + * whose C parser runs unit_full_printf()/unit_path_printf() before parsing the expanded text. + */ +fun withUnitSpecifiers(grammar: Combinator): Combinator = + AlternativeCombinator(grammar, SequenceCombinator(UNIT_SPECIFIED_VALUE, EOF())) + + +// --------------------------------------------------------------------------------------------------- +// Time spans — parse_time(…, USEC_PER_SEC) a.k.a. parse_sec (src/basic/time-util.c). +// +// - Leading/trailing whitespace is skipped. `infinity` stands alone (only whitespace may follow). +// - Each element is a number read by strtoll(…, 10) — so an optional `+`, never `-` — with an +// optional `.digits` fraction (a bare `.5` is fine, `5.` is not), then optional whitespace and an +// optional unit from extract_multiplier's table. With no unit the default (seconds) applies. +// - Elements may follow each other directly after a unit (`1h30min`, `3.1s.2`) but a number without +// a unit must be followed by whitespace or the end (`12.34.56`, `5+5` are refused). +// - A unit is matched by prefix in table order, so `5mins` reads `min` and then chokes on `s`. +// +// Overflow (ERANGE past UINT64_MAX µs) is not modelled. + +private val TS_OPTIONAL_WS = ZeroOrOne(WhitespaceTerminal()) + +/** One number of a time span, as strtoll(…, 10) plus parse_time's own `.fraction` handling reads it. */ +val TIMESPAN_NUMBER = RegexTerminal("""\+?[0-9]+(?:\.[0-9]+)?|\.[0-9]+""", """\+?[0-9]+(?:\.[0-9]+)?|\.[0-9]+""") + +/** extract_multiplier's suffix table (case-sensitive: `m` is minutes, `M` is months). */ +val TIMESPAN_UNIT = FlexibleLiteralChoiceTerminal( + "seconds", "second", "sec", "s", + "minutes", "minute", "min", + "months", "month", "M", + "msec", "ms", "m", + "hours", "hour", "hr", "h", + "days", "day", "d", + "weeks", "week", "w", + "years", "year", "y", + "usec", "us", "μs", "µs", +) + +private val TIMESPAN_ELEMENT = AlternativeCombinator( + SequenceCombinator(TIMESPAN_NUMBER, TS_OPTIONAL_WS, TIMESPAN_UNIT, TS_OPTIONAL_WS), + SequenceCombinator(TIMESPAN_NUMBER, AlternativeCombinator(WhitespaceTerminal(), EOF())), +) + +/** A complete parse_sec() time span, `infinity` included, ending at [EOF]. */ +val TIMESPAN = AlternativeCombinator( + SequenceCombinator(TS_OPTIONAL_WS, LiteralChoiceTerminal("infinity"), TS_OPTIONAL_WS, EOF()), + SequenceCombinator(TS_OPTIONAL_WS, OneOrMore(TIMESPAN_ELEMENT), EOF()), +) + + +// --------------------------------------------------------------------------------------------------- +// Calendar events — calendar_spec_from_string (src/shared/calendarspec.c). +// +// [WEEKDAYS] [DATE] [TIME] [TIMEZONE] or KEYWORD [TIMEZONE] +// +// What is checked: the shape the C parser walks (weekday list with `..`/`-` ranges and an optional +// trailing comma, a two- or three-part date with `-` or `~`, a two- or three-part time, `@epoch`), and +// the per-field ranges calendar_spec_valid() enforces after fix_year() (years 0..99 or 1970..2199, +// months 1..12, days 1..31 or 1..28 counted from the end of the month, hours 0..23, minutes and +// seconds 0..59), with repetitions bounded by `to - from` and never zero. +// +// What is deliberately left lenient: cross-field checks (reversed ranges like `12..1`, a repetition +// that overshoots the field like `20/4` hours, descending weekday ranges like `Fri..Mon`), rounding +// of second fractions, and the timezone, which systemd only accepts if it names a file under +// /usr/share/zoneinfo or the host's local zone abbreviation — any word shaped like a zone name is +// accepted here. + +private val CAL_SPACES = RegexTerminal(" +", " +") +private val CAL_STAR = LiteralChoiceTerminal("*") +private val CAL_COMMA = LiteralChoiceTerminal(",") +private val CAL_RANGE = LiteralChoiceTerminal("..") +private val CAL_REPEAT = LiteralChoiceTerminal("/") +private val CAL_TILDE = LiteralChoiceTerminal("~") +/** + * A plain decimal number in `[minInclusive, maxExclusive)`, as calendarspec's parse_component_decimal + * reads one: it insists on a leading digit (so no sign, unlike [IntegerTerminal], which accepts `-0`) + * and then calls strtoul(…, 10). + */ +class DecimalNumberTerminal(private val minInclusive: Long, private val maxExclusive: Long) : TerminalCombinator { + + private fun span(value: String, offset: Int): String? = SHAPE.matchAt(value, offset)?.value + + private fun inRange(text: String): Boolean = + text.toBigInteger().let { it >= BigInteger.valueOf(minInclusive) && it < BigInteger.valueOf(maxExclusive) } + + override fun SyntacticMatch(value: String, offset: Int): MatchResult { + val text = span(value, offset) ?: return NoMatch + return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) + } + + override fun SemanticMatch(value: String, offset: Int): MatchResult { + val text = span(value, offset) ?: return NoMatch + if (!inRange(text)) return NoMatch.copy(longestMatch = offset) + return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) + } + + override fun parse(value: String, offset: Int): Sequence { + val text = span(value, offset) ?: return sequenceOf(Stuck(offset, setOf(this))) + return sequenceOf(Parse(offset + text.length, listOf(ParsedToken(offset, offset + text.length, text, this, inRange(text))))) + } + + override fun toString(): String = "Decimal($minInclusive,$maxExclusive)" + + private companion object { + val SHAPE = Regex("[0-9]+") + } +} + +private val CAL_FRACTION = SequenceCombinator(LiteralChoiceTerminal("."), RegexTerminal("[0-9]+", "[0-9]+")) + +/** One chain element: `value[..value][/repeat]` — prepend_component. */ +private fun calComponent(value: Combinator, repeat: Combinator): Combinator = SequenceCombinator( + value, + ZeroOrOne(SequenceCombinator(CAL_RANGE, value)), + ZeroOrOne(SequenceCombinator(CAL_REPEAT, repeat)), +) + +/** `*`, or a comma-separated list of components — parse_chain. `*` never combines with a list. */ +private fun calChain(value: Combinator, repeat: Combinator): Combinator { + val component = calComponent(value, repeat) + return AlternativeCombinator(CAL_STAR, SequenceCombinator(component, ZeroOrMore(SequenceCombinator(CAL_COMMA, component)))) +} + +// fix_year() maps 0..69 to 2000..2069 and 70..99 to 1970..1999 before the 1970..2199 range check. +private val CAL_YEAR = AlternativeCombinator(DecimalNumberTerminal(1970, 2200), DecimalNumberTerminal(0, 100)) +private val CAL_YEAR_CHAIN = calChain(CAL_YEAR, DecimalNumberTerminal(1, 230)) +private val CAL_MONTH_CHAIN = calChain(DecimalNumberTerminal(1, 13), DecimalNumberTerminal(1, 12)) +private val CAL_DAY_CHAIN = calChain(DecimalNumberTerminal(1, 32), DecimalNumberTerminal(1, 31)) +// With `~` the day counts back from the end of the month and chain_valid() lowers the ceiling to 28. +private val CAL_END_OF_MONTH_DAY_CHAIN = calChain(DecimalNumberTerminal(1, 29), DecimalNumberTerminal(1, 28)) +private val CAL_HOUR_CHAIN = calChain(DecimalNumberTerminal(0, 24), DecimalNumberTerminal(1, 24)) +private val CAL_MINUTE_CHAIN = calChain(DecimalNumberTerminal(0, 60), DecimalNumberTerminal(1, 60)) +// Seconds are parsed with usec = true: a `.fraction` is allowed on values and repetitions alike. +private val CAL_SECOND_VALUE = SequenceCombinator(DecimalNumberTerminal(0, 60), ZeroOrOne(CAL_FRACTION)) +private val CAL_SECOND_REPEAT = AlternativeCombinator( + SequenceCombinator(DecimalNumberTerminal(0, 60), CAL_FRACTION), + DecimalNumberTerminal(1, 60), +) +private val CAL_SECOND_CHAIN = calChain(CAL_SECOND_VALUE, CAL_SECOND_REPEAT) + +private val CAL_DAY_PART = AlternativeCombinator( + SequenceCombinator(HYPHEN, CAL_DAY_CHAIN), + SequenceCombinator(CAL_TILDE, CAL_END_OF_MONTH_DAY_CHAIN), +) + +/** `[YEAR-]MONTH-DAY` or `[YEAR-]MONTH~DAY` — parse_date. A lone component is not a date. */ +private val CAL_DATE = AlternativeCombinator( + SequenceCombinator(CAL_YEAR_CHAIN, HYPHEN, CAL_MONTH_CHAIN, CAL_DAY_PART), + SequenceCombinator(CAL_MONTH_CHAIN, CAL_DAY_PART), +) + +/** `HOUR:MINUTE[:SECOND]` — parse_calendar_time. */ +private val CAL_TIME = SequenceCombinator( + CAL_HOUR_CHAIN, COLON, CAL_MINUTE_CHAIN, + ZeroOrOne(SequenceCombinator(COLON, CAL_SECOND_CHAIN)), +) + +// `@SECONDS` since the epoch; the resulting year must still pass the 1970..2199 check, i.e. the value +// must be below 2200-01-01T00:00:00Z. +private val CAL_EPOCH = SequenceCombinator(LiteralChoiceTerminal("@"), DecimalNumberTerminal(0L, 7_258_118_400L)) + +private val CAL_DATE_AND_TIME = AlternativeCombinator( + CAL_EPOCH, + SequenceCombinator(CAL_DATE, ZeroOrOne(SequenceCombinator(CAL_SPACES, CAL_TIME))), + CAL_TIME, +) + +/** parse_weekdays' table; startswith_no_case, so any casing. */ +val CALENDAR_WEEKDAY = FlexibleLiteralChoiceTerminal( + "Monday", "Mon", "Tuesday", "Tue", "Wednesday", "Wed", "Thursday", "Thu", + "Friday", "Fri", "Saturday", "Sat", "Sunday", "Sun", + ignoreCase = true, +) + +private val CAL_WEEKDAY_ITEM = SequenceCombinator( + CALENDAR_WEEKDAY, + ZeroOrOne(SequenceCombinator(LiteralChoiceTerminal("..", "-"), CALENDAR_WEEKDAY)), +) + +// A trailing comma is allowed ("Wednesday,", "Wed, 17:48"), an open range is not ("Monday.."). +private val CAL_WEEKDAYS = SequenceCombinator( + CAL_WEEKDAY_ITEM, + ZeroOrMore(SequenceCombinator(CAL_COMMA, CAL_WEEKDAY_ITEM)), + ZeroOrOne(CAL_COMMA), +) + +private val CAL_BODY = AlternativeCombinator( + SequenceCombinator(CAL_WEEKDAYS, ZeroOrOne(SequenceCombinator(CAL_SPACES, CAL_DATE_AND_TIME))), + CAL_DATE_AND_TIME, +) + +/** The shorthand keywords, matched with strcaseeq. `anually` is a kept-for-compatibility misspelling. */ +val CALENDAR_KEYWORD = FlexibleLiteralChoiceTerminal( + "minutely", "hourly", "daily", "monthly", "weekly", "yearly", "annually", "anually", "quarterly", + "biannually", "bi-annually", "semiannually", "semi-annually", + ignoreCase = true, +) + +/** + * A timezone suffix: ` UTC` (any case), the host's local zone abbreviation, or an IANA zone name + * (verify_timezone's character set: letters, digits, `-_+/`, no empty components). Whether the zone + * actually exists depends on the machine, so only the shape is checked; a leading letter keeps a date + * or time from being mistaken for one. + */ +private val CAL_TIMEZONE = RegexTerminal( + """[A-Za-z][A-Za-z0-9_+\-]*(?:/[A-Za-z0-9_+\-]+)*""", + """[A-Za-z][A-Za-z0-9_+\-]*(?:/[A-Za-z0-9_+\-]+)*""" +) +private val CAL_OPTIONAL_TIMEZONE = ZeroOrOne(SequenceCombinator(CAL_SPACES, CAL_TIMEZONE)) + +/** A complete calendar event expression, ending at [EOF]. */ +val CALENDAR_SPEC = AlternativeCombinator( + SequenceCombinator(CALENDAR_KEYWORD, CAL_OPTIONAL_TIMEZONE, EOF()), + SequenceCombinator(CAL_BODY, CAL_OPTIONAL_TIMEZONE, EOF()), +) + + +// --------------------------------------------------------------------------------------------------- +// Numbers as the safe_ato* family reads them (src/basic/parse-util.c): leading whitespace is skipped, +// mangle_base() turns a `0b`/`0o` prefix into base 2/8, and otherwise strtoul()/strtoll() run with base +// 0, so `0x` is hexadecimal and a leading `0` is octal. The unsigned helpers accept a leading `+` and +// refuse `-` (even `-0`); the signed ones accept both signs. `0b`/`0o` are only recognised unsigned. + +/** + * An integer spelled any way the safe_atou and safe_atoi helpers accept, range-checked on the value it denotes. + * + * Unlike [UnsignedNumberTerminal] this also accepts `0b…`, `0o…` and a leading `+`, and with + * [signed] a leading `-`. + */ +class SafeAtoNumberTerminal( + private val minInclusive: BigInteger, + private val maxInclusive: BigInteger, + private val signed: Boolean, +) : TerminalCombinator { + + constructor(minInclusive: Long, maxInclusive: Long, signed: Boolean) : + this(BigInteger.valueOf(minInclusive), BigInteger.valueOf(maxInclusive), signed) + + private fun span(value: String, offset: Int): String? = SHAPE.matchAt(value, offset)?.value + + private fun valueOf(text: String): BigInteger? { + var s = text + if (s.length > 2 && (s.startsWith("0b") || s.startsWith("0B"))) return s.drop(2).toBigIntegerOrNull(2) + if (s.length > 2 && (s.startsWith("0o") || s.startsWith("0O"))) return s.drop(2).toBigIntegerOrNull(8) + var negative = false + if (s.startsWith("+") || s.startsWith("-")) { + negative = s[0] == '-' + s = s.drop(1) + } + val magnitude = when { + s.length > 2 && (s.startsWith("0x") || s.startsWith("0X")) -> s.drop(2).toBigIntegerOrNull(16) + s.length > 1 && s.startsWith("0") -> s.drop(1).toBigIntegerOrNull(8) + else -> s.toBigIntegerOrNull(10) + } ?: return null + if (negative && !signed) return null // safe_atou* refuse any '-', even "-0" + return if (negative) magnitude.negate() else magnitude + } + + private fun inRange(text: String): Boolean = valueOf(text)?.let { it >= minInclusive && it <= maxInclusive } ?: false + + override fun SyntacticMatch(value: String, offset: Int): MatchResult { + val text = span(value, offset) ?: return NoMatch + return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) + } + + override fun SemanticMatch(value: String, offset: Int): MatchResult { + val text = span(value, offset) ?: return NoMatch + if (!inRange(text)) return NoMatch.copy(longestMatch = offset) + return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) + } + + override fun parse(value: String, offset: Int): Sequence { + val text = span(value, offset) ?: return sequenceOf(Stuck(offset, setOf(this))) + return sequenceOf(Parse(offset + text.length, listOf(ParsedToken(offset, offset + text.length, text, this, inRange(text))))) + } + + override fun toString(): String = "SafeAto($minInclusive,$maxInclusive${if (signed) ",signed" else ""})" + + private companion object { + // Prefixed forms first so "0x10" isn't read as "0". Over-long digit runs (e.g. "08", an invalid + // octal) are still matched whole so the bad token can be highlighted. + val SHAPE = Regex("""0[bB][0-9]+|0[oO][0-9]+|[+-]?(?:0[xX][0-9a-fA-F]+|[0-9]+)""") + } +} + +/** An unsigned number in `[minInclusive, maxInclusive]` as safe_atou*() reads it. */ +fun safeAtou(minInclusive: Long, maxInclusive: Long): Combinator = SafeAtoNumberTerminal(minInclusive, maxInclusive, signed = false) + + +// --------------------------------------------------------------------------------------------------- +// Byte sizes — parse_size(…, 1024) (src/basic/parse-util.c). +// +// A run of `number[.fraction] [suffix]` elements, whitespace allowed before each element and between a +// number and its suffix. The number is read by strtoull(…, 10), so a leading `+` is fine and a `-` is +// ERANGE; the fraction may be empty (`10.M` is accepted). Suffixes are B, K, M, G, T, P, E (upper case +// only) or none, and an element without a suffix can only come last. systemd also insists suffixes +// appear in strictly descending order (`1G 512M`, not `512M 1G`) and that the sum fits — neither is +// modelled. + +private val SIZE_NUMBER = RegexTerminal("""\+?[0-9]+(?:\.[0-9]*)?""", """\+?[0-9]+(?:\.[0-9]*)?""") +private val SIZE_SUFFIX = LiteralChoiceTerminal("B", "K", "M", "G", "T", "P", "E") + +/** A complete parse_size(…, 1024) value, ending at [EOF]. */ +val IEC_SIZE = SequenceCombinator( + TS_OPTIONAL_WS, + ZeroOrMore(SequenceCombinator(SIZE_NUMBER, TS_OPTIONAL_WS, SIZE_SUFFIX, TS_OPTIONAL_WS)), + ZeroOrOne(SequenceCombinator(SIZE_NUMBER, TS_OPTIONAL_WS)), + EOF(), +) + + +// --------------------------------------------------------------------------------------------------- +// Paths as path_simplify_and_warn() (src/shared/parse-helpers.c) checks them after specifier expansion: +// PATH_CHECK_ABSOLUTE, then path_simplify() (which drops `.` components and duplicate slashes), then +// path_is_normalized() (which refuses any `..` component), and with PATH_CHECK_NON_API_VFS also +// path_below_api_vfs() (refuses /dev, /sys, /proc and anything below them). + +// Rejects a `..` component anywhere: the optional `(?:[\s\S]*/)` swallows any leading directories. +private const val NO_DOT_DOT_COMPONENT = """(?!(?:[\s\S]*/)?\.\.(?:/|$))""" + +// path_startswith() is component-aware: /dev and /dev/x are API VFS, /devices is not. +private const val NOT_API_VFS = """(?!/+(?:dev|sys|proc)(?:/|$))""" + +private const val SPECIFIER_OR_SLASH = """(?:/|%[$UNIT_SPECIFIER_LETTERS])""" + +/** An absolute, normalized path after unit_path_printf(); may begin with a specifier such as `%t`. */ +val UNIT_ABSOLUTE_PATH = RegexTerminal("""[\s\S]+""", """$NO_DOT_DOT_COMPONENT$SPECIFIER_OR_SLASH$UNIT_TEXT_CHAR*""") + +/** As [UNIT_ABSOLUTE_PATH], but also not at or below /dev, /sys or /proc (PATH_CHECK_NON_API_VFS). */ +val UNIT_ABSOLUTE_NON_API_VFS_PATH = RegexTerminal( + """[\s\S]+""", + """$NO_DOT_DOT_COMPONENT$NOT_API_VFS$SPECIFIER_OR_SLASH$UNIT_TEXT_CHAR*""" +) + +/** An absolute, normalized path taken literally (no specifier expansion) — config_parse_path. */ +val LITERAL_ABSOLUTE_PATH = RegexTerminal("""[\s\S]+""", """$NO_DOT_DOT_COMPONENT/[\s\S]*""") diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerCalendarOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerCalendarOptionValueTest.kt new file mode 100644 index 00000000..99f8ca12 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerCalendarOptionValueTest.kt @@ -0,0 +1,166 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +class ConfigParseTimerCalendarOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValuesFromSystemdTests() { + // Every input of TEST(calendar_spec_one) in src/test/test-calendarspec.c, plus the man page examples. + // language="unit file (systemd)" + val file = """ + [Timer] + OnCalendar=Sat,Thu,Mon-Wed,Sat-Sun + OnCalendar=Sat,Thu,Mon..Wed,Sat..Sun + OnCalendar=Mon,Sun 12-*-* 2,1:23 + OnCalendar=Wed *-1 + OnCalendar=Wed-Wed,Wed *-1 + OnCalendar=Wed..Wed,Wed *-1 + OnCalendar=Wed, 17:48 + OnCalendar=Wednesday, + OnCalendar=Wed-Sat,Tue 12-10-15 1:2:3 + OnCalendar=Wed..Sat,Tue 12-10-15 1:2:3 + OnCalendar=*-*-7 0:0:0 + OnCalendar=10-15 + OnCalendar=monday *-12-* 17:00 + OnCalendar=Mon,Fri *-*-3,1,2 *:30:45 + OnCalendar=12,14,13,12:20,10,30 + OnCalendar=mon,fri *-1/2-1,3 *:30:45 + OnCalendar=03-05 08:05:40 + OnCalendar=08:05:40 + OnCalendar=05:40 + OnCalendar=Sat,Sun 12-05 08:05:40 + OnCalendar=Sat,Sun 08:05:40 + OnCalendar=2003-03-05 05:40 + OnCalendar=2003-03-05 + OnCalendar=03-05 + OnCalendar=hourly + OnCalendar=daily + OnCalendar=monthly + OnCalendar=weekly + OnCalendar=minutely + OnCalendar=quarterly + OnCalendar=semi-annually + OnCalendar=annually + OnCalendar=*:2/3 + OnCalendar=2015-10-25 01:00:00 uTc + OnCalendar=2015-10-25 01:00:00 Asia/Vladivostok + OnCalendar=weekly Pacific/Auckland + OnCalendar=2016-03-27 03:17:00.4200005 + OnCalendar=2016-03-27 03:17:00/0.42 + OnCalendar=9..11,13:00,30 + OnCalendar=1..3-1..3 1..3:1..3 + OnCalendar=00:00:1.125..2.125 + OnCalendar=00:00:1.0..3.8 + OnCalendar=00:00:01..03 + OnCalendar=00:00:01/2,02..03 + OnCalendar=*:4,30:0..3 + OnCalendar=*:4,30:0/1 + OnCalendar=*:4,30:0/1,3,5 + OnCalendar=*-*~1 Utc + OnCalendar=*-*~* 00:00:00 + OnCalendar=Monday + OnCalendar=Monday *-*-* + OnCalendar=*-*-* + OnCalendar=*:*:* + OnCalendar=*:* + OnCalendar=12:* + OnCalendar=*:30 + OnCalendar=93..00-*-* + OnCalendar=00..07-*-* + OnCalendar=*:20..39/5 + OnCalendar=00:00:20..40/1 + OnCalendar=*~03/1,03..05 + OnCalendar=@1493187147 + OnCalendar=@1493187147 UTC + OnCalendar=@0 + OnCalendar=*:05..05 + OnCalendar=*:05..10/6 + OnCalendar=Thu,Fri 2012-*-1,5 11:12:13 + OnCalendar=12..14:10,20,30 + OnCalendar=05:40:23.4200004/3.1700005 + OnCalendar=2003-02..04-05 + OnCalendar=daily UTC + OnCalendar=*-02~03 + OnCalendar=Mon *-05~07/1 + OnCalendar=Mon 2017-05~01..07 UTC + OnCalendar=Sun *-*-* 01:00:00 Europe/Dublin + """.trimIndent() + + setupFileInEditor("file.timer", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testValidValuesFromCorpus() { + // language="unit file (systemd)" + val file = """ + [Timer] + OnCalendar=Daily + OnCalendar=*-*-* 6:00 + OnCalendar=*:0/5 + OnCalendar=*-*-* 00,12:00:00 + OnCalendar=Sun *-*-1..7 0:45:00 + OnCalendar=Mon..Fri *-*-* 06:30:00 + OnCalendar=Mon,Tue,Wed,Thu,Fri,Sat + OnCalendar=Mon *-*-* 6:15:0 + OnCalendar=*-*-* 16,17,18,19,20,21,22,23,00,01,02,03,04,05,06:00:00 + OnCalendar=*-*-* *:00/10:00 + OnCalendar=Fri 2012-11-23 11:12:13 + OnCalendar=daily utc + OnCalendar=*-*-* %i:00 + """.trimIndent() + + setupFileInEditor("file.timer", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Rejections from TEST(calendar_spec_from_string) whose cause is a shape or a single-field bound, + // plus a few typos seen in the wild. + // language="unit file (systemd)" + val file = """ + [Timer] + OnCalendar=test + OnCalendar=7 + OnCalendar=121212:1:2 + OnCalendar=2000-03-05.23 00:00:00 + OnCalendar=2000-03-05 00:00.1:00 + OnCalendar=2016~11-22 + OnCalendar=Monday.. 12:00 + OnCalendar=Monday.. + OnCalendar=-00:+00/-5 + OnCalendar=00:+00/-5 + OnCalendar=2016- 11- 24 12: 30: 00 + OnCalendar=*~29 + OnCalendar=*~16..31 + OnCalendar=00:00/60 + OnCalendar=00:00:2300 + OnCalendar=00:00:18446744073709551615 + OnCalendar=@88588582097858858 + OnCalendar=*:4,30:*,5 + OnCalendar=*:4,30:5,* + OnCalendar=Mo *-12-* 17:00 + OnCalendar=dialy + OnCalendar=*-*-* 24:00 + OnCalendar=*-13-01 + OnCalendar=1969-01-01 + OnCalendar=%e + """.trimIndent() + + setupFileInEditor("file.timer", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(25, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerMonotonicOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerMonotonicOptionValueTest.kt new file mode 100644 index 00000000..2e4c7d52 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerMonotonicOptionValueTest.kt @@ -0,0 +1,103 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +class ConfigParseTimerMonotonicOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // Spellings from systemd's TEST(parse_sec) (src/test/test-time-util.c) and the Debian corpus, + // spread across all five keys that share config_parse_timer's parse_sec branch. + // language="unit file (systemd)" + val file = """ + [Timer] + OnActiveSec=5s + OnActiveSec=5s500ms + OnActiveSec= 5s 500ms + OnActiveSec=5.5s 0.5ms + OnBootSec=.22s + OnBootSec=.50y + OnBootSec=2.5 + OnBootSec=.7 + OnBootSec=23us + OnBootSec=23μs + OnBootSec=23µs + OnStartupSec=infinity + OnStartupSec=+3.1s + OnStartupSec=3.1s.2 + OnStartupSec=3.1 .2 + OnStartupSec=3.1 sec .2 sec + OnUnitActiveSec=1h30min + OnUnitActiveSec=5min 15s 100ms + OnUnitActiveSec=300ms20s 5day + OnUnitActiveSec=2 minutes + OnUnitActiveSec=1 min + OnUnitActiveSec=12 M + OnUnitActiveSec=1y2M3w4d5h6m7s + OnUnitInactiveSec=23434 + OnUnitInactiveSec=0 + OnUnitInactiveSec=15m + OnUnitInactiveSec=1 seconds + OnUnitInactiveSec=7 month + OnUnitInactiveSec=7hr 2hr + OnUnitInactiveSec=%i + OnUnitInactiveSec=%im + """.trimIndent() + + setupFileInEditor("file.timer", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testEmptyValueResets() { + // language="unit file (systemd)" + val file = """ + [Timer] + OnBootSec= + OnUnitActiveSec= + """.trimIndent() + + setupFileInEditor("file.timer", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Rejections from TEST(parse_sec), plus mis-pluralised units and an unknown specifier. + // language="unit file (systemd)" + val file = """ + [Timer] + OnActiveSec=xyz + OnActiveSec=5. + OnActiveSec=.s + OnBootSec=-5s + OnBootSec=0.-0s + OnBootSec=infinity .7 + OnStartupSec=.3 infinity + OnStartupSec=3.+1s + OnStartupSec=3. 1s + OnUnitActiveSec=3.s + OnUnitActiveSec=12.34.56 + OnUnitActiveSec=12..34 + OnUnitActiveSec=5mins + OnUnitInactiveSec=2hs + OnUnitInactiveSec=12foo 1 bar + OnUnitInactiveSec=min 1 sec + OnUnitInactiveSec=%e + """.trimIndent() + + setupFileInEditor("file.timer", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(17, highlights) + } +} From f1c4b64eecb360c3b09fed0b75979dea1e410c6e Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Fri, 23 Oct 2026 18:25:02 -0700 Subject: [PATCH 2/8] feat: add grammar validators for [Socket] Listen*= (unit-core batch) SOCKET_SOCKET is shared by ListenStream/Datagram/SequentialPacket/Netlink, which config_parse_socket_listen tells apart by key name, so that validator dispatches on the key. The file ltypes follow path_simplify_and_warn, with ListenSpecial= exempt from the API VFS check. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../semanticdata/optionvalues/AiGenerated.kt | 7 + .../ai/ConfigParseSocketListenOptionValues.kt | 188 ++++++++++++++++++ ...figParseSocketListenFileOptionValueTest.kt | 55 +++++ ...gParseSocketListenSocketOptionValueTest.kt | 174 ++++++++++++++++ ...ParseSocketListenSpecialOptionValueTest.kt | 45 +++++ 5 files changed, 469 insertions(+) create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenFileOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSpecialOptionValueTest.kt diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt index fa66e5a3..f10549f6 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt @@ -421,6 +421,13 @@ fun getAllAIGeneratedValidators(): Map { Validator("config_parse_timer", "TIMER_STARTUP") to ConfigParseTimerMonotonicOptionValue() as OptionValueInformation, Validator("config_parse_timer", "TIMER_UNIT_ACTIVE") to ConfigParseTimerMonotonicOptionValue() as OptionValueInformation, Validator("config_parse_timer", "TIMER_UNIT_INACTIVE") to ConfigParseTimerMonotonicOptionValue() as OptionValueInformation, + // config_parse_socket_listen: SOCKET_SOCKET covers four keys with different value types, so its + // validator dispatches on the key name, exactly as the C function does. + Validator("config_parse_socket_listen", "SOCKET_SOCKET") to ConfigParseSocketListenSocketOptionValue() as OptionValueInformation, + Validator("config_parse_socket_listen", "SOCKET_FIFO") to ConfigParseSocketListenFileOptionValue() as OptionValueInformation, + Validator("config_parse_socket_listen", "SOCKET_MQUEUE") to ConfigParseSocketListenFileOptionValue() as OptionValueInformation, + Validator("config_parse_socket_listen", "SOCKET_USB_FUNCTION") to ConfigParseSocketListenFileOptionValue() as OptionValueInformation, + Validator("config_parse_socket_listen", "SOCKET_SPECIAL") to ConfigParseSocketListenSpecialOptionValue() as OptionValueInformation, ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt new file mode 100644 index 00000000..0bb5f882 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt @@ -0,0 +1,188 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import com.intellij.codeInspection.ProblemsHolder +import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFilePropertyType +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ALTERNATIVE_INTERFACE_NAME +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.AlternativeCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.Combinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.FlexibleLiteralChoiceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.IPV4_ADDR +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.IPV6_ADDR +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.RegexTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.UNIT_ABSOLUTE_NON_API_VFS_PATH +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.UNIT_ABSOLUTE_PATH +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.UNIT_SPECIFIER_LETTERS +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.UNIT_TEXT_CHAR +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.WhitespaceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.safeAtou +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.withUnitSpecifiers + +/* + * [Socket] ListenStream= / ListenDatagram= / ListenSequentialPacket= / ListenNetlink= (SOCKET_SOCKET), + * ListenFIFO= (SOCKET_FIFO), ListenMessageQueue= (SOCKET_MQUEUE), ListenUSBFunction= (SOCKET_USB_FUNCTION) + * and ListenSpecial= (SOCKET_SPECIAL). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html#ListenStream= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html#ListenFIFO= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html#ListenNetlink= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L603-L722 config_parse_socket_listen (SOCKET_SOCKET, SOCKET_FIFO, SOCKET_SPECIAL, SOCKET_MQUEUE, SOCKET_USB_FUNCTION) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit-printf.c#L253 unit_path_printf (specifiers expanded first, every ltype) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L33-L105 specifier_printf (unknown %X is an error, %% is a literal %) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L34 path_simplify_and_warn (file ltypes: absolute, no "..", not below /dev /sys /proc unless SPECIAL) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/mountpoint-util.c#L721 path_below_api_vfs + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/socket-netlink.c#L24-L96 socket_address_parse + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L1668 socket_address_parse_unix + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L1352 sockaddr_un_set_path (2..107 bytes) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L1754 socket_address_parse_vsock + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L1708 vsock_parse_port + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L1731 vsock_parse_cid + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L718 parse_ip_port (1..65535, safe_atou16 so hex/octal are fine) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/socket-netlink.c#L220 in_addr_port_ifindex_name_from_string_auto + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L785 ifname_valid_full (IFNAME_VALID_ALTERNATIVE | IFNAME_VALID_NUMERIC) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/socket-netlink.c#L119-L152 socket_address_parse_netlink + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L656 netlink_family_table (WITH_FALLBACK: a number up to INT_MAX also works) + * tests https://github.com/systemd/systemd/blob/0f0682b773/src/test/test-socket-netlink.c#L40 TEST(socket_address_parse) + * tests https://github.com/systemd/systemd/blob/0f0682b773/src/test/test-socket-netlink.c#L105 TEST(socket_address_parse_netlink) + * linux https://man7.org/linux/man-pages/man7/unix.7.html + * linux https://man7.org/linux/man-pages/man7/netlink.7.html + * linux https://man7.org/linux/man-pages/man7/vsock.7.html + * linux https://man7.org/linux/man-pages/man7/mq_overview.7.html + */ + +// --------------------------------------------------------------------------------------------------- +// Socket addresses (socket_address_parse), applied to the specifier-expanded text. + +private val PERCENT_START_SPECIFIER = """%[$UNIT_SPECIFIER_LETTERS]""" + +// AF_UNIX: '/' (file system) or '@' (abstract namespace), then anything. sockaddr_un_set_path insists on +// 2..107 bytes including the leading character. The length is only checked when no specifier is present, +// because a specifier's expansion length is unknown; a specifier may also stand at the start (%t/foo). +private val UNIX_SOCKET_ADDRESS = RegexTerminal( + """[/@%][\s\S]*""", + """(?:(?=[^%]*$)[/@][\s\S]{1,106}|(?=[\s\S]*%)(?:[/@]|$PERCENT_START_SPECIFIER)$UNIT_TEXT_CHAR*)""" +) + +private val IP_PORT = safeAtou(1, 65535) + +// AF_VSOCK: vsock[-stream|-dgram|-seqpacket]:CID:PORT; the CID may be empty (VMADDR_CID_ANY), one of the +// named CIDs, "-1", or a number; the port is any unsigned except VMADDR_PORT_ANY (UINT32_MAX). +private val VSOCK_CID = AlternativeCombinator( + FlexibleLiteralChoiceTerminal("hypervisor", "local", "host", "any"), + LiteralChoiceTerminal("-1"), + safeAtou(0, 4_294_967_295), +) +private val VSOCK_ADDRESS = SequenceCombinator( + LiteralChoiceTerminal("vsock-seqpacket:", "vsock-stream:", "vsock-dgram:", "vsock:"), + ZeroOrOne(VSOCK_CID), + COLON, + safeAtou(0, 4_294_967_294), +) + +// An interface scope after the port. The `%` that introduces it has to be written `%%` in a unit file: +// unit_path_printf() runs first, and `%eth0` is an unknown specifier (an error), `%lo` would expand %l. +private val INTERFACE_SCOPE = SequenceCombinator( + LiteralChoiceTerminal("%%"), + AlternativeCombinator(ALTERNATIVE_INTERFACE_NAME, RegexTerminal("[1-9][0-9]*", "[1-9][0-9]*")), +) + +private val IPV6_SOCKET_ADDRESS = SequenceCombinator( + LiteralChoiceTerminal("["), IPV6_ADDR, LiteralChoiceTerminal("]"), COLON, IP_PORT, ZeroOrOne(INTERFACE_SCOPE), +) +private val IPV4_SOCKET_ADDRESS = SequenceCombinator(IPV4_ADDR, COLON, IP_PORT, ZeroOrOne(INTERFACE_SCOPE)) + +/** Anything socket_address_parse() accepts — the grammar for ListenStream= and ListenDatagram=. */ +private val SOCKET_ADDRESS = withUnitSpecifiers( + AlternativeCombinator( + SequenceCombinator(UNIX_SOCKET_ADDRESS, EOF()), + SequenceCombinator(VSOCK_ADDRESS, EOF()), + SequenceCombinator(IPV6_SOCKET_ADDRESS, EOF()), + SequenceCombinator(IPV4_SOCKET_ADDRESS, EOF()), + SequenceCombinator(IP_PORT, EOF()), + ) +) + +/** ListenSequentialPacket=: config_parse_socket_listen refuses every family but AF_UNIX for SOCK_SEQPACKET. */ +private val SEQPACKET_ADDRESS = withUnitSpecifiers(SequenceCombinator(UNIX_SOCKET_ADDRESS, EOF())) + +/** ListenNetlink=: `FAMILY [GROUP]`, the family a name from netlink_family_table or a number. */ +private val NETLINK_ADDRESS = withUnitSpecifiers( + SequenceCombinator( + AlternativeCombinator( + FlexibleLiteralChoiceTerminal( + "route", "firewall", "inet-diag", "nflog", "xfrm", "selinux", "iscsi", "audit", "fib-lookup", + "connector", "netfilter", "ip6-fw", "dnrtmsg", "kobject-uevent", "generic", "scsitransport", + "ecryptfs", "rdma", + ), + safeAtou(0, Int.MAX_VALUE.toLong()), + ), + ZeroOrOne(SequenceCombinator(WhitespaceTerminal(), safeAtou(0, 4_294_967_295))), + EOF(), + ) +) + +/** + * Validator for the four `[Socket]` keys config_parse_socket_listen handles with ltype SOCKET_SOCKET. + * + * The ltype is shared but the value type is not — config_parse_socket_listen branches on the key name + * itself — so this dispatches on [UnitFilePropertyType.key]: + * + * - `ListenStream=` / `ListenDatagram=`: any socket_address_parse() address — an absolute path or + * `@abstract` name (2..107 bytes), `vsock:CID:PORT` (also `vsock-stream:` etc.), a bare port + * (1..65535; safe_atou16, so `0x50` is port 80), `IPv4:PORT` or `[IPv6]:PORT`, the latter two + * optionally followed by an interface scope. Because unit_path_printf() runs first, that scope's + * `%` must be written `%%` (`[fe80::1]:80%%eth0`). + * - `ListenSequentialPacket=`: AF_UNIX only. + * - `ListenNetlink=`: a netlink family name or number, optionally followed by a multicast group. + * + * Any value that uses a known unit specifier is accepted. [combinator] (used for colouring, completion + * and anything key-agnostic) is the union of the per-key grammars. + */ +class ConfigParseSocketListenSocketOptionValue : SimpleGrammarOptionValues( + "config_parse_socket_listen", + AlternativeCombinator(SOCKET_ADDRESS, NETLINK_ADDRESS) +) { + private val byKey: Map = mapOf( + "ListenStream" to SimpleGrammarOptionValues("config_parse_socket_listen", SOCKET_ADDRESS), + "ListenDatagram" to SimpleGrammarOptionValues("config_parse_socket_listen", SOCKET_ADDRESS), + "ListenSequentialPacket" to SimpleGrammarOptionValues("config_parse_socket_listen", SEQPACKET_ADDRESS), + "ListenNetlink" to SimpleGrammarOptionValues("config_parse_socket_listen", NETLINK_ADDRESS), + ) + + /** The grammar config_parse_socket_listen applies to [key]; the union for an unexpected key. */ + fun combinatorForKey(key: String): Combinator = byKey[key]?.combinator ?: combinator + + override fun generateProblemDescriptors(property: UnitFilePropertyType, holder: ProblemsHolder) { + val delegate = byKey[property.key] + if (delegate != null) delegate.generateProblemDescriptors(property, holder) else super.generateProblemDescriptors(property, holder) + } +} + +/** + * Validator for `[Socket] ListenFIFO=`, `ListenMessageQueue=` and `ListenUSBFunction=` (ltypes + * SOCKET_FIFO, SOCKET_MQUEUE, SOCKET_USB_FUNCTION). + * + * After unit_path_printf() the value goes through path_simplify_and_warn(PATH_CHECK_ABSOLUTE | + * PATH_CHECK_NON_API_VFS): it must be absolute (or start with a specifier such as `%t`), contain no + * `..` component, and not be /dev, /sys, /proc or below. A message queue name is a single-component + * path like `/queue`; systemd does not enforce that at parse time, so neither does this. + */ +class ConfigParseSocketListenFileOptionValue : SimpleGrammarOptionValues( + "config_parse_socket_listen", + SequenceCombinator(UNIT_ABSOLUTE_NON_API_VFS_PATH, EOF()) +) + +/** + * Validator for `[Socket] ListenSpecial=` (ltype SOCKET_SPECIAL): as + * [ConfigParseSocketListenFileOptionValue] but without PATH_CHECK_NON_API_VFS, because special files + * live precisely in /dev, /proc and /sys (`ListenSpecial=/dev/rfkill`). + */ +class ConfigParseSocketListenSpecialOptionValue : SimpleGrammarOptionValues( + "config_parse_socket_listen", + SequenceCombinator(UNIT_ABSOLUTE_PATH, EOF()) +) diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenFileOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenFileOptionValueTest.kt new file mode 100644 index 00000000..a46d4fca --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenFileOptionValueTest.kt @@ -0,0 +1,55 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +class ConfigParseSocketListenFileOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // language="unit file (systemd)" + val file = """ + [Socket] + ListenFIFO=/run/acpi_fakekey + ListenFIFO=/run/cloud-init/share/hook-hotplug-cmd + ListenFIFO=%t/wob.sock + ListenFIFO=/var/run/legacy.fifo + ListenFIFO=/run//double/./slash + ListenFIFO=/devices/not-api-vfs + ListenMessageQueue=/queue + ListenMessageQueue=/%i + ListenUSBFunction=/run/usb-ffs/adb + ListenUSBFunction=/some/path + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Relative paths, ".." components, API VFS paths (PATH_CHECK_NON_API_VFS) and an unknown specifier. + // language="unit file (systemd)" + val file = """ + [Socket] + ListenFIFO=relative/path + ListenFIFO=/run/../etc/fifo + ListenFIFO=/dev/foo + ListenFIFO=/proc + ListenMessageQueue=queue + ListenMessageQueue=/sys/fs/mqueue/q + ListenUSBFunction=/dev/usb-ffs/adb + ListenUSBFunction=%e/ffs + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(8, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt new file mode 100644 index 00000000..4d6ad7a5 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt @@ -0,0 +1,174 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +class ConfigParseSocketListenSocketOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidStreamAndDatagramAddresses() { + // From TEST(socket_address_parse) (src/test/test-socket-netlink.c) and the Debian corpus. + // language="unit file (systemd)" + val file = """ + [Socket] + ListenStream=65535 + ListenStream=8888 + ListenStream=0x50 + ListenStream=[2001:0db8:0000:85a3:0000:0000:ac1f:8001]:8888 + ListenStream=[::1]:8888 + ListenStream=[::]:111 + ListenStream=[::1]:1234%%lo + ListenStream=[fe80::1]:80%%eth0 + ListenStream=[fe80::1]:80%%2 + ListenStream=192.168.1.254:8888 + ListenStream=0.0.0.0:80 + ListenStream=127.0.0.1:1234%%lo + ListenStream=/foo/bar + ListenStream=/run/foo bar.sock + ListenStream=@abstract + ListenStream=@/org/freedesktop/systemd1/logger + ListenStream=%t/pulse/native + ListenStream=%t/foot-%i.sock + ListenStream=%C/sigstore/gitsign/cache.sock + ListenStream=%i + ListenStream=vsock:2:1234 + ListenStream=vsock::1234 + ListenStream=vsock:host:22 + ListenStream=vsock:-1:22 + ListenStream=vsock-stream:any:22 + ListenDatagram=69 + ListenDatagram=[::]:53 + ListenDatagram=127.0.0.1:1153 + ListenDatagram=@fcm_clif + ListenDatagram=/path/to/socket + ListenDatagram=vsock-dgram:3:4000 + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidStreamAndDatagramAddresses() { + // language="unit file (systemd)" + val file = """ + [Socket] + ListenStream=junk + ListenStream=192.168.1.1 + ListenStream=.168.1.1 + ListenStream=989.168.1.1:80 + ListenStream=192.168.1.1:65536 + ListenStream=192.168.1.1:0 + ListenStream=0 + ListenStream=65536 + ListenStream=[::1] + ListenStream=[::1]8888 + ListenStream=::1 + ListenStream=[::1]:0 + ListenStream=[a:b:1]:8888 + ListenStream=[::1]%%lo:1234 + ListenStream=[::1]:1234%%lo%%lo + ListenStream=/ + ListenStream=@ + ListenStream=vsock:2:1234x + ListenStream=vsock:2x:1234 + ListenStream=vsock:2 + ListenStream=[fe80::1]:80%eth0 + ListenDatagram=80/udp + ListenDatagram=localhost:53 + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(23, highlights) + } + + @Test + fun testUnixSocketPathLength() { + // sockaddr_un_set_path: 107 bytes is the longest path that fits with its NUL terminator. + val ok = "/" + "a".repeat(106) + val tooLong = "/" + "a".repeat(107) + val file = """ + [Socket] + ListenStream=$ok + ListenStream=@${"a".repeat(106)} + ListenStream=$tooLong + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(1, highlights) + } + + @Test + fun testSequentialPacketIsUnixOnly() { + // language="unit file (systemd)" + val file = """ + [Socket] + ListenSequentialPacket=/run/foo.sock + ListenSequentialPacket=@namespace + ListenSequentialPacket=%t/foo.sock + ListenSequentialPacket=1.2.3.4:1234 + ListenSequentialPacket=1234 + ListenSequentialPacket=vsock-seqpacket:2:1234 + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(3, highlights) + } + + @Test + fun testNetlink() { + // From TEST(socket_address_parse_netlink) and the corpus. + // language="unit file (systemd)" + val file = """ + [Socket] + ListenNetlink=route + ListenNetlink=route 10 + ListenNetlink=kobject-uevent 1 + ListenNetlink=kobject-uevent 10 + ListenNetlink=audit 1 + ListenNetlink=route 1361 + ListenNetlink=rdma 4 + ListenNetlink=16 + ListenNetlink=generic 0x10 + ListenNetlink=junk + ListenNetlink=route ten + ListenNetlink=route 1 2 + ListenNetlink=/run/foo.sock + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(4, highlights) + } + + @Test + fun testEmptyValueResets() { + // language="unit file (systemd)" + val file = """ + [Socket] + ListenStream= + ListenNetlink= + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSpecialOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSpecialOptionValueTest.kt new file mode 100644 index 00000000..3237a435 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSpecialOptionValueTest.kt @@ -0,0 +1,45 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +class ConfigParseSocketListenSpecialOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // ListenSpecial= is the one file ltype without PATH_CHECK_NON_API_VFS (systemd 8bd473ff25). + // language="unit file (systemd)" + val file = """ + [Socket] + ListenSpecial=/dev/rfkill + ListenSpecial=/proc/kmsg + ListenSpecial=/sys/kernel/debug/foo + ListenSpecial=/some/path + ListenSpecial=%t/special + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // language="unit file (systemd)" + val file = """ + [Socket] + ListenSpecial=dev/rfkill + ListenSpecial=/dev/../rfkill + ListenSpecial=%e + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(3, highlights) + } +} From f568e287b4ca19615d86c6f8094472e641eb7844 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Fri, 23 Oct 2026 18:30:43 -0700 Subject: [PATCH 3/8] feat: add grammar validators for service, job and generic parsers (unit-core batch) Covers PIDFile=, BusName=, OpenFile=, RefreshOnReload=, JobTimeoutSec=, the obsolete Requires/RequisiteOverridable= (forwarded to the unit dependency validator, as the C parser does), and the generic config_parse_path, config_parse_long and config_parse_iec_size_long. Also namespaces the batch's shared grammar in BatchUnitCoreGrammar and anchors semantic regexes to the end of their token, which the classic engine needs to localise errors without overrunning the value. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../semanticdata/optionvalues/AiGenerated.kt | 11 + .../ai/ConfigParseBusNameOptionValue.kt | 48 ++ .../ai/ConfigParseIecSizeLongOptionValue.kt | 28 + .../ai/ConfigParseJobTimeoutSecOptionValue.kt | 30 + .../ai/ConfigParseLongOptionValue.kt | 34 + .../ai/ConfigParseOpenFileOptionValue.kt | 84 +++ .../ai/ConfigParsePathOptionValue.kt | 33 + .../ai/ConfigParsePidFileOptionValue.kt | 40 ++ ...gParseServiceRefreshOnReloadOptionValue.kt | 51 ++ .../ai/ConfigParseSocketListenOptionValues.kt | 14 +- .../ai/ConfigParseTimerCalendarOptionValue.kt | 4 +- .../ConfigParseTimerMonotonicOptionValue.kt | 4 +- .../grammar/BatchUnitCoreCombinators.kt | 680 +++++++++--------- .../ai/ConfigParseBusNameOptionValueTest.kt | 53 ++ .../ConfigParseIecSizeLongOptionValueTest.kt | 61 ++ ...ConfigParseJobTimeoutSecOptionValueTest.kt | 49 ++ .../ai/ConfigParseLongOptionValueTest.kt | 55 ++ ...figParseObsoleteUnitDepsOptionValueTest.kt | 46 ++ .../ai/ConfigParseOpenFileOptionValueTest.kt | 59 ++ .../ai/ConfigParsePathOptionValueTest.kt | 65 ++ .../ai/ConfigParsePidFileOptionValueTest.kt | 55 ++ ...seServiceRefreshOnReloadOptionValueTest.kt | 53 ++ 22 files changed, 1209 insertions(+), 348 deletions(-) create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBusNameOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseIecSizeLongOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseJobTimeoutSecOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLongOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePathOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePidFileOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseServiceRefreshOnReloadOptionValue.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBusNameOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseIecSizeLongOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseJobTimeoutSecOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLongOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseObsoleteUnitDepsOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOpenFileOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePathOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePidFileOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseServiceRefreshOnReloadOptionValueTest.kt diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt index f10549f6..86799f7a 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt @@ -428,6 +428,17 @@ fun getAllAIGeneratedValidators(): Map { Validator("config_parse_socket_listen", "SOCKET_MQUEUE") to ConfigParseSocketListenFileOptionValue() as OptionValueInformation, Validator("config_parse_socket_listen", "SOCKET_USB_FUNCTION") to ConfigParseSocketListenFileOptionValue() as OptionValueInformation, Validator("config_parse_socket_listen", "SOCKET_SPECIAL") to ConfigParseSocketListenSpecialOptionValue() as OptionValueInformation, + Validator("config_parse_service_refresh_on_reload", "0") to ConfigParseServiceRefreshOnReloadOptionValue() as OptionValueInformation, + Validator("config_parse_pid_file", "0") to ConfigParsePidFileOptionValue() as OptionValueInformation, + Validator("config_parse_open_file", "0") to ConfigParseOpenFileOptionValue() as OptionValueInformation, + Validator("config_parse_job_timeout_sec", "0") to ConfigParseJobTimeoutSecOptionValue() as OptionValueInformation, + Validator("config_parse_bus_name", "0") to ConfigParseBusNameOptionValue() as OptionValueInformation, + // RequiresOverridable=/RequisiteOverridable= just log and forward to config_parse_unit_deps. + Validator("config_parse_obsolete_unit_deps", "UNIT_REQUIRES") to UnitDependencyOptionValue() as OptionValueInformation, + Validator("config_parse_obsolete_unit_deps", "UNIT_REQUISITE") to UnitDependencyOptionValue() as OptionValueInformation, + Validator("config_parse_path", "0") to ConfigParsePathOptionValue() as OptionValueInformation, + Validator("config_parse_long", "0") to ConfigParseLongOptionValue() as OptionValueInformation, + Validator("config_parse_iec_size_long", "0") to ConfigParseIecSizeLongOptionValue() as OptionValueInformation, ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBusNameOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBusNameOptionValue.kt new file mode 100644 index 00000000..71255292 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBusNameOptionValue.kt @@ -0,0 +1,48 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.AlternativeCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.withUnitSpecifiers +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.DOT +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.OneOrMore +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.RegexTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator + +/* + * [Service] BusName= + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#BusName= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L2421-L2453 config_parse_bus_name (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit-printf.c#L179 unit_full_printf_full (specifiers expanded first) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/libsystemd/sd-bus/bus-type.c#L144 sd_bus_service_name_is_valid + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/libsystemd/sd-bus/bus-internal.c#L119 service_name_is_valid + * linux https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-names-bus + */ + +// One dot-separated element of a well-known name: letters, digits, `_` and `-`, not starting with a +// digit. The syntactic shape is wider so a digit-led element is reported as the bad token. +private val WELL_KNOWN_ELEMENT = RegexTerminal("[A-Za-z0-9_-]+", "[A-Za-z_-][A-Za-z0-9_-]*") + +// In a unique name (leading `:`) elements may start with a digit. +private val UNIQUE_ELEMENT = RegexTerminal("[A-Za-z0-9_-]+", "[A-Za-z0-9_-]+") + +/** + * Validator for `[Service] BusName=` (config_parse_bus_name, the only key using it). + * + * After specifier expansion the name must pass sd_bus_service_name_is_valid(): two or more + * dot-separated elements of `[A-Za-z0-9_-]`, none empty and none starting with a digit — or a unique + * name (`:1.42`), whose elements may start with a digit; systemd accepts that form too, odd as it is + * for this setting. The 255-character limit is not checked. Any value that uses a known unit + * specifier (`org.example.%i`) is accepted. + */ +class ConfigParseBusNameOptionValue : SimpleGrammarOptionValues( + "config_parse_bus_name", + withUnitSpecifiers( + AlternativeCombinator( + SequenceCombinator(LiteralChoiceTerminal(":"), UNIQUE_ELEMENT, OneOrMore(SequenceCombinator(DOT, UNIQUE_ELEMENT)), EOF()), + SequenceCombinator(WELL_KNOWN_ELEMENT, OneOrMore(SequenceCombinator(DOT, WELL_KNOWN_ELEMENT)), EOF()), + ) + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseIecSizeLongOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseIecSizeLongOptionValue.kt new file mode 100644 index 00000000..1d2bb94c --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseIecSizeLongOptionValue.kt @@ -0,0 +1,28 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.IEC_SIZE + +/* + * [Socket] MessageQueueMessageSize= + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html#MessageQueueMessageSize= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L1004-L1032 config_parse_iec_size_long (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L157-L283 parse_size (base 1024) + * tests https://github.com/systemd/systemd/blob/0f0682b773/src/test/test-parse-util.c#L111 parse_size cases (1024 block) + */ + +/** + * Validator for config_parse_iec_size_long, used only by `[Socket] MessageQueueMessageSize=`. + * + * A parse_size(…, 1024) byte size: `8192`, `8K`, `1.5M`, `3. K`, `4 M 11.5K`, `3.5G3B` — see + * [IEC_SIZE]. Suffixes are upper-case B/K/M/G/T/P/E only. systemd additionally requires the result to + * fit in a long and the suffixes of a multi-part size to be strictly descending; neither is checked + * here. + * + * The C parser has no empty-value case; the plugin does not validate empty assignments. + */ +class ConfigParseIecSizeLongOptionValue : SimpleGrammarOptionValues( + "config_parse_iec_size_long", + IEC_SIZE +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseJobTimeoutSecOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseJobTimeoutSecOptionValue.kt new file mode 100644 index 00000000..6ebad830 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseJobTimeoutSecOptionValue.kt @@ -0,0 +1,30 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.TIMESPAN + +/* + * [Unit] JobTimeoutSec= + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#JobTimeoutSec= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L5594-L5630 config_parse_job_timeout_sec (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1293 parse_sec_fix_0 (parse_sec, then 0 means infinity) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1198-L1287 parse_time + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1147 extract_multiplier + * tests https://github.com/systemd/systemd/blob/0f0682b773/src/test/test-time-util.c#L85 TEST(parse_sec_fix_0) + */ + +/** + * Validator for `[Unit] JobTimeoutSec=` (config_parse_job_timeout_sec, the only key using it). + * + * The value goes straight to parse_sec_fix_0() — no specifier expansion — so it is a plain + * [TIMESPAN]: `infinity`, `0` (which this parser also turns into infinity), `90`, `5min 20s`, `1h30min`. + * + * There is no special case for an empty value in the C parser: parse_sec("") fails and the assignment + * is ignored with a warning. The plugin does not run value validators on empty assignments, so that + * case is not reported here. + */ +class ConfigParseJobTimeoutSecOptionValue : SimpleGrammarOptionValues( + "config_parse_job_timeout_sec", + TIMESPAN +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLongOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLongOptionValue.kt new file mode 100644 index 00000000..8bb06354 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLongOptionValue.kt @@ -0,0 +1,34 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.SafeAtoNumberTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator + +/* + * [Socket] MessageQueueMaxMessages= + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html#MessageQueueMaxMessages= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L960 config_parse_long (DEFINE_PARSER(long, long, safe_atoli)) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.h#L114 safe_atoli (safe_atolli where long is 64-bit) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L560 safe_atolli (strtoll base 0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L403 mangle_base (0b / 0o prefixes) + * linux https://man7.org/linux/man-pages/man7/mq_overview.7.html + * linux https://man7.org/linux/man-pages/man3/strtol.3.html + */ + +/** + * Validator for config_parse_long, used only by `[Socket] MessageQueueMaxMessages=`. + * + * A signed `long` as safe_atoli() reads it: an optional sign, then decimal, `0x` hex, leading-zero + * octal, or (unsigned only) a `0b`/`0o` prefix — anything from LONG_MIN to LONG_MAX on a 64-bit host. + * The setting itself only means something when positive (socket.c tests `mq_maxmsg > 0`), but the + * parser accepts any long and so does this. + * + * The C parser has no empty-value case (safe_atoli("") fails and the line is ignored with a warning); + * the plugin does not validate empty assignments, so that is not reported. + */ +class ConfigParseLongOptionValue : SimpleGrammarOptionValues( + "config_parse_long", + SequenceCombinator(SafeAtoNumberTerminal(Long.MIN_VALUE, Long.MAX_VALUE, signed = true), EOF()) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt new file mode 100644 index 00000000..87b8a9db --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt @@ -0,0 +1,84 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.FlexibleLiteralChoiceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.OneOrMore +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.RegexTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrMore +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne + +/* + * [Service] OpenFile= + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#OpenFile= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L6743-L6777 config_parse_open_file (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/open-file.c#L12-L68 open_file_parse + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/open-file.c#L70 open_file_validate (absolute path, valid fd name, at most one of read-only/append/truncate) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/open-file.c#L134 open_file_flags_table + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/fd-util.c#L554 fdname_is_valid (printable ASCII except ':', at most 255) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L10 extract_first_word (":" with EXTRACT_DONT_COALESCE_SEPARATORS|EXTRACT_CUNESCAPE; "," with 0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/escape.c#L104 cunescape_one + * tests https://github.com/systemd/systemd/blob/0f0682b773/src/test/test-open-file.c#L7 TEST(open_file_parse) + */ + +// A C escape as cunescape_one() understands it; any other backslash sequence makes open_file_parse fail. +private const val C_ESCAPE = """\\(?:[abfnrtv\\"'s]|x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|[0-7]{3})""" + +// The path: everything up to the first ':' (a ':' inside it has to be escaped, e.g. \x3a). Must be +// absolute; path_is_valid() allows ".." here, unlike most unit-file paths. No specifier expansion. +private val OPEN_FILE_PATH = RegexTerminal("""(?:[^:\\]|\\[\s\S]?)+""", """(?:/|$C_ESCAPE)(?:[^:\\]|$C_ESCAPE)*(?=:|$)""") + +// The fd name: printable ASCII other than ':'. Empty means "use the file name". +private val OPEN_FILE_FDNAME = RegexTerminal("""(?:[^:\\]|\\[\s\S]?)+""", """(?:[\x20-\x7E&&[^:\\]]|$C_ESCAPE){1,255}(?=:|$)""") + +private val OPEN_FILE_FLAG = FlexibleLiteralChoiceTerminal("read-only", "append", "truncate", "graceful") +private val COMMA = LiteralChoiceTerminal(",") + +// extract_first_word(…, ",", 0) coalesces separators, so stray commas anywhere are harmless (even a +// field of nothing but commas, which yields no flags). +private val OPEN_FILE_OPTIONS = SequenceCombinator( + ZeroOrMore(COMMA), + ZeroOrOne( + SequenceCombinator( + OPEN_FILE_FLAG, + ZeroOrMore(SequenceCombinator(OneOrMore(COMMA), OPEN_FILE_FLAG)), + ZeroOrMore(COMMA), + ) + ), +) + +/** + * Validator for `[Service] OpenFile=` (config_parse_open_file, the only key using it). + * + * `PATH[:FD-NAME[:OPTIONS]]`, split on the first two colons with C escapes decoded (so a literal colon + * in the path is written `\x3a`) and nothing allowed after a third field: + * + * - PATH must be absolute; + * - FD-NAME, if given, is printable ASCII without `:` (at most 255 characters); an empty one defaults + * to the file name; + * - OPTIONS is a comma-separated list of `read-only`, `append`, `truncate`, `graceful`. + * + * Not checked: a flag given twice, or more than one of read-only/append/truncate (both refused by + * systemd), and a path such as `/` whose file name cannot serve as the default fd name. Unlike most + * settings this one performs no specifier expansion, so `%` is an ordinary character. + * + * An empty assignment resets the list and is handled before this grammar is consulted. + */ +class ConfigParseOpenFileOptionValue : SimpleGrammarOptionValues( + "config_parse_open_file", + SequenceCombinator( + OPEN_FILE_PATH, + ZeroOrOne( + SequenceCombinator( + COLON, + ZeroOrOne(OPEN_FILE_FDNAME), + ZeroOrOne(SequenceCombinator(COLON, OPEN_FILE_OPTIONS)), + ) + ), + EOF(), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePathOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePathOptionValue.kt new file mode 100644 index 00000000..18aa24d2 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePathOptionValue.kt @@ -0,0 +1,33 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.LITERAL_ABSOLUTE_PATH +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator + +/* + * .nspawn [Exec] WorkingDirectory= and [Network] NamespacePath= (also journald.conf TTYPath=, a file + * type the plugin does not load). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#WorkingDirectory= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#NamespacePath= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L1378-L1414 config_parse_path (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L34 path_simplify_and_warn (PATH_CHECK_ABSOLUTE) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/path-util.c#L1192 path_is_normalized (no ".." component) + */ + +/** + * Validator for config_parse_path with ltype 0, used by `[Exec] WorkingDirectory=` and + * `[Network] NamespacePath=` in .nspawn files. + * + * The value is taken literally (no specifier expansion) and must be an absolute path with no `..` + * component; `.` components and repeated slashes are simplified away rather than refused. There is no + * API VFS restriction, so `NamespacePath=/proc/1/ns/net` is fine. A non-zero ltype would make the error + * fatal but accepts the same values; no gperf entry uses one today. + * + * An empty assignment clears the setting and is handled before this grammar is consulted. + */ +class ConfigParsePathOptionValue : SimpleGrammarOptionValues( + "config_parse_path", + SequenceCombinator(LITERAL_ABSOLUTE_PATH, EOF()) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePidFileOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePidFileOptionValue.kt new file mode 100644 index 00000000..574dc291 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePidFileOptionValue.kt @@ -0,0 +1,40 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.NOT_API_VFS +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.NO_DOT_DOT_COMPONENT +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.UNIT_TEXT_CHAR +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.RegexTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator + +/* + * [Service] PIDFile= + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#PIDFile= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L5702-L5750 config_parse_pid_file (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit-printf.c#L253 unit_path_printf (specifiers expanded first) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L34 path_simplify_and_warn (PATH_CHECK_ABSOLUTE | PATH_CHECK_NON_API_VFS) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/path-util.c#L1192 path_is_normalized (no ".." component) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/mountpoint-util.c#L721 path_below_api_vfs (/dev, /sys, /proc) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L574 patch_var_run (/var/run/ is rewritten with a warning, not refused) + */ + +/** + * Validator for `[Service] PIDFile=` (config_parse_pid_file, the only key using it). + * + * After specifier expansion a relative path is made absolute by prefixing the runtime directory (/run, + * or $XDG_RUNTIME_DIR for user services), so both `foo.pid` and `/run/foo.pid` are fine. The result + * then goes through path_simplify_and_warn(PATH_CHECK_ABSOLUTE | PATH_CHECK_NON_API_VFS): no `..` + * component anywhere, and an absolute path may not be /dev, /sys, /proc or below them. Unknown + * specifiers make systemd ignore the setting, so those are flagged too. + * + * An empty assignment clears the setting and is handled before this grammar is consulted. + */ +class ConfigParsePidFileOptionValue : SimpleGrammarOptionValues( + "config_parse_pid_file", + SequenceCombinator( + RegexTerminal("""[\s\S]+""", """$NO_DOT_DOT_COMPONENT$NOT_API_VFS$UNIT_TEXT_CHAR+$"""), + EOF(), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseServiceRefreshOnReloadOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseServiceRefreshOnReloadOptionValue.kt new file mode 100644 index 00000000..4d1903ab --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseServiceRefreshOnReloadOptionValue.kt @@ -0,0 +1,51 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.AlternativeCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BOOLEAN +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.FlexibleLiteralChoiceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.WhitespaceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrMore +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne + +/* + * [Service] RefreshOnReload= + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#RefreshOnReload= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L4928-L4975 config_parse_service_refresh_on_reload (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/service.c#L6630-L6657 service_refresh_on_reload_from_string_many + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/service.c#L6615 service_refresh_on_reload_table ("extensions", "credentials"; streq, so case-sensitive) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L10 extract_first_word (whitespace-separated, flags 0) + */ + +private val REFRESH_RESOURCE = FlexibleLiteralChoiceTerminal("extensions", "credentials") +private val OPTIONAL_WHITESPACE = ZeroOrOne(WhitespaceTerminal()) + +/** + * Validator for `[Service] RefreshOnReload=` (config_parse_service_refresh_on_reload, the only key using + * it). + * + * Either a boolean (parse_boolean is tried first), or a whitespace-separated list of `extensions` and + * `credentials`, optionally prefixed by a single `~` to invert it. A bare `~` is accepted by systemd + * (an empty inverted list, i.e. the default) and so is accepted here. A boolean cannot be inverted + * (`~yes` is an unknown resource name). + * + * An empty assignment resets to the default and is handled before this grammar is consulted. + */ +class ConfigParseServiceRefreshOnReloadOptionValue : SimpleGrammarOptionValues( + "config_parse_service_refresh_on_reload", + AlternativeCombinator( + SequenceCombinator(BOOLEAN, EOF()), + SequenceCombinator( + ZeroOrOne(LiteralChoiceTerminal("~")), + OPTIONAL_WHITESPACE, + ZeroOrOne( + SequenceCombinator(REFRESH_RESOURCE, ZeroOrMore(SequenceCombinator(WhitespaceTerminal(), REFRESH_RESOURCE)), OPTIONAL_WHITESPACE) + ), + EOF(), + ), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt index 0bb5f882..a82f349b 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt @@ -14,14 +14,14 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.RegexTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.UNIT_ABSOLUTE_NON_API_VFS_PATH -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.UNIT_ABSOLUTE_PATH -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.UNIT_SPECIFIER_LETTERS -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.UNIT_TEXT_CHAR +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.UNIT_ABSOLUTE_NON_API_VFS_PATH +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.UNIT_ABSOLUTE_PATH +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.UNIT_TEXT_CHAR import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.WhitespaceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.safeAtou -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.withUnitSpecifiers +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.safeAtou +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.withUnitSpecifiers /* * [Socket] ListenStream= / ListenDatagram= / ListenSequentialPacket= / ListenNetlink= (SOCKET_SOCKET), @@ -65,7 +65,7 @@ private val PERCENT_START_SPECIFIER = """%[$UNIT_SPECIFIER_LETTERS]""" // because a specifier's expansion length is unknown; a specifier may also stand at the start (%t/foo). private val UNIX_SOCKET_ADDRESS = RegexTerminal( """[/@%][\s\S]*""", - """(?:(?=[^%]*$)[/@][\s\S]{1,106}|(?=[\s\S]*%)(?:[/@]|$PERCENT_START_SPECIFIER)$UNIT_TEXT_CHAR*)""" + """(?:(?=[^%]*$)[/@][\s\S]{1,106}|(?=[\s\S]*%)(?:[/@]|$PERCENT_START_SPECIFIER)$UNIT_TEXT_CHAR*)$""" ) private val IP_PORT = safeAtou(1, 65535) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt index 3b9e86b3..100d5502 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt @@ -1,8 +1,8 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.CALENDAR_SPEC -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.withUnitSpecifiers +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.CALENDAR_SPEC +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.withUnitSpecifiers /* * [Timer] OnCalendar= diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerMonotonicOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerMonotonicOptionValue.kt index c848714c..47ce6b38 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerMonotonicOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerMonotonicOptionValue.kt @@ -1,8 +1,8 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.TIMESPAN -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.withUnitSpecifiers +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.TIMESPAN +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.withUnitSpecifiers /* * [Timer] OnActiveSec= / OnBootSec= / OnStartupSec= / OnUnitActiveSec= / OnUnitInactiveSec= diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt index 30b7497d..a8a94311 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt @@ -16,373 +16,379 @@ import java.math.BigInteger * Every grammar here is careful about the classic engine: no repetition wraps something that can match * the empty string (classic ZeroOrMore/OneOrMore would loop forever), and every alternative that * could stop on a prefix of another is either ordered after it or terminated with EOF(). - */ - -// --------------------------------------------------------------------------------------------------- -// Unit specifiers — specifier_printf (src/shared/specifier.c) driven by the table in -// unit_full_printf_full (src/core/unit-printf.c), which also pulls in COMMON_SYSTEM_SPECIFIERS, -// COMMON_CREDS_SPECIFIERS and COMMON_TMP_SPECIFIERS (src/shared/specifier.h). -// -// `%%` is a literal percent sign. `%` followed by a letter or digit that is NOT in the table is a hard -// error (-EBADSLT, "Failed to resolve unit specifiers"), so the setting is ignored. `%` followed by -// anything else (or at the very end) is copied through verbatim. - -/** Every specifier letter unit_full_printf() knows about. */ -const val UNIT_SPECIFIER_LETTERS = "aAbBcCdDEfgGhHiIjJlLmMnNopPqrRsStTuUvVwWyY" - -/** One character of specifier-expanded text: anything but a `%`, or a `%` that expands to something. */ -const val UNIT_TEXT_CHAR = """(?:[^%]|%%|%[$UNIT_SPECIFIER_LETTERS]|%(?![A-Za-z0-9%]))""" - -// A run of text that contains at least one real (value-changing) specifier, and no unknown ones. -private const val NON_SPECIFIER_CHAR = """(?:[^%]|%%|%(?![A-Za-z0-9%]))""" - -/** - * A whole value that contains at least one unit specifier (`%i`, `%t`, …) and no unknown ones. * - * What a specifier expands to is only known when the unit is loaded, so a value that uses one cannot - * be checked against a grammar for the expanded text. This terminal is the escape hatch: it accepts - * such a value as long as every `%X` in it is one systemd knows (an unknown one makes systemd ignore - * the whole assignment, so that is still flagged). - */ -val UNIT_SPECIFIED_VALUE = RegexTerminal( - """[\s\S]*%[A-Za-z0-9][\s\S]*""", - """(?=$NON_SPECIFIER_CHAR*%[$UNIT_SPECIFIER_LETTERS])$UNIT_TEXT_CHAR*""" -) - -/** - * [grammar] (which must itself end in [EOF]), or any value that uses a unit specifier — for settings - * whose C parser runs unit_full_printf()/unit_path_printf() before parsing the expanded text. + * Everything is namespaced in [BatchUnitCoreGrammar] so that generic names like TIMESPAN cannot collide + * with top-level declarations other batches add to this package in parallel. */ -fun withUnitSpecifiers(grammar: Combinator): Combinator = - AlternativeCombinator(grammar, SequenceCombinator(UNIT_SPECIFIED_VALUE, EOF())) - - -// --------------------------------------------------------------------------------------------------- -// Time spans — parse_time(…, USEC_PER_SEC) a.k.a. parse_sec (src/basic/time-util.c). -// -// - Leading/trailing whitespace is skipped. `infinity` stands alone (only whitespace may follow). -// - Each element is a number read by strtoll(…, 10) — so an optional `+`, never `-` — with an -// optional `.digits` fraction (a bare `.5` is fine, `5.` is not), then optional whitespace and an -// optional unit from extract_multiplier's table. With no unit the default (seconds) applies. -// - Elements may follow each other directly after a unit (`1h30min`, `3.1s.2`) but a number without -// a unit must be followed by whitespace or the end (`12.34.56`, `5+5` are refused). -// - A unit is matched by prefix in table order, so `5mins` reads `min` and then chokes on `s`. -// -// Overflow (ERANGE past UINT64_MAX µs) is not modelled. - -private val TS_OPTIONAL_WS = ZeroOrOne(WhitespaceTerminal()) - -/** One number of a time span, as strtoll(…, 10) plus parse_time's own `.fraction` handling reads it. */ -val TIMESPAN_NUMBER = RegexTerminal("""\+?[0-9]+(?:\.[0-9]+)?|\.[0-9]+""", """\+?[0-9]+(?:\.[0-9]+)?|\.[0-9]+""") - -/** extract_multiplier's suffix table (case-sensitive: `m` is minutes, `M` is months). */ -val TIMESPAN_UNIT = FlexibleLiteralChoiceTerminal( - "seconds", "second", "sec", "s", - "minutes", "minute", "min", - "months", "month", "M", - "msec", "ms", "m", - "hours", "hour", "hr", "h", - "days", "day", "d", - "weeks", "week", "w", - "years", "year", "y", - "usec", "us", "μs", "µs", -) - -private val TIMESPAN_ELEMENT = AlternativeCombinator( - SequenceCombinator(TIMESPAN_NUMBER, TS_OPTIONAL_WS, TIMESPAN_UNIT, TS_OPTIONAL_WS), - SequenceCombinator(TIMESPAN_NUMBER, AlternativeCombinator(WhitespaceTerminal(), EOF())), -) - -/** A complete parse_sec() time span, `infinity` included, ending at [EOF]. */ -val TIMESPAN = AlternativeCombinator( - SequenceCombinator(TS_OPTIONAL_WS, LiteralChoiceTerminal("infinity"), TS_OPTIONAL_WS, EOF()), - SequenceCombinator(TS_OPTIONAL_WS, OneOrMore(TIMESPAN_ELEMENT), EOF()), -) - - -// --------------------------------------------------------------------------------------------------- -// Calendar events — calendar_spec_from_string (src/shared/calendarspec.c). -// -// [WEEKDAYS] [DATE] [TIME] [TIMEZONE] or KEYWORD [TIMEZONE] -// -// What is checked: the shape the C parser walks (weekday list with `..`/`-` ranges and an optional -// trailing comma, a two- or three-part date with `-` or `~`, a two- or three-part time, `@epoch`), and -// the per-field ranges calendar_spec_valid() enforces after fix_year() (years 0..99 or 1970..2199, -// months 1..12, days 1..31 or 1..28 counted from the end of the month, hours 0..23, minutes and -// seconds 0..59), with repetitions bounded by `to - from` and never zero. -// -// What is deliberately left lenient: cross-field checks (reversed ranges like `12..1`, a repetition -// that overshoots the field like `20/4` hours, descending weekday ranges like `Fri..Mon`), rounding -// of second fractions, and the timezone, which systemd only accepts if it names a file under -// /usr/share/zoneinfo or the host's local zone abbreviation — any word shaped like a zone name is -// accepted here. - -private val CAL_SPACES = RegexTerminal(" +", " +") -private val CAL_STAR = LiteralChoiceTerminal("*") -private val CAL_COMMA = LiteralChoiceTerminal(",") -private val CAL_RANGE = LiteralChoiceTerminal("..") -private val CAL_REPEAT = LiteralChoiceTerminal("/") -private val CAL_TILDE = LiteralChoiceTerminal("~") -/** - * A plain decimal number in `[minInclusive, maxExclusive)`, as calendarspec's parse_component_decimal - * reads one: it insists on a leading digit (so no sign, unlike [IntegerTerminal], which accepts `-0`) - * and then calls strtoul(…, 10). - */ -class DecimalNumberTerminal(private val minInclusive: Long, private val maxExclusive: Long) : TerminalCombinator { - private fun span(value: String, offset: Int): String? = SHAPE.matchAt(value, offset)?.value +object BatchUnitCoreGrammar { + + // --------------------------------------------------------------------------------------------------- + // Unit specifiers — specifier_printf (src/shared/specifier.c) driven by the table in + // unit_full_printf_full (src/core/unit-printf.c), which also pulls in COMMON_SYSTEM_SPECIFIERS, + // COMMON_CREDS_SPECIFIERS and COMMON_TMP_SPECIFIERS (src/shared/specifier.h). + // + // `%%` is a literal percent sign. `%` followed by a letter or digit that is NOT in the table is a hard + // error (-EBADSLT, "Failed to resolve unit specifiers"), so the setting is ignored. `%` followed by + // anything else (or at the very end) is copied through verbatim. + + /** Every specifier letter unit_full_printf() knows about. */ + const val UNIT_SPECIFIER_LETTERS = "aAbBcCdDEfgGhHiIjJlLmMnNopPqrRsStTuUvVwWyY" + + /** One character of specifier-expanded text: anything but a `%`, or a `%` that expands to something. */ + const val UNIT_TEXT_CHAR = """(?:[^%]|%%|%[$UNIT_SPECIFIER_LETTERS]|%(?![A-Za-z0-9%]))""" + + // A run of text that contains at least one real (value-changing) specifier, and no unknown ones. + private const val NON_SPECIFIER_CHAR = """(?:[^%]|%%|%(?![A-Za-z0-9%]))""" + + /** + * A whole value that contains at least one unit specifier (`%i`, `%t`, …) and no unknown ones. + * + * What a specifier expands to is only known when the unit is loaded, so a value that uses one cannot + * be checked against a grammar for the expanded text. This terminal is the escape hatch: it accepts + * such a value as long as every `%X` in it is one systemd knows (an unknown one makes systemd ignore + * the whole assignment, so that is still flagged). + */ + val UNIT_SPECIFIED_VALUE = RegexTerminal( + """[\s\S]*%[A-Za-z0-9][\s\S]*""", + """(?=$NON_SPECIFIER_CHAR*%[$UNIT_SPECIFIER_LETTERS])$UNIT_TEXT_CHAR*$""" + ) + + /** + * [grammar] (which must itself end in [EOF]), or any value that uses a unit specifier — for settings + * whose C parser runs unit_full_printf()/unit_path_printf() before parsing the expanded text. + */ + fun withUnitSpecifiers(grammar: Combinator): Combinator = + AlternativeCombinator(grammar, SequenceCombinator(UNIT_SPECIFIED_VALUE, EOF())) + + + // --------------------------------------------------------------------------------------------------- + // Time spans — parse_time(…, USEC_PER_SEC) a.k.a. parse_sec (src/basic/time-util.c). + // + // - Leading/trailing whitespace is skipped. `infinity` stands alone (only whitespace may follow). + // - Each element is a number read by strtoll(…, 10) — so an optional `+`, never `-` — with an + // optional `.digits` fraction (a bare `.5` is fine, `5.` is not), then optional whitespace and an + // optional unit from extract_multiplier's table. With no unit the default (seconds) applies. + // - Elements may follow each other directly after a unit (`1h30min`, `3.1s.2`) but a number without + // a unit must be followed by whitespace or the end (`12.34.56`, `5+5` are refused). + // - A unit is matched by prefix in table order, so `5mins` reads `min` and then chokes on `s`. + // + // Overflow (ERANGE past UINT64_MAX µs) is not modelled. + + private val TS_OPTIONAL_WS = ZeroOrOne(WhitespaceTerminal()) + + /** One number of a time span, as strtoll(…, 10) plus parse_time's own `.fraction` handling reads it. */ + val TIMESPAN_NUMBER = RegexTerminal("""\+?[0-9]+(?:\.[0-9]+)?|\.[0-9]+""", """\+?[0-9]+(?:\.[0-9]+)?|\.[0-9]+""") + + /** extract_multiplier's suffix table (case-sensitive: `m` is minutes, `M` is months). */ + val TIMESPAN_UNIT = FlexibleLiteralChoiceTerminal( + "seconds", "second", "sec", "s", + "minutes", "minute", "min", + "months", "month", "M", + "msec", "ms", "m", + "hours", "hour", "hr", "h", + "days", "day", "d", + "weeks", "week", "w", + "years", "year", "y", + "usec", "us", "μs", "µs", + ) + + private val TIMESPAN_ELEMENT = AlternativeCombinator( + SequenceCombinator(TIMESPAN_NUMBER, TS_OPTIONAL_WS, TIMESPAN_UNIT, TS_OPTIONAL_WS), + SequenceCombinator(TIMESPAN_NUMBER, AlternativeCombinator(WhitespaceTerminal(), EOF())), + ) + + /** A complete parse_sec() time span, `infinity` included, ending at [EOF]. */ + val TIMESPAN = AlternativeCombinator( + SequenceCombinator(TS_OPTIONAL_WS, LiteralChoiceTerminal("infinity"), TS_OPTIONAL_WS, EOF()), + SequenceCombinator(TS_OPTIONAL_WS, OneOrMore(TIMESPAN_ELEMENT), EOF()), + ) + + + // --------------------------------------------------------------------------------------------------- + // Calendar events — calendar_spec_from_string (src/shared/calendarspec.c). + // + // [WEEKDAYS] [DATE] [TIME] [TIMEZONE] or KEYWORD [TIMEZONE] + // + // What is checked: the shape the C parser walks (weekday list with `..`/`-` ranges and an optional + // trailing comma, a two- or three-part date with `-` or `~`, a two- or three-part time, `@epoch`), and + // the per-field ranges calendar_spec_valid() enforces after fix_year() (years 0..99 or 1970..2199, + // months 1..12, days 1..31 or 1..28 counted from the end of the month, hours 0..23, minutes and + // seconds 0..59), with repetitions bounded by `to - from` and never zero. + // + // What is deliberately left lenient: cross-field checks (reversed ranges like `12..1`, a repetition + // that overshoots the field like `20/4` hours, descending weekday ranges like `Fri..Mon`), rounding + // of second fractions, and the timezone, which systemd only accepts if it names a file under + // /usr/share/zoneinfo or the host's local zone abbreviation — any word shaped like a zone name is + // accepted here. + + private val CAL_SPACES = RegexTerminal(" +", " +") + private val CAL_STAR = LiteralChoiceTerminal("*") + private val CAL_COMMA = LiteralChoiceTerminal(",") + private val CAL_RANGE = LiteralChoiceTerminal("..") + private val CAL_REPEAT = LiteralChoiceTerminal("/") + private val CAL_TILDE = LiteralChoiceTerminal("~") + /** + * A plain decimal number in `[minInclusive, maxExclusive)`, as calendarspec's parse_component_decimal + * reads one: it insists on a leading digit (so no sign, unlike [IntegerTerminal], which accepts `-0`) + * and then calls strtoul(…, 10). + */ + class DecimalNumberTerminal(private val minInclusive: Long, private val maxExclusive: Long) : TerminalCombinator { + + private fun span(value: String, offset: Int): String? = SHAPE.matchAt(value, offset)?.value + + private fun inRange(text: String): Boolean = + text.toBigInteger().let { it >= BigInteger.valueOf(minInclusive) && it < BigInteger.valueOf(maxExclusive) } + + override fun SyntacticMatch(value: String, offset: Int): MatchResult { + val text = span(value, offset) ?: return NoMatch + return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) + } - private fun inRange(text: String): Boolean = - text.toBigInteger().let { it >= BigInteger.valueOf(minInclusive) && it < BigInteger.valueOf(maxExclusive) } + override fun SemanticMatch(value: String, offset: Int): MatchResult { + val text = span(value, offset) ?: return NoMatch + if (!inRange(text)) return NoMatch.copy(longestMatch = offset) + return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) + } - override fun SyntacticMatch(value: String, offset: Int): MatchResult { - val text = span(value, offset) ?: return NoMatch - return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) - } + override fun parse(value: String, offset: Int): Sequence { + val text = span(value, offset) ?: return sequenceOf(Stuck(offset, setOf(this))) + return sequenceOf(Parse(offset + text.length, listOf(ParsedToken(offset, offset + text.length, text, this, inRange(text))))) + } - override fun SemanticMatch(value: String, offset: Int): MatchResult { - val text = span(value, offset) ?: return NoMatch - if (!inRange(text)) return NoMatch.copy(longestMatch = offset) - return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) - } + override fun toString(): String = "Decimal($minInclusive,$maxExclusive)" - override fun parse(value: String, offset: Int): Sequence { - val text = span(value, offset) ?: return sequenceOf(Stuck(offset, setOf(this))) - return sequenceOf(Parse(offset + text.length, listOf(ParsedToken(offset, offset + text.length, text, this, inRange(text))))) + private companion object { + val SHAPE = Regex("[0-9]+") + } } - override fun toString(): String = "Decimal($minInclusive,$maxExclusive)" - - private companion object { - val SHAPE = Regex("[0-9]+") - } -} + private val CAL_FRACTION = SequenceCombinator(LiteralChoiceTerminal("."), RegexTerminal("[0-9]+", "[0-9]+")) -private val CAL_FRACTION = SequenceCombinator(LiteralChoiceTerminal("."), RegexTerminal("[0-9]+", "[0-9]+")) + /** One chain element: `value[..value][/repeat]` — prepend_component. */ + private fun calComponent(value: Combinator, repeat: Combinator): Combinator = SequenceCombinator( + value, + ZeroOrOne(SequenceCombinator(CAL_RANGE, value)), + ZeroOrOne(SequenceCombinator(CAL_REPEAT, repeat)), + ) -/** One chain element: `value[..value][/repeat]` — prepend_component. */ -private fun calComponent(value: Combinator, repeat: Combinator): Combinator = SequenceCombinator( - value, - ZeroOrOne(SequenceCombinator(CAL_RANGE, value)), - ZeroOrOne(SequenceCombinator(CAL_REPEAT, repeat)), -) - -/** `*`, or a comma-separated list of components — parse_chain. `*` never combines with a list. */ -private fun calChain(value: Combinator, repeat: Combinator): Combinator { - val component = calComponent(value, repeat) - return AlternativeCombinator(CAL_STAR, SequenceCombinator(component, ZeroOrMore(SequenceCombinator(CAL_COMMA, component)))) -} + /** `*`, or a comma-separated list of components — parse_chain. `*` never combines with a list. */ + private fun calChain(value: Combinator, repeat: Combinator): Combinator { + val component = calComponent(value, repeat) + return AlternativeCombinator(CAL_STAR, SequenceCombinator(component, ZeroOrMore(SequenceCombinator(CAL_COMMA, component)))) + } -// fix_year() maps 0..69 to 2000..2069 and 70..99 to 1970..1999 before the 1970..2199 range check. -private val CAL_YEAR = AlternativeCombinator(DecimalNumberTerminal(1970, 2200), DecimalNumberTerminal(0, 100)) -private val CAL_YEAR_CHAIN = calChain(CAL_YEAR, DecimalNumberTerminal(1, 230)) -private val CAL_MONTH_CHAIN = calChain(DecimalNumberTerminal(1, 13), DecimalNumberTerminal(1, 12)) -private val CAL_DAY_CHAIN = calChain(DecimalNumberTerminal(1, 32), DecimalNumberTerminal(1, 31)) -// With `~` the day counts back from the end of the month and chain_valid() lowers the ceiling to 28. -private val CAL_END_OF_MONTH_DAY_CHAIN = calChain(DecimalNumberTerminal(1, 29), DecimalNumberTerminal(1, 28)) -private val CAL_HOUR_CHAIN = calChain(DecimalNumberTerminal(0, 24), DecimalNumberTerminal(1, 24)) -private val CAL_MINUTE_CHAIN = calChain(DecimalNumberTerminal(0, 60), DecimalNumberTerminal(1, 60)) -// Seconds are parsed with usec = true: a `.fraction` is allowed on values and repetitions alike. -private val CAL_SECOND_VALUE = SequenceCombinator(DecimalNumberTerminal(0, 60), ZeroOrOne(CAL_FRACTION)) -private val CAL_SECOND_REPEAT = AlternativeCombinator( - SequenceCombinator(DecimalNumberTerminal(0, 60), CAL_FRACTION), - DecimalNumberTerminal(1, 60), -) -private val CAL_SECOND_CHAIN = calChain(CAL_SECOND_VALUE, CAL_SECOND_REPEAT) - -private val CAL_DAY_PART = AlternativeCombinator( - SequenceCombinator(HYPHEN, CAL_DAY_CHAIN), - SequenceCombinator(CAL_TILDE, CAL_END_OF_MONTH_DAY_CHAIN), -) - -/** `[YEAR-]MONTH-DAY` or `[YEAR-]MONTH~DAY` — parse_date. A lone component is not a date. */ -private val CAL_DATE = AlternativeCombinator( - SequenceCombinator(CAL_YEAR_CHAIN, HYPHEN, CAL_MONTH_CHAIN, CAL_DAY_PART), - SequenceCombinator(CAL_MONTH_CHAIN, CAL_DAY_PART), -) - -/** `HOUR:MINUTE[:SECOND]` — parse_calendar_time. */ -private val CAL_TIME = SequenceCombinator( - CAL_HOUR_CHAIN, COLON, CAL_MINUTE_CHAIN, - ZeroOrOne(SequenceCombinator(COLON, CAL_SECOND_CHAIN)), -) - -// `@SECONDS` since the epoch; the resulting year must still pass the 1970..2199 check, i.e. the value -// must be below 2200-01-01T00:00:00Z. -private val CAL_EPOCH = SequenceCombinator(LiteralChoiceTerminal("@"), DecimalNumberTerminal(0L, 7_258_118_400L)) - -private val CAL_DATE_AND_TIME = AlternativeCombinator( - CAL_EPOCH, - SequenceCombinator(CAL_DATE, ZeroOrOne(SequenceCombinator(CAL_SPACES, CAL_TIME))), - CAL_TIME, -) - -/** parse_weekdays' table; startswith_no_case, so any casing. */ -val CALENDAR_WEEKDAY = FlexibleLiteralChoiceTerminal( - "Monday", "Mon", "Tuesday", "Tue", "Wednesday", "Wed", "Thursday", "Thu", - "Friday", "Fri", "Saturday", "Sat", "Sunday", "Sun", - ignoreCase = true, -) - -private val CAL_WEEKDAY_ITEM = SequenceCombinator( - CALENDAR_WEEKDAY, - ZeroOrOne(SequenceCombinator(LiteralChoiceTerminal("..", "-"), CALENDAR_WEEKDAY)), -) - -// A trailing comma is allowed ("Wednesday,", "Wed, 17:48"), an open range is not ("Monday.."). -private val CAL_WEEKDAYS = SequenceCombinator( - CAL_WEEKDAY_ITEM, - ZeroOrMore(SequenceCombinator(CAL_COMMA, CAL_WEEKDAY_ITEM)), - ZeroOrOne(CAL_COMMA), -) - -private val CAL_BODY = AlternativeCombinator( - SequenceCombinator(CAL_WEEKDAYS, ZeroOrOne(SequenceCombinator(CAL_SPACES, CAL_DATE_AND_TIME))), - CAL_DATE_AND_TIME, -) - -/** The shorthand keywords, matched with strcaseeq. `anually` is a kept-for-compatibility misspelling. */ -val CALENDAR_KEYWORD = FlexibleLiteralChoiceTerminal( - "minutely", "hourly", "daily", "monthly", "weekly", "yearly", "annually", "anually", "quarterly", - "biannually", "bi-annually", "semiannually", "semi-annually", - ignoreCase = true, -) - -/** - * A timezone suffix: ` UTC` (any case), the host's local zone abbreviation, or an IANA zone name - * (verify_timezone's character set: letters, digits, `-_+/`, no empty components). Whether the zone - * actually exists depends on the machine, so only the shape is checked; a leading letter keeps a date - * or time from being mistaken for one. - */ -private val CAL_TIMEZONE = RegexTerminal( - """[A-Za-z][A-Za-z0-9_+\-]*(?:/[A-Za-z0-9_+\-]+)*""", - """[A-Za-z][A-Za-z0-9_+\-]*(?:/[A-Za-z0-9_+\-]+)*""" -) -private val CAL_OPTIONAL_TIMEZONE = ZeroOrOne(SequenceCombinator(CAL_SPACES, CAL_TIMEZONE)) - -/** A complete calendar event expression, ending at [EOF]. */ -val CALENDAR_SPEC = AlternativeCombinator( - SequenceCombinator(CALENDAR_KEYWORD, CAL_OPTIONAL_TIMEZONE, EOF()), - SequenceCombinator(CAL_BODY, CAL_OPTIONAL_TIMEZONE, EOF()), -) - - -// --------------------------------------------------------------------------------------------------- -// Numbers as the safe_ato* family reads them (src/basic/parse-util.c): leading whitespace is skipped, -// mangle_base() turns a `0b`/`0o` prefix into base 2/8, and otherwise strtoul()/strtoll() run with base -// 0, so `0x` is hexadecimal and a leading `0` is octal. The unsigned helpers accept a leading `+` and -// refuse `-` (even `-0`); the signed ones accept both signs. `0b`/`0o` are only recognised unsigned. - -/** - * An integer spelled any way the safe_atou and safe_atoi helpers accept, range-checked on the value it denotes. - * - * Unlike [UnsignedNumberTerminal] this also accepts `0b…`, `0o…` and a leading `+`, and with - * [signed] a leading `-`. - */ -class SafeAtoNumberTerminal( - private val minInclusive: BigInteger, - private val maxInclusive: BigInteger, - private val signed: Boolean, -) : TerminalCombinator { - - constructor(minInclusive: Long, maxInclusive: Long, signed: Boolean) : - this(BigInteger.valueOf(minInclusive), BigInteger.valueOf(maxInclusive), signed) - - private fun span(value: String, offset: Int): String? = SHAPE.matchAt(value, offset)?.value - - private fun valueOf(text: String): BigInteger? { - var s = text - if (s.length > 2 && (s.startsWith("0b") || s.startsWith("0B"))) return s.drop(2).toBigIntegerOrNull(2) - if (s.length > 2 && (s.startsWith("0o") || s.startsWith("0O"))) return s.drop(2).toBigIntegerOrNull(8) - var negative = false - if (s.startsWith("+") || s.startsWith("-")) { - negative = s[0] == '-' - s = s.drop(1) + // fix_year() maps 0..69 to 2000..2069 and 70..99 to 1970..1999 before the 1970..2199 range check. + private val CAL_YEAR = AlternativeCombinator(DecimalNumberTerminal(1970, 2200), DecimalNumberTerminal(0, 100)) + private val CAL_YEAR_CHAIN = calChain(CAL_YEAR, DecimalNumberTerminal(1, 230)) + private val CAL_MONTH_CHAIN = calChain(DecimalNumberTerminal(1, 13), DecimalNumberTerminal(1, 12)) + private val CAL_DAY_CHAIN = calChain(DecimalNumberTerminal(1, 32), DecimalNumberTerminal(1, 31)) + // With `~` the day counts back from the end of the month and chain_valid() lowers the ceiling to 28. + private val CAL_END_OF_MONTH_DAY_CHAIN = calChain(DecimalNumberTerminal(1, 29), DecimalNumberTerminal(1, 28)) + private val CAL_HOUR_CHAIN = calChain(DecimalNumberTerminal(0, 24), DecimalNumberTerminal(1, 24)) + private val CAL_MINUTE_CHAIN = calChain(DecimalNumberTerminal(0, 60), DecimalNumberTerminal(1, 60)) + // Seconds are parsed with usec = true: a `.fraction` is allowed on values and repetitions alike. + private val CAL_SECOND_VALUE = SequenceCombinator(DecimalNumberTerminal(0, 60), ZeroOrOne(CAL_FRACTION)) + private val CAL_SECOND_REPEAT = AlternativeCombinator( + SequenceCombinator(DecimalNumberTerminal(0, 60), CAL_FRACTION), + DecimalNumberTerminal(1, 60), + ) + private val CAL_SECOND_CHAIN = calChain(CAL_SECOND_VALUE, CAL_SECOND_REPEAT) + + private val CAL_DAY_PART = AlternativeCombinator( + SequenceCombinator(HYPHEN, CAL_DAY_CHAIN), + SequenceCombinator(CAL_TILDE, CAL_END_OF_MONTH_DAY_CHAIN), + ) + + /** `[YEAR-]MONTH-DAY` or `[YEAR-]MONTH~DAY` — parse_date. A lone component is not a date. */ + private val CAL_DATE = AlternativeCombinator( + SequenceCombinator(CAL_YEAR_CHAIN, HYPHEN, CAL_MONTH_CHAIN, CAL_DAY_PART), + SequenceCombinator(CAL_MONTH_CHAIN, CAL_DAY_PART), + ) + + /** `HOUR:MINUTE[:SECOND]` — parse_calendar_time. */ + private val CAL_TIME = SequenceCombinator( + CAL_HOUR_CHAIN, COLON, CAL_MINUTE_CHAIN, + ZeroOrOne(SequenceCombinator(COLON, CAL_SECOND_CHAIN)), + ) + + // `@SECONDS` since the epoch; the resulting year must still pass the 1970..2199 check, i.e. the value + // must be below 2200-01-01T00:00:00Z. + private val CAL_EPOCH = SequenceCombinator(LiteralChoiceTerminal("@"), DecimalNumberTerminal(0L, 7_258_118_400L)) + + private val CAL_DATE_AND_TIME = AlternativeCombinator( + CAL_EPOCH, + SequenceCombinator(CAL_DATE, ZeroOrOne(SequenceCombinator(CAL_SPACES, CAL_TIME))), + CAL_TIME, + ) + + /** parse_weekdays' table; startswith_no_case, so any casing. */ + val CALENDAR_WEEKDAY = FlexibleLiteralChoiceTerminal( + "Monday", "Mon", "Tuesday", "Tue", "Wednesday", "Wed", "Thursday", "Thu", + "Friday", "Fri", "Saturday", "Sat", "Sunday", "Sun", + ignoreCase = true, + ) + + private val CAL_WEEKDAY_ITEM = SequenceCombinator( + CALENDAR_WEEKDAY, + ZeroOrOne(SequenceCombinator(LiteralChoiceTerminal("..", "-"), CALENDAR_WEEKDAY)), + ) + + // A trailing comma is allowed ("Wednesday,", "Wed, 17:48"), an open range is not ("Monday.."). + private val CAL_WEEKDAYS = SequenceCombinator( + CAL_WEEKDAY_ITEM, + ZeroOrMore(SequenceCombinator(CAL_COMMA, CAL_WEEKDAY_ITEM)), + ZeroOrOne(CAL_COMMA), + ) + + private val CAL_BODY = AlternativeCombinator( + SequenceCombinator(CAL_WEEKDAYS, ZeroOrOne(SequenceCombinator(CAL_SPACES, CAL_DATE_AND_TIME))), + CAL_DATE_AND_TIME, + ) + + /** The shorthand keywords, matched with strcaseeq. `anually` is a kept-for-compatibility misspelling. */ + val CALENDAR_KEYWORD = FlexibleLiteralChoiceTerminal( + "minutely", "hourly", "daily", "monthly", "weekly", "yearly", "annually", "anually", "quarterly", + "biannually", "bi-annually", "semiannually", "semi-annually", + ignoreCase = true, + ) + + /** + * A timezone suffix: ` UTC` (any case), the host's local zone abbreviation, or an IANA zone name + * (verify_timezone's character set: letters, digits, `-_+/`, no empty components). Whether the zone + * actually exists depends on the machine, so only the shape is checked; a leading letter keeps a date + * or time from being mistaken for one. + */ + private val CAL_TIMEZONE = RegexTerminal( + """[A-Za-z][A-Za-z0-9_+\-]*(?:/[A-Za-z0-9_+\-]+)*""", + """[A-Za-z][A-Za-z0-9_+\-]*(?:/[A-Za-z0-9_+\-]+)*""" + ) + private val CAL_OPTIONAL_TIMEZONE = ZeroOrOne(SequenceCombinator(CAL_SPACES, CAL_TIMEZONE)) + + /** A complete calendar event expression, ending at [EOF]. */ + val CALENDAR_SPEC = AlternativeCombinator( + SequenceCombinator(CALENDAR_KEYWORD, CAL_OPTIONAL_TIMEZONE, EOF()), + SequenceCombinator(CAL_BODY, CAL_OPTIONAL_TIMEZONE, EOF()), + ) + + + // --------------------------------------------------------------------------------------------------- + // Numbers as the safe_ato* family reads them (src/basic/parse-util.c): leading whitespace is skipped, + // mangle_base() turns a `0b`/`0o` prefix into base 2/8, and otherwise strtoul()/strtoll() run with base + // 0, so `0x` is hexadecimal and a leading `0` is octal. The unsigned helpers accept a leading `+` and + // refuse `-` (even `-0`); the signed ones accept both signs. `0b`/`0o` are only recognised unsigned. + + /** + * An integer spelled any way the safe_atou and safe_atoi helpers accept, range-checked on the value it denotes. + * + * Unlike [UnsignedNumberTerminal] this also accepts `0b…`, `0o…` and a leading `+`, and with + * [signed] a leading `-`. + */ + class SafeAtoNumberTerminal( + private val minInclusive: BigInteger, + private val maxInclusive: BigInteger, + private val signed: Boolean, + ) : TerminalCombinator { + + constructor(minInclusive: Long, maxInclusive: Long, signed: Boolean) : + this(BigInteger.valueOf(minInclusive), BigInteger.valueOf(maxInclusive), signed) + + private fun span(value: String, offset: Int): String? = SHAPE.matchAt(value, offset)?.value + + private fun valueOf(text: String): BigInteger? { + var s = text + if (s.length > 2 && (s.startsWith("0b") || s.startsWith("0B"))) return s.drop(2).toBigIntegerOrNull(2) + if (s.length > 2 && (s.startsWith("0o") || s.startsWith("0O"))) return s.drop(2).toBigIntegerOrNull(8) + var negative = false + if (s.startsWith("+") || s.startsWith("-")) { + negative = s[0] == '-' + s = s.drop(1) + } + val magnitude = when { + s.length > 2 && (s.startsWith("0x") || s.startsWith("0X")) -> s.drop(2).toBigIntegerOrNull(16) + s.length > 1 && s.startsWith("0") -> s.drop(1).toBigIntegerOrNull(8) + else -> s.toBigIntegerOrNull(10) + } ?: return null + if (negative && !signed) return null // safe_atou* refuse any '-', even "-0" + return if (negative) magnitude.negate() else magnitude } - val magnitude = when { - s.length > 2 && (s.startsWith("0x") || s.startsWith("0X")) -> s.drop(2).toBigIntegerOrNull(16) - s.length > 1 && s.startsWith("0") -> s.drop(1).toBigIntegerOrNull(8) - else -> s.toBigIntegerOrNull(10) - } ?: return null - if (negative && !signed) return null // safe_atou* refuse any '-', even "-0" - return if (negative) magnitude.negate() else magnitude - } - private fun inRange(text: String): Boolean = valueOf(text)?.let { it >= minInclusive && it <= maxInclusive } ?: false + private fun inRange(text: String): Boolean = valueOf(text)?.let { it >= minInclusive && it <= maxInclusive } ?: false - override fun SyntacticMatch(value: String, offset: Int): MatchResult { - val text = span(value, offset) ?: return NoMatch - return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) - } + override fun SyntacticMatch(value: String, offset: Int): MatchResult { + val text = span(value, offset) ?: return NoMatch + return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) + } - override fun SemanticMatch(value: String, offset: Int): MatchResult { - val text = span(value, offset) ?: return NoMatch - if (!inRange(text)) return NoMatch.copy(longestMatch = offset) - return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) - } + override fun SemanticMatch(value: String, offset: Int): MatchResult { + val text = span(value, offset) ?: return NoMatch + if (!inRange(text)) return NoMatch.copy(longestMatch = offset) + return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) + } - override fun parse(value: String, offset: Int): Sequence { - val text = span(value, offset) ?: return sequenceOf(Stuck(offset, setOf(this))) - return sequenceOf(Parse(offset + text.length, listOf(ParsedToken(offset, offset + text.length, text, this, inRange(text))))) - } + override fun parse(value: String, offset: Int): Sequence { + val text = span(value, offset) ?: return sequenceOf(Stuck(offset, setOf(this))) + return sequenceOf(Parse(offset + text.length, listOf(ParsedToken(offset, offset + text.length, text, this, inRange(text))))) + } - override fun toString(): String = "SafeAto($minInclusive,$maxInclusive${if (signed) ",signed" else ""})" + override fun toString(): String = "SafeAto($minInclusive,$maxInclusive${if (signed) ",signed" else ""})" - private companion object { - // Prefixed forms first so "0x10" isn't read as "0". Over-long digit runs (e.g. "08", an invalid - // octal) are still matched whole so the bad token can be highlighted. - val SHAPE = Regex("""0[bB][0-9]+|0[oO][0-9]+|[+-]?(?:0[xX][0-9a-fA-F]+|[0-9]+)""") + private companion object { + // Prefixed forms first so "0x10" isn't read as "0". Over-long digit runs (e.g. "08", an invalid + // octal) are still matched whole so the bad token can be highlighted. + val SHAPE = Regex("""0[bB][0-9]+|0[oO][0-9]+|[+-]?(?:0[xX][0-9a-fA-F]+|[0-9]+)""") + } } -} -/** An unsigned number in `[minInclusive, maxInclusive]` as safe_atou*() reads it. */ -fun safeAtou(minInclusive: Long, maxInclusive: Long): Combinator = SafeAtoNumberTerminal(minInclusive, maxInclusive, signed = false) + /** An unsigned number in `[minInclusive, maxInclusive]` as safe_atou*() reads it. */ + fun safeAtou(minInclusive: Long, maxInclusive: Long): Combinator = SafeAtoNumberTerminal(minInclusive, maxInclusive, signed = false) -// --------------------------------------------------------------------------------------------------- -// Byte sizes — parse_size(…, 1024) (src/basic/parse-util.c). -// -// A run of `number[.fraction] [suffix]` elements, whitespace allowed before each element and between a -// number and its suffix. The number is read by strtoull(…, 10), so a leading `+` is fine and a `-` is -// ERANGE; the fraction may be empty (`10.M` is accepted). Suffixes are B, K, M, G, T, P, E (upper case -// only) or none, and an element without a suffix can only come last. systemd also insists suffixes -// appear in strictly descending order (`1G 512M`, not `512M 1G`) and that the sum fits — neither is -// modelled. + // --------------------------------------------------------------------------------------------------- + // Byte sizes — parse_size(…, 1024) (src/basic/parse-util.c). + // + // A run of `number[.fraction] [suffix]` elements, whitespace allowed before each element and between a + // number and its suffix. The number is read by strtoull(…, 10), so a leading `+` is fine and a `-` is + // ERANGE; the fraction may be empty (`10.M` is accepted). Suffixes are B, K, M, G, T, P, E (upper case + // only) or none, and an element without a suffix can only come last. systemd also insists suffixes + // appear in strictly descending order (`1G 512M`, not `512M 1G`) and that the sum fits — neither is + // modelled. -private val SIZE_NUMBER = RegexTerminal("""\+?[0-9]+(?:\.[0-9]*)?""", """\+?[0-9]+(?:\.[0-9]*)?""") -private val SIZE_SUFFIX = LiteralChoiceTerminal("B", "K", "M", "G", "T", "P", "E") + private val SIZE_NUMBER = RegexTerminal("""\+?[0-9]+(?:\.[0-9]*)?""", """\+?[0-9]+(?:\.[0-9]*)?""") + private val SIZE_SUFFIX = LiteralChoiceTerminal("B", "K", "M", "G", "T", "P", "E") -/** A complete parse_size(…, 1024) value, ending at [EOF]. */ -val IEC_SIZE = SequenceCombinator( - TS_OPTIONAL_WS, - ZeroOrMore(SequenceCombinator(SIZE_NUMBER, TS_OPTIONAL_WS, SIZE_SUFFIX, TS_OPTIONAL_WS)), - ZeroOrOne(SequenceCombinator(SIZE_NUMBER, TS_OPTIONAL_WS)), - EOF(), -) + /** A complete parse_size(…, 1024) value, ending at [EOF]. */ + val IEC_SIZE = SequenceCombinator( + TS_OPTIONAL_WS, + ZeroOrMore(SequenceCombinator(SIZE_NUMBER, TS_OPTIONAL_WS, SIZE_SUFFIX, TS_OPTIONAL_WS)), + ZeroOrOne(SequenceCombinator(SIZE_NUMBER, TS_OPTIONAL_WS)), + EOF(), + ) -// --------------------------------------------------------------------------------------------------- -// Paths as path_simplify_and_warn() (src/shared/parse-helpers.c) checks them after specifier expansion: -// PATH_CHECK_ABSOLUTE, then path_simplify() (which drops `.` components and duplicate slashes), then -// path_is_normalized() (which refuses any `..` component), and with PATH_CHECK_NON_API_VFS also -// path_below_api_vfs() (refuses /dev, /sys, /proc and anything below them). + // --------------------------------------------------------------------------------------------------- + // Paths as path_simplify_and_warn() (src/shared/parse-helpers.c) checks them after specifier expansion: + // PATH_CHECK_ABSOLUTE, then path_simplify() (which drops `.` components and duplicate slashes), then + // path_is_normalized() (which refuses any `..` component), and with PATH_CHECK_NON_API_VFS also + // path_below_api_vfs() (refuses /dev, /sys, /proc and anything below them). -// Rejects a `..` component anywhere: the optional `(?:[\s\S]*/)` swallows any leading directories. -private const val NO_DOT_DOT_COMPONENT = """(?!(?:[\s\S]*/)?\.\.(?:/|$))""" + // Rejects a `..` component anywhere: the optional `(?:[\s\S]*/)` swallows any leading directories. + const val NO_DOT_DOT_COMPONENT = """(?!(?:[\s\S]*/)?\.\.(?:/|$))""" -// path_startswith() is component-aware: /dev and /dev/x are API VFS, /devices is not. -private const val NOT_API_VFS = """(?!/+(?:dev|sys|proc)(?:/|$))""" + // path_startswith() is component-aware: /dev and /dev/x are API VFS, /devices is not. + const val NOT_API_VFS = """(?!/+(?:dev|sys|proc)(?:/|$))""" -private const val SPECIFIER_OR_SLASH = """(?:/|%[$UNIT_SPECIFIER_LETTERS])""" + private const val SPECIFIER_OR_SLASH = """(?:/|%[$UNIT_SPECIFIER_LETTERS])""" -/** An absolute, normalized path after unit_path_printf(); may begin with a specifier such as `%t`. */ -val UNIT_ABSOLUTE_PATH = RegexTerminal("""[\s\S]+""", """$NO_DOT_DOT_COMPONENT$SPECIFIER_OR_SLASH$UNIT_TEXT_CHAR*""") + /** An absolute, normalized path after unit_path_printf(); may begin with a specifier such as `%t`. */ + val UNIT_ABSOLUTE_PATH = RegexTerminal("""[\s\S]+""", """$NO_DOT_DOT_COMPONENT$SPECIFIER_OR_SLASH$UNIT_TEXT_CHAR*$""") -/** As [UNIT_ABSOLUTE_PATH], but also not at or below /dev, /sys or /proc (PATH_CHECK_NON_API_VFS). */ -val UNIT_ABSOLUTE_NON_API_VFS_PATH = RegexTerminal( - """[\s\S]+""", - """$NO_DOT_DOT_COMPONENT$NOT_API_VFS$SPECIFIER_OR_SLASH$UNIT_TEXT_CHAR*""" -) + /** As [UNIT_ABSOLUTE_PATH], but also not at or below /dev, /sys or /proc (PATH_CHECK_NON_API_VFS). */ + val UNIT_ABSOLUTE_NON_API_VFS_PATH = RegexTerminal( + """[\s\S]+""", + """$NO_DOT_DOT_COMPONENT$NOT_API_VFS$SPECIFIER_OR_SLASH$UNIT_TEXT_CHAR*$""" + ) -/** An absolute, normalized path taken literally (no specifier expansion) — config_parse_path. */ -val LITERAL_ABSOLUTE_PATH = RegexTerminal("""[\s\S]+""", """$NO_DOT_DOT_COMPONENT/[\s\S]*""") + /** An absolute, normalized path taken literally (no specifier expansion) — config_parse_path. */ + val LITERAL_ABSOLUTE_PATH = RegexTerminal("""[\s\S]+""", """$NO_DOT_DOT_COMPONENT/[\s\S]*""") +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBusNameOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBusNameOptionValueTest.kt new file mode 100644 index 00000000..e1f0fd1c --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBusNameOptionValueTest.kt @@ -0,0 +1,53 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +class ConfigParseBusNameOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // Well-known names, a unique name (accepted by sd_bus_service_name_is_valid), and a specifier. + // language="unit file (systemd)" + val file = """ + [Service] + BusName=org.freedesktop.systemd1 + BusName=org.freedesktop.NetworkManager + BusName=com.example.my-service_2 + BusName=a.b + BusName=_foo.-bar + BusName=:1.42 + BusName=org.example.%i + BusName=%N.service + """.trimIndent() + + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // language="unit file (systemd)" + val file = """ + [Service] + BusName=org + BusName=org. + BusName=.org.freedesktop + BusName=org..freedesktop + BusName=org.1freedesktop + BusName=org.free desktop + BusName=org.freedesktop/foo + BusName=org.example.%e + """.trimIndent() + + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(8, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseIecSizeLongOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseIecSizeLongOptionValueTest.kt new file mode 100644 index 00000000..2e1e04d1 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseIecSizeLongOptionValueTest.kt @@ -0,0 +1,61 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +class ConfigParseIecSizeLongOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // parse_size(…, 1024) spellings from TEST(parse_size) (src/test/test-parse-util.c). + // language="unit file (systemd)" + val file = """ + [Socket] + MessageQueueMessageSize=8192 + MessageQueueMessageSize=111.4 + MessageQueueMessageSize=112 B + MessageQueueMessageSize=112.6 B + MessageQueueMessageSize=3.5 K + MessageQueueMessageSize=3. K + MessageQueueMessageSize=3.0 K + MessageQueueMessageSize=4 M 11.5K + MessageQueueMessageSize=3.5G3B + MessageQueueMessageSize=3.5G 4B + MessageQueueMessageSize=4T3G3B + MessageQueueMessageSize=4 T 3 G 3 B + MessageQueueMessageSize=12P + MessageQueueMessageSize=3E 2P + MessageQueueMessageSize=+8K + MessageQueueMessageSize=1K5 + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // language="unit file (systemd)" + val file = """ + [Socket] + MessageQueueMessageSize=3. 0 K + MessageQueueMessageSize=12X + MessageQueueMessageSize=12.5X + MessageQueueMessageSize=12.5e3 + MessageQueueMessageSize=-1 + MessageQueueMessageSize=-10B 20K + MessageQueueMessageSize=8k + MessageQueueMessageSize=8 KiB + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(8, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseJobTimeoutSecOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseJobTimeoutSecOptionValueTest.kt new file mode 100644 index 00000000..1f19efa3 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseJobTimeoutSecOptionValueTest.kt @@ -0,0 +1,49 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +class ConfigParseJobTimeoutSecOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // parse_sec_fix_0: any parse_sec time span, 0 meaning infinity. No specifier expansion. + // language="unit file (systemd)" + val file = """ + [Unit] + JobTimeoutSec=infinity + JobTimeoutSec=0 + JobTimeoutSec=90 + JobTimeoutSec=5min 20s + JobTimeoutSec=1h30min + JobTimeoutSec=.5s + JobTimeoutSec=+10 + """.trimIndent() + + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // language="unit file (systemd)" + val file = """ + [Unit] + JobTimeoutSec=-1 + JobTimeoutSec=5mins + JobTimeoutSec=forever + JobTimeoutSec=%i + JobTimeoutSec=5. + """.trimIndent() + + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(5, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLongOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLongOptionValueTest.kt new file mode 100644 index 00000000..102890f5 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLongOptionValueTest.kt @@ -0,0 +1,55 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +class ConfigParseLongOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // safe_atoli: signed long, base 0 plus the 0b/0o prefixes mangle_base adds. + // language="unit file (systemd)" + val file = """ + [Socket] + MessageQueueMaxMessages=10 + MessageQueueMaxMessages=0 + MessageQueueMaxMessages=-5 + MessageQueueMaxMessages=+5 + MessageQueueMaxMessages=0x10 + MessageQueueMaxMessages=-0x10 + MessageQueueMaxMessages=010 + MessageQueueMaxMessages=0b101 + MessageQueueMaxMessages=0o17 + MessageQueueMaxMessages=9223372036854775807 + MessageQueueMaxMessages=-9223372036854775808 + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // language="unit file (systemd)" + val file = """ + [Socket] + MessageQueueMaxMessages=ten + MessageQueueMaxMessages=08 + MessageQueueMaxMessages=0x + MessageQueueMaxMessages=9223372036854775808 + MessageQueueMaxMessages=-0b1 + MessageQueueMaxMessages=10K + MessageQueueMaxMessages=1.5 + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(7, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseObsoleteUnitDepsOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseObsoleteUnitDepsOptionValueTest.kt new file mode 100644 index 00000000..f0346af7 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseObsoleteUnitDepsOptionValueTest.kt @@ -0,0 +1,46 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +/** + * RequiresOverridable=/RequisiteOverridable= (config_parse_obsolete_unit_deps) forward to + * config_parse_unit_deps, so they share the unit-dependency validator with Requires=/Requisite=. + */ +class ConfigParseObsoleteUnitDepsOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // language="unit file (systemd)" + val file = """ + [Unit] + RequiresOverridable=foo.service + RequiresOverridable=foo.service bar.socket + RequisiteOverridable=network-online.target + RequisiteOverridable=getty@tty1.service + """.trimIndent() + + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // language="unit file (systemd)" + val file = """ + [Unit] + RequiresOverridable=foo + RequisiteOverridable=foo.notatype + """.trimIndent() + + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(2, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOpenFileOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOpenFileOptionValueTest.kt new file mode 100644 index 00000000..f3b0d844 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOpenFileOptionValueTest.kt @@ -0,0 +1,59 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +class ConfigParseOpenFileOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // From TEST(open_file_parse) (src/test/test-open-file.c) plus escape and comma edge cases. + // language="unit file (systemd)" + val file = """ + [Service] + OpenFile=/proc/1/ns/mnt:host-mount-namespace:read-only + OpenFile=/proc/1/ns/mnt + OpenFile=/proc/1/ns/mnt:host-mount-namespace + OpenFile=/proc/1/ns/mnt::read-only + OpenFile=/proc/1/ns/mnt:host-mount-namespace:append + OpenFile=/proc/1/ns/mnt:host-mount-namespace:truncate + OpenFile=/proc/1/ns/mnt:host-mount-namespace:graceful + OpenFile=/proc/1/ns/mnt:host-mount-namespace:read-only,graceful + OpenFile=/run/a\x3ab:name + OpenFile=/run/with space/file:my name + OpenFile=/run/x: + OpenFile=/run/x:: + OpenFile=/run/x::,read-only,,graceful, + OpenFile=/run/%i + OpenFile=/run/../x + OpenFile= + """.trimIndent() + + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // language="unit file (systemd)" + val file = """ + [Service] + OpenFile=../file.dat:file:read-only + OpenFile=/proc/1/ns/mnt:host-mount-namespace:rw + OpenFile=/proc/1/ns/mnt:host-mount-namespace:read-only:other + OpenFile=relative + OpenFile=/run/x:name:read-only append + OpenFile=/run/x\q:name + """.trimIndent() + + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(6, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePathOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePathOptionValueTest.kt new file mode 100644 index 00000000..ecfe59e8 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePathOptionValueTest.kt @@ -0,0 +1,65 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +class ConfigParsePathOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // config_parse_path: literal absolute path, no '..' component, no API VFS restriction. + // language="unit file (systemd)" + val file = """ + [Exec] + WorkingDirectory=/ + WorkingDirectory=/root + WorkingDirectory=/home/user/work dir + WorkingDirectory=/srv//data/./x + WorkingDirectory=/..foo/bar.. + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // language="unit file (systemd)" + val file = """ + [Exec] + WorkingDirectory=root + WorkingDirectory=/srv/../etc + WorkingDirectory=.. + WorkingDirectory=%h/work + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(4, highlights) + } + + @Test + fun testNamespacePath() { + // The [Network] key shares config_parse_path; /proc is fine here (no PATH_CHECK_NON_API_VFS). + // language="unit file (systemd)" + val file = """ + [Network] + NamespacePath=/run/netns/foo + NamespacePath=/proc/1/ns/net + NamespacePath=netns/foo + NamespacePath=/run/netns/../foo + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(2, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePidFileOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePidFileOptionValueTest.kt new file mode 100644 index 00000000..148f9628 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePidFileOptionValueTest.kt @@ -0,0 +1,55 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +class ConfigParsePidFileOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // Absolute and relative paths (relative ones are placed under /run), specifiers, and /var/run (rewritten, not refused). + // language="unit file (systemd)" + val file = """ + [Service] + PIDFile=/run/foo.pid + PIDFile=foo.pid + PIDFile=nginx/nginx.pid + PIDFile=./foo.pid + PIDFile=/var/run/foo.pid + PIDFile=%t/foo.pid + PIDFile=/run/%i.pid + PIDFile=/devices/foo.pid + PIDFile= + """.trimIndent() + + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + // PidFileOptionWarning adds an informational note on every PIDFile= line; only value problems count here. + val highlights = myFixture.doHighlighting().filter { it.description?.startsWith("PID files should be avoided") != true } + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // language="unit file (systemd)" + val file = """ + [Service] + PIDFile=../foo.pid + PIDFile=/run/../foo.pid + PIDFile=/run/foo/.. + PIDFile=/dev/foo.pid + PIDFile=/proc/self/foo.pid + PIDFile=/sys + PIDFile=/run/%e.pid + """.trimIndent() + + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + // PidFileOptionWarning adds an informational note on every PIDFile= line; only value problems count here. + val highlights = myFixture.doHighlighting().filter { it.description?.startsWith("PID files should be avoided") != true } + + assertSize(7, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseServiceRefreshOnReloadOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseServiceRefreshOnReloadOptionValueTest.kt new file mode 100644 index 00000000..6853becb --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseServiceRefreshOnReloadOptionValueTest.kt @@ -0,0 +1,53 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import org.junit.Test + +class ConfigParseServiceRefreshOnReloadOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // Booleans, resource lists, the ~ inversion (a bare ~ is an empty inverted list), and an empty reset. + // language="unit file (systemd)" + val file = """ + [Service] + RefreshOnReload=yes + RefreshOnReload=false + RefreshOnReload=1 + RefreshOnReload=extensions + RefreshOnReload=credentials + RefreshOnReload=extensions credentials + RefreshOnReload=~credentials + RefreshOnReload=~ extensions + RefreshOnReload=~ + RefreshOnReload= + """.trimIndent() + + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // language="unit file (systemd)" + val file = """ + [Service] + RefreshOnReload=~yes + RefreshOnReload=Extensions + RefreshOnReload=extensions,credentials + RefreshOnReload=~~extensions + RefreshOnReload=extensions ~credentials + RefreshOnReload=all + """.trimIndent() + + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(6, highlights) + } +} From e8dc7bc5a6fb6dab53593fe2dae84c609f7a0100 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Fri, 23 Oct 2026 18:33:58 -0700 Subject: [PATCH 4/8] docs: point timer validator headers at systemd.time sections without guessed anchors Co-Authored-By: Claude Opus 5.5 (1M context) --- .../optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt | 2 +- .../optionvalues/ai/ConfigParseTimerMonotonicOptionValue.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt index 100d5502..9da9712b 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt @@ -8,7 +8,7 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * [Timer] OnCalendar= * * man https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html#OnCalendar= - * man https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html#Calendar%20Events + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html (section "Calendar Events") * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L2043-L2114 config_parse_timer (TIMER_CALENDAR) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit-printf.c#L179 unit_full_printf_full (specifiers expanded first) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L870-L1103 calendar_spec_from_string_full diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerMonotonicOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerMonotonicOptionValue.kt index 47ce6b38..3eff2f67 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerMonotonicOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerMonotonicOptionValue.kt @@ -8,7 +8,7 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * [Timer] OnActiveSec= / OnBootSec= / OnStartupSec= / OnUnitActiveSec= / OnUnitInactiveSec= * * man https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html#OnActiveSec= - * man https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html#Parsing%20Time%20Spans + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html (section "Parsing Time Spans") * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L2043-L2114 config_parse_timer (TIMER_ACTIVE, TIMER_BOOT, TIMER_STARTUP, TIMER_UNIT_ACTIVE, TIMER_UNIT_INACTIVE) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit-printf.c#L179 unit_full_printf_full (specifiers expanded first) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1198-L1287 parse_time (via parse_sec, default unit seconds) From 48fea7269d5ade9934e50f04f5e7a2b8d4dba852 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Fri, 23 Oct 2026 19:01:27 -0700 Subject: [PATCH 5/8] fix: address review findings for unit-core validators (unit-core batch) - RefreshOnReload=: accept booleans in any case (parse_boolean uses STRCASE_IN_SET) via a batch-local case-insensitive boolean terminal. - OnCalendar=: only accept a plausible timezone in the zone position (UTC any case, known zone IDs, tzdata Area/ names, host-style abbreviations that are not weekdays/keywords, numeric offsets), and require exactly one space before it except after a bare weekday list or date. Accept "@+5" / "@ 5" (strtoul). Spell the optional weekday prefix as separate branches to avoid a classic-engine crash. - safe_atou numbers: accept "-0" (ERANGE only for non-zero values). - OpenFile=: accept one trailing ':' after the options field. - Listen*= AF_UNIX: allow 4 extra characters for /var/run/ paths, which patch_var_run() shortens before the length check. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ai/ConfigParseOpenFileOptionValue.kt | 6 +- ...gParseServiceRefreshOnReloadOptionValue.kt | 11 +- .../ai/ConfigParseSocketListenOptionValues.kt | 7 +- .../ai/ConfigParseTimerCalendarOptionValue.kt | 8 +- .../grammar/BatchUnitCoreCombinators.kt | 149 ++++++++++++++---- .../ai/ConfigParseOpenFileOptionValueTest.kt | 5 +- ...seServiceRefreshOnReloadOptionValueTest.kt | 6 +- ...gParseSocketListenSocketOptionValueTest.kt | 12 +- ...ConfigParseTimerCalendarOptionValueTest.kt | 37 ++++- 9 files changed, 200 insertions(+), 41 deletions(-) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt index 87b8a9db..dfd79d9e 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt @@ -55,7 +55,7 @@ private val OPEN_FILE_OPTIONS = SequenceCombinator( * Validator for `[Service] OpenFile=` (config_parse_open_file, the only key using it). * * `PATH[:FD-NAME[:OPTIONS]]`, split on the first two colons with C escapes decoded (so a literal colon - * in the path is written `\x3a`) and nothing allowed after a third field: + * in the path is written `\x3a`) and nothing allowed after a third field except one trailing `:`: * * - PATH must be absolute; * - FD-NAME, if given, is printable ASCII without `:` (at most 255 characters); an empty one defaults @@ -76,7 +76,9 @@ class ConfigParseOpenFileOptionValue : SimpleGrammarOptionValues( SequenceCombinator( COLON, ZeroOrOne(OPEN_FILE_FDNAME), - ZeroOrOne(SequenceCombinator(COLON, OPEN_FILE_OPTIONS)), + // The third extract (EXTRACT_DONT_COALESCE_SEPARATORS) consumes the colon that ends it, so one + // trailing `:` after the options leaves nothing behind and passes the isempty(v) check. + ZeroOrOne(SequenceCombinator(COLON, OPEN_FILE_OPTIONS, ZeroOrOne(COLON))), ) ), EOF(), diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseServiceRefreshOnReloadOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseServiceRefreshOnReloadOptionValue.kt index 4d1903ab..3ed68a91 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseServiceRefreshOnReloadOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseServiceRefreshOnReloadOptionValue.kt @@ -2,7 +2,7 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.AlternativeCombinator -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BOOLEAN +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.BOOLEAN_ANY_CASE import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.FlexibleLiteralChoiceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal @@ -16,6 +16,7 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * * man https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#RefreshOnReload= * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L4928-L4975 config_parse_service_refresh_on_reload (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L23-L46 parse_boolean (STRCASE_IN_SET: any case) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/service.c#L6630-L6657 service_refresh_on_reload_from_string_many * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/service.c#L6615 service_refresh_on_reload_table ("extensions", "credentials"; streq, so case-sensitive) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L10 extract_first_word (whitespace-separated, flags 0) @@ -28,17 +29,21 @@ private val OPTIONAL_WHITESPACE = ZeroOrOne(WhitespaceTerminal()) * Validator for `[Service] RefreshOnReload=` (config_parse_service_refresh_on_reload, the only key using * it). * - * Either a boolean (parse_boolean is tried first), or a whitespace-separated list of `extensions` and + * Either a boolean in any case (parse_boolean is tried first and is case-insensitive, so `Yes` and + * `TRUE` are fine), or a whitespace-separated list of `extensions` and * `credentials`, optionally prefixed by a single `~` to invert it. A bare `~` is accepted by systemd * (an empty inverted list, i.e. the default) and so is accepted here. A boolean cannot be inverted * (`~yes` is an unknown resource name). * + * Not modelled: extract_first_word() (flags 0) still decodes a backslash escape, so the obfuscated + * spelling `ext\ensions` is accepted by systemd but flagged here. + * * An empty assignment resets to the default and is handled before this grammar is consulted. */ class ConfigParseServiceRefreshOnReloadOptionValue : SimpleGrammarOptionValues( "config_parse_service_refresh_on_reload", AlternativeCombinator( - SequenceCombinator(BOOLEAN, EOF()), + SequenceCombinator(BOOLEAN_ANY_CASE, EOF()), SequenceCombinator( ZeroOrOne(LiteralChoiceTerminal("~")), OPTIONAL_WHITESPACE, diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt index a82f349b..caf7944b 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt @@ -36,6 +36,7 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L33-L105 specifier_printf (unknown %X is an error, %% is a literal %) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L34 path_simplify_and_warn (file ltypes: absolute, no "..", not below /dev /sys /proc unless SPECIAL) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/mountpoint-util.c#L721 path_below_api_vfs + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L574-L601 patch_var_run (/var/run/ -> /run/ before the length check) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/socket-netlink.c#L24-L96 socket_address_parse * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L1668 socket_address_parse_unix * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L1352 sockaddr_un_set_path (2..107 bytes) @@ -63,9 +64,13 @@ private val PERCENT_START_SPECIFIER = """%[$UNIT_SPECIFIER_LETTERS]""" // AF_UNIX: '/' (file system) or '@' (abstract namespace), then anything. sockaddr_un_set_path insists on // 2..107 bytes including the leading character. The length is only checked when no specifier is present, // because a specifier's expansion length is unknown; a specifier may also stand at the start (%t/foo). +// config_parse_socket_listen runs patch_var_run() before parsing, which rewrites a /var/run/ prefix +// (path_startswith, so repeated slashes count as one) to /run/; the length limit applies to the result, +// so after /var/run/ up to 102 characters may follow. Length is counted in UTF-16 units, not bytes, so +// non-ASCII paths are checked leniently. private val UNIX_SOCKET_ADDRESS = RegexTerminal( """[/@%][\s\S]*""", - """(?:(?=[^%]*$)[/@][\s\S]{1,106}|(?=[\s\S]*%)(?:[/@]|$PERCENT_START_SPECIFIER)$UNIT_TEXT_CHAR*)$""" + """(?:(?=[^%]*$)(?:/+var/+run/+[\s\S]{0,102}|[/@][\s\S]{1,106})|(?=[\s\S]*%)(?:[/@]|$PERCENT_START_SPECIFIER)$UNIT_TEXT_CHAR*)$""" ) private val IP_PORT = safeAtou(1, 65535) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt index 9da9712b..5175d980 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt @@ -1,6 +1,7 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.CALENDAR_SPEC import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.withUnitSpecifiers @@ -20,6 +21,8 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L125 fix_year * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L169 chain_valid * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L212 calendar_spec_valid + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L887-L933 timezone suffix: " UTC" (any case), tzname[0..1], or last word if timezone_is_valid + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/calendarspec.c#L480-L498 parse_one_number (@EPOCH: bare strtoul, so "@ +5" is fine) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1596 verify_timezone * tests https://github.com/systemd/systemd/blob/0f0682b773/src/test/test-calendarspec.c#L117 calendar_spec_one (accepted spellings) * tests https://github.com/systemd/systemd/blob/0f0682b773/src/test/test-calendarspec.c#L231 calendar_spec_from_string (rejected spellings) @@ -33,7 +36,10 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * * [WEEKDAYS] [[YEAR-]MONTH-DAY | [YEAR-]MONTH~DAY | @EPOCH] [HOUR:MINUTE[:SECOND]] * - * optionally followed by a timezone (` UTC`, the local zone abbreviation or an IANA zone name). Each + * optionally followed by a timezone after exactly one space (` UTC`, the local zone abbreviation or an + * IANA zone name; extra spaces are only tolerated after a weekday list or a bare date, which skip + * them). A weekday or keyword in the timezone position (`*-*-* 12:00 Mon`, `Mon Fri`) is refused, as + * systemd refuses it; see [BatchUnitCoreGrammar.CalendarTimezoneTerminal] for what counts as a zone. Each * date/time field is `*` or a comma list of `value[..value][/repeat]`; seconds may carry fractions. * See [CALENDAR_SPEC] for exactly which per-field bounds are checked and which cross-field checks * (reversed ranges, overshooting repetitions, descending weekday ranges, zone existence) are left to diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt index a8a94311..8e7fc6f9 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt @@ -62,6 +62,17 @@ object BatchUnitCoreGrammar { AlternativeCombinator(grammar, SequenceCombinator(UNIT_SPECIFIED_VALUE, EOF())) + // --------------------------------------------------------------------------------------------------- + // Booleans. + + /** + * A boolean as parse_boolean() (src/basic/parse-util.c) really reads it: STRCASE_IN_SET, so `Yes`, + * `TRUE` and `oN` are all fine. The shared [BOOLEAN] in Combinators.kt is case-sensitive; this is the + * batch-local case-insensitive one (see RECOMMENDATIONS.md about promoting it). + */ + val BOOLEAN_ANY_CASE = FlexibleLiteralChoiceTerminal(*BOOLEAN_SPELLINGS, ignoreCase = true) + + // --------------------------------------------------------------------------------------------------- // Time spans — parse_time(…, USEC_PER_SEC) a.k.a. parse_sec (src/basic/time-util.c). // @@ -118,9 +129,8 @@ object BatchUnitCoreGrammar { // // What is deliberately left lenient: cross-field checks (reversed ranges like `12..1`, a repetition // that overshoots the field like `20/4` hours, descending weekday ranges like `Fri..Mon`), rounding - // of second fractions, and the timezone, which systemd only accepts if it names a file under - // /usr/share/zoneinfo or the host's local zone abbreviation — any word shaped like a zone name is - // accepted here. + // of second fractions, and whether a timezone actually exists on the machine (see + // [CalendarTimezoneTerminal] for what is accepted in its place). private val CAL_SPACES = RegexTerminal(" +", " +") private val CAL_STAR = LiteralChoiceTerminal("*") @@ -213,13 +223,14 @@ object BatchUnitCoreGrammar { ) // `@SECONDS` since the epoch; the resulting year must still pass the 1970..2199 check, i.e. the value - // must be below 2200-01-01T00:00:00Z. - private val CAL_EPOCH = SequenceCombinator(LiteralChoiceTerminal("@"), DecimalNumberTerminal(0L, 7_258_118_400L)) - - private val CAL_DATE_AND_TIME = AlternativeCombinator( - CAL_EPOCH, - SequenceCombinator(CAL_DATE, ZeroOrOne(SequenceCombinator(CAL_SPACES, CAL_TIME))), - CAL_TIME, + // must be below 2200-01-01T00:00:00Z. parse_one_number() is a bare strtoul(…, 10), so leading + // whitespace and a `+` are fine (`@ +5`). A `-` is wrapped modulo 2^64 by strtoul and lands before + // 1970, so is refused — except `@-0`, which is 0; that one spelling is not modelled. + private val CAL_EPOCH = SequenceCombinator( + LiteralChoiceTerminal("@"), + ZeroOrOne(WhitespaceTerminal()), + ZeroOrOne(LiteralChoiceTerminal("+")), + DecimalNumberTerminal(0L, 7_258_118_400L), ) /** parse_weekdays' table; startswith_no_case, so any casing. */ @@ -241,11 +252,6 @@ object BatchUnitCoreGrammar { ZeroOrOne(CAL_COMMA), ) - private val CAL_BODY = AlternativeCombinator( - SequenceCombinator(CAL_WEEKDAYS, ZeroOrOne(SequenceCombinator(CAL_SPACES, CAL_DATE_AND_TIME))), - CAL_DATE_AND_TIME, - ) - /** The shorthand keywords, matched with strcaseeq. `anually` is a kept-for-compatibility misspelling. */ val CALENDAR_KEYWORD = FlexibleLiteralChoiceTerminal( "minutely", "hourly", "daily", "monthly", "weekly", "yearly", "annually", "anually", "quarterly", @@ -254,21 +260,107 @@ object BatchUnitCoreGrammar { ) /** - * A timezone suffix: ` UTC` (any case), the host's local zone abbreviation, or an IANA zone name - * (verify_timezone's character set: letters, digits, `-_+/`, no empty components). Whether the zone - * actually exists depends on the machine, so only the shape is checked; a leading letter keeps a date - * or time from being mistaken for one. + * The timezone that may end a calendar event, as calendar_spec_from_string_full() recognises one. + * + * In C the suffix is tried three ways, each after exactly one space: ` UTC` in any case + * (endswith_no_case); the host's own zone abbreviations tzname[0]/tzname[1] in any case (`CET`, + * `CEST`, or numeric ones like `-03` for zones without a letter abbreviation); and otherwise the last + * space-separated word, if timezone_is_valid() finds a TZif file of that name under + * /usr/share/zoneinfo. Anything else is left in the expression, which then fails to parse. + * + * Which zones exist depends on the machine, so this accepts: `UTC` in any case; any zone ID the JDK + * knows (also under the `posix/` and `right/` trees); any `Area/Location` shape whose area is one of + * the tzdata regions (newer zones than the JDK's); a 2..6 letter word that is not a weekday or + * calendar keyword (a plausible host abbreviation); and `+NN`/`-NN`/`+NNNN`. What it refuses is the + * common mistake: a weekday or keyword written where systemd expects the date/time to have ended + * (`*-*-* 12:00 Mon`, `Mon Fri`), or a long word that is no zone (`everyday`). */ - private val CAL_TIMEZONE = RegexTerminal( - """[A-Za-z][A-Za-z0-9_+\-]*(?:/[A-Za-z0-9_+\-]+)*""", - """[A-Za-z][A-Za-z0-9_+\-]*(?:/[A-Za-z0-9_+\-]+)*""" + class CalendarTimezoneTerminal : TerminalCombinator { + + private fun span(value: String, offset: Int): String? = SHAPE.matchAt(value, offset)?.value + + override fun SyntacticMatch(value: String, offset: Int): MatchResult { + val text = span(value, offset) ?: return NoMatch + return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) + } + + override fun SemanticMatch(value: String, offset: Int): MatchResult { + val text = span(value, offset) ?: return NoMatch + if (!isPlausibleZone(text)) return NoMatch.copy(longestMatch = offset) + return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) + } + + override fun parse(value: String, offset: Int): Sequence { + val text = span(value, offset) ?: return sequenceOf(Stuck(offset, setOf(this))) + return sequenceOf(Parse(offset + text.length, listOf(ParsedToken(offset, offset + text.length, text, this, isPlausibleZone(text))))) + } + + override fun toString(): String = "CalendarTimezone" + + companion object { + // verify_timezone's character set; the leading character excludes digits so a time is never read as a zone. + private val SHAPE = Regex("""[A-Za-z+\-][A-Za-z0-9_+\-]*(?:/[A-Za-z0-9_+\-]+)*""") + + private val KNOWN_ZONE_IDS: Set by lazy { + // "Factory" is a tzdata zone file the JDK does not ship (checked against a host's /usr/share/zoneinfo). + java.util.TimeZone.getAvailableIDs().toSet() + java.time.ZoneId.getAvailableZoneIds() + "Factory" + } + + private val TZDATA_AREAS = setOf( + "Africa", "America", "Antarctica", "Arctic", "Asia", "Atlantic", "Australia", "Brazil", "Canada", + "Chile", "Etc", "Europe", "Indian", "Mexico", "Pacific", "US", + ) + + private val ABBREVIATION = Regex("[A-Za-z]{2,6}") + private val NUMERIC_ABBREVIATION = Regex("[+-][0-9]{2}(?:[0-9]{2})?") + + private val NOT_A_ZONE: Set = ( + listOf("monday", "mon", "tuesday", "tue", "wednesday", "wed", "thursday", "thu", "friday", "fri", + "saturday", "sat", "sunday", "sun") + + listOf("minutely", "hourly", "daily", "monthly", "weekly", "yearly", "annually", "anually", + "quarterly", "biannually", "semiannually") + ).toSet() + + fun isPlausibleZone(text: String): Boolean { + if (text.equals("UTC", ignoreCase = true)) return true + val bare = text.removePrefix("posix/").removePrefix("right/") + if (bare in KNOWN_ZONE_IDS) return true + if (bare.contains('/') && bare.substringBefore('/') in TZDATA_AREAS && !bare.endsWith("/")) return true + if (ABBREVIATION.matches(text)) return text.lowercase() !in NOT_A_ZONE + return NUMERIC_ABBREVIATION.matches(text) + } + } + } + + private val CAL_TIMEZONE = CalendarTimezoneTerminal() + + // The zone is split off at exactly one space (endswith " UTC", or the last space for a zone name), so + // any extra spaces stay in the expression. parse_weekdays() and parse_date() skip a run of trailing + // spaces, so after a weekday list or a bare date they are harmless; after a keyword, a time or an + // `@epoch` they are left over and the value is refused. + private val CAL_ONE_SPACE = LiteralChoiceTerminal(" ") + private val CAL_TIMEZONE_AFTER_ONE_SPACE = ZeroOrOne(SequenceCombinator(CAL_ONE_SPACE, CAL_TIMEZONE)) + private val CAL_TIMEZONE_AFTER_SPACES = ZeroOrOne(SequenceCombinator(CAL_SPACES, CAL_TIMEZONE)) + + // `[WEEKDAYS ]` before each body is spelled out as two branches rather than a ZeroOrOne prefix: the + // classic engine's ZeroOrOne re-applies its operand after a match to compute longestMatch, so on + // `Mon Fri` it would read `Fri` as a second weekday prefix, report the whole value as matched and send + // GrammarOptionValue's error localisation past its last token. + private fun withOptionalWeekdays(vararg body: Combinator): List = listOf( + SequenceCombinator(CAL_WEEKDAYS, CAL_SPACES, *body), + SequenceCombinator(*body), ) - private val CAL_OPTIONAL_TIMEZONE = ZeroOrOne(SequenceCombinator(CAL_SPACES, CAL_TIMEZONE)) - /** A complete calendar event expression, ending at [EOF]. */ + /** A complete calendar event expression, ending at [EOF]. Every branch is EOF-terminated. */ val CALENDAR_SPEC = AlternativeCombinator( - SequenceCombinator(CALENDAR_KEYWORD, CAL_OPTIONAL_TIMEZONE, EOF()), - SequenceCombinator(CAL_BODY, CAL_OPTIONAL_TIMEZONE, EOF()), + *( + listOf(SequenceCombinator(CALENDAR_KEYWORD, CAL_TIMEZONE_AFTER_ONE_SPACE, EOF())) + + withOptionalWeekdays(CAL_EPOCH, CAL_TIMEZONE_AFTER_ONE_SPACE, EOF()) + + withOptionalWeekdays(CAL_DATE, CAL_SPACES, CAL_TIME, CAL_TIMEZONE_AFTER_ONE_SPACE, EOF()) + + withOptionalWeekdays(CAL_DATE, CAL_TIMEZONE_AFTER_SPACES, EOF()) + + withOptionalWeekdays(CAL_TIME, CAL_TIMEZONE_AFTER_ONE_SPACE, EOF()) + + SequenceCombinator(CAL_WEEKDAYS, CAL_TIMEZONE_AFTER_SPACES, EOF()) + ).toTypedArray() ) @@ -276,7 +368,8 @@ object BatchUnitCoreGrammar { // Numbers as the safe_ato* family reads them (src/basic/parse-util.c): leading whitespace is skipped, // mangle_base() turns a `0b`/`0o` prefix into base 2/8, and otherwise strtoul()/strtoll() run with base // 0, so `0x` is hexadecimal and a leading `0` is octal. The unsigned helpers accept a leading `+` and - // refuse `-` (even `-0`); the signed ones accept both signs. `0b`/`0o` are only recognised unsigned. + // refuse `-` with ERANGE — but only when the parsed value is non-zero (`l != 0 && s[0] == '-'`), so + // `-0` and `-0x0` are a valid 0. The signed ones accept both signs. `0b`/`0o` are only recognised unsigned. /** * An integer spelled any way the safe_atou and safe_atoi helpers accept, range-checked on the value it denotes. @@ -309,7 +402,7 @@ object BatchUnitCoreGrammar { s.length > 1 && s.startsWith("0") -> s.drop(1).toBigIntegerOrNull(8) else -> s.toBigIntegerOrNull(10) } ?: return null - if (negative && !signed) return null // safe_atou* refuse any '-', even "-0" + if (negative && !signed && magnitude.signum() != 0) return null // safe_atou*: ERANGE unless the value is 0 return if (negative) magnitude.negate() else magnitude } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOpenFileOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOpenFileOptionValueTest.kt index f3b0d844..4020fd2f 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOpenFileOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOpenFileOptionValueTest.kt @@ -24,6 +24,8 @@ class ConfigParseOpenFileOptionValueTest : AbstractUnitFileTest() { OpenFile=/run/with space/file:my name OpenFile=/run/x: OpenFile=/run/x:: + OpenFile=/etc/foo:fd:read-only: + OpenFile=/run/x:fd:: OpenFile=/run/x::,read-only,,graceful, OpenFile=/run/%i OpenFile=/run/../x @@ -48,12 +50,13 @@ class ConfigParseOpenFileOptionValueTest : AbstractUnitFileTest() { OpenFile=relative OpenFile=/run/x:name:read-only append OpenFile=/run/x\q:name + OpenFile=/run/x:name:read-only:: """.trimIndent() setupFileInEditor("file.service", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(6, highlights) + assertSize(7, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseServiceRefreshOnReloadOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseServiceRefreshOnReloadOptionValueTest.kt index 6853becb..892c4228 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseServiceRefreshOnReloadOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseServiceRefreshOnReloadOptionValueTest.kt @@ -8,13 +8,17 @@ class ConfigParseServiceRefreshOnReloadOptionValueTest : AbstractUnitFileTest() @Test fun testValidValues() { - // Booleans, resource lists, the ~ inversion (a bare ~ is an empty inverted list), and an empty reset. + // Booleans (parse_boolean is case-insensitive), resource lists, the ~ inversion (a bare ~ is an empty inverted list), and an empty reset. // language="unit file (systemd)" val file = """ [Service] RefreshOnReload=yes RefreshOnReload=false RefreshOnReload=1 + RefreshOnReload=Yes + RefreshOnReload=TRUE + RefreshOnReload=oN + RefreshOnReload=OFF RefreshOnReload=extensions RefreshOnReload=credentials RefreshOnReload=extensions credentials diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt index 4d6ad7a5..8cd27c29 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt @@ -37,6 +37,7 @@ class ConfigParseSocketListenSocketOptionValueTest : AbstractUnitFileTest() { ListenStream=vsock:host:22 ListenStream=vsock:-1:22 ListenStream=vsock-stream:any:22 + ListenStream=vsock:-0:5 ListenDatagram=69 ListenDatagram=[::]:53 ListenDatagram=127.0.0.1:1153 @@ -91,7 +92,8 @@ class ConfigParseSocketListenSocketOptionValueTest : AbstractUnitFileTest() { @Test fun testUnixSocketPathLength() { - // sockaddr_un_set_path: 107 bytes is the longest path that fits with its NUL terminator. + // sockaddr_un_set_path: 107 bytes is the longest path that fits with its NUL terminator. The limit + // applies after patch_var_run() has rewritten /var/run/ to /run/, so /var/run/ paths get 4 more. val ok = "/" + "a".repeat(106) val tooLong = "/" + "a".repeat(107) val file = """ @@ -99,13 +101,15 @@ class ConfigParseSocketListenSocketOptionValueTest : AbstractUnitFileTest() { ListenStream=$ok ListenStream=@${"a".repeat(106)} ListenStream=$tooLong + ListenStream=/var/run/${"a".repeat(102)} + ListenStream=/var/run/${"a".repeat(103)} """.trimIndent() setupFileInEditor("file.socket", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(1, highlights) + assertSize(2, highlights) } @Test @@ -143,17 +147,19 @@ class ConfigParseSocketListenSocketOptionValueTest : AbstractUnitFileTest() { ListenNetlink=rdma 4 ListenNetlink=16 ListenNetlink=generic 0x10 + ListenNetlink=route -0 ListenNetlink=junk ListenNetlink=route ten ListenNetlink=route 1 2 ListenNetlink=/run/foo.sock + ListenNetlink=route -1 """.trimIndent() setupFileInEditor("file.socket", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(4, highlights) + assertSize(5, highlights) } @Test diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerCalendarOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerCalendarOptionValueTest.kt index 99f8ca12..dc75ae05 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerCalendarOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerCalendarOptionValueTest.kt @@ -123,6 +123,33 @@ class ConfigParseTimerCalendarOptionValueTest : AbstractUnitFileTest() { assertSize(0, highlights) } + @Test + fun testValidTimezoneAndEpochSpellings() { + // Extra spaces before the zone survive only after a weekday list or a bare date (both skip a run of + // spaces); @EPOCH is read by strtoul, so whitespace and '+' may follow the '@'. CET/-03 stand for the + // host's tzname abbreviations, posix/ and Etc/ zones are real files under /usr/share/zoneinfo. + // language="unit file (systemd)" + val file = """ + [Timer] + OnCalendar=Mon UTC + OnCalendar=2024-01-01 UTC + OnCalendar=Mon 2024-01-01 Europe/Berlin + OnCalendar=@+5 + OnCalendar=@ 5 + OnCalendar=daily CET + OnCalendar=12:00 -03 + OnCalendar=Mon 12:00 posix/Europe/Berlin + OnCalendar=daily Etc/GMT+5 + OnCalendar=Mon,Fri UTC + """.trimIndent() + + setupFileInEditor("file.timer", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + @Test fun testInvalidValues() { // Rejections from TEST(calendar_spec_from_string) whose cause is a shape or a single-field bound, @@ -155,12 +182,20 @@ class ConfigParseTimerCalendarOptionValueTest : AbstractUnitFileTest() { OnCalendar=*-13-01 OnCalendar=1969-01-01 OnCalendar=%e + OnCalendar=Mon Fri + OnCalendar=Mon, Tue + OnCalendar=*-*-* 12:00 Mon + OnCalendar=*-*-* 10:00 everyday + OnCalendar=*-*-* 10:00 daily + OnCalendar=daily UTC + OnCalendar=12:00 Europe/Berlin + OnCalendar=@5 UTC """.trimIndent() setupFileInEditor("file.timer", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(25, highlights) + assertSize(33, highlights) } } From 498577117eda858d95659297c535eec137934b10 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sat, 24 Oct 2026 06:38:21 -0700 Subject: [PATCH 6/8] fix: align unit-core validators with systemd's parsers OpenFile= now refuses a repeated flag and more than one of read-only/append/truncate, as open_file_parse/open_file_validate do (found by the systemd oracle check on the kf6 corpus value). The other oracle mismatches for this family are harness or host-timezone artifacts; the tests now record the sd-check evidence. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ai/ConfigParseOpenFileOptionValue.kt | 24 ++++++++++++------- .../ai/ConfigParseOpenFileOptionValueTest.kt | 13 +++++++++- ...gParseSocketListenSocketOptionValueTest.kt | 5 ++++ ...ConfigParseTimerCalendarOptionValueTest.kt | 5 ++++ 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt index dfd79d9e..0d5b7a7d 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt @@ -1,9 +1,9 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.AlternativeCombinator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.FlexibleLiteralChoiceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.OneOrMore import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.RegexTerminal @@ -16,7 +16,7 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * * man https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#OpenFile= * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L6743-L6777 config_parse_open_file (0) - * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/open-file.c#L12-L68 open_file_parse + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/open-file.c#L12-L68 open_file_parse (a repeated flag is EINVAL, #L48-L49) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/open-file.c#L70 open_file_validate (absolute path, valid fd name, at most one of read-only/append/truncate) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/open-file.c#L134 open_file_flags_table * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/fd-util.c#L554 fdname_is_valid (printable ASCII except ':', at most 255) @@ -35,7 +35,12 @@ private val OPEN_FILE_PATH = RegexTerminal("""(?:[^:\\]|\\[\s\S]?)+""", """(?:/| // The fd name: printable ASCII other than ':'. Empty means "use the file name". private val OPEN_FILE_FDNAME = RegexTerminal("""(?:[^:\\]|\\[\s\S]?)+""", """(?:[\x20-\x7E&&[^:\\]]|$C_ESCAPE){1,255}(?=:|$)""") -private val OPEN_FILE_FLAG = FlexibleLiteralChoiceTerminal("read-only", "append", "truncate", "graceful") +// open_file_parse refuses a flag given twice, and open_file_validate refuses more than one of +// read-only/append/truncate, so a valid list holds at most one access flag and at most one `graceful`. +// Exact literals rather than FlexibleLiteralChoiceTerminal: the two choices must not match each other's +// words syntactically, or the original (greedy, non-backtracking) engine commits to the wrong branch. +private val OPEN_FILE_ACCESS_FLAG = LiteralChoiceTerminal("read-only", "append", "truncate") +private val OPEN_FILE_GRACEFUL_FLAG = LiteralChoiceTerminal("graceful") private val COMMA = LiteralChoiceTerminal(",") // extract_first_word(…, ",", 0) coalesces separators, so stray commas anywhere are harmless (even a @@ -44,8 +49,10 @@ private val OPEN_FILE_OPTIONS = SequenceCombinator( ZeroOrMore(COMMA), ZeroOrOne( SequenceCombinator( - OPEN_FILE_FLAG, - ZeroOrMore(SequenceCombinator(OneOrMore(COMMA), OPEN_FILE_FLAG)), + AlternativeCombinator( + SequenceCombinator(OPEN_FILE_ACCESS_FLAG, ZeroOrOne(SequenceCombinator(OneOrMore(COMMA), OPEN_FILE_GRACEFUL_FLAG))), + SequenceCombinator(OPEN_FILE_GRACEFUL_FLAG, ZeroOrOne(SequenceCombinator(OneOrMore(COMMA), OPEN_FILE_ACCESS_FLAG))), + ), ZeroOrMore(COMMA), ) ), @@ -60,10 +67,11 @@ private val OPEN_FILE_OPTIONS = SequenceCombinator( * - PATH must be absolute; * - FD-NAME, if given, is printable ASCII without `:` (at most 255 characters); an empty one defaults * to the file name; - * - OPTIONS is a comma-separated list of `read-only`, `append`, `truncate`, `graceful`. + * - OPTIONS is a comma-separated list of at most one of `read-only`, `append`, `truncate` and at + * most one `graceful`, in either order (a repeated flag, or two of read-only/append/truncate, is + * refused by systemd). * - * Not checked: a flag given twice, or more than one of read-only/append/truncate (both refused by - * systemd), and a path such as `/` whose file name cannot serve as the default fd name. Unlike most + * Not checked: a path such as `/` whose file name cannot serve as the default fd name. Unlike most * settings this one performs no specifier expansion, so `%` is an ordinary character. * * An empty assignment resets the list and is handled before this grammar is consulted. diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOpenFileOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOpenFileOptionValueTest.kt index 4020fd2f..14244208 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOpenFileOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOpenFileOptionValueTest.kt @@ -27,6 +27,10 @@ class ConfigParseOpenFileOptionValueTest : AbstractUnitFileTest() { OpenFile=/etc/foo:fd:read-only: OpenFile=/run/x:fd:: OpenFile=/run/x::,read-only,,graceful, + OpenFile=/run/x:fd:graceful,truncate + OpenFile=/run/x:fd:truncate,graceful + OpenFile=/run/x:fd:graceful,read-only + OpenFile=/run/x:fd:,,, OpenFile=/run/%i OpenFile=/run/../x OpenFile= @@ -41,6 +45,8 @@ class ConfigParseOpenFileOptionValueTest : AbstractUnitFileTest() { @Test fun testInvalidValues() { + // The last five are refused by open_file_parse/open_file_validate (repeated flag, or more than one of + // read-only/append/truncate); confirmed with systemd's own parser at 0f0682b773 (sd-check). // language="unit file (systemd)" val file = """ [Service] @@ -51,12 +57,17 @@ class ConfigParseOpenFileOptionValueTest : AbstractUnitFileTest() { OpenFile=/run/x:name:read-only append OpenFile=/run/x\q:name OpenFile=/run/x:name:read-only:: + OpenFile=/run/x:fd:read-only,read-only + OpenFile=/run/x:fd:read-only,append + OpenFile=/run/x:fd:graceful,graceful + OpenFile=/run/x:fd:append,graceful,truncate + OpenFile=/some/path/:some-name:append,graceful,read-only,truncate """.trimIndent() setupFileInEditor("file.service", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(7, highlights) + assertSize(12, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt index 8cd27c29..b8e0bfa5 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt @@ -55,6 +55,8 @@ class ConfigParseSocketListenSocketOptionValueTest : AbstractUnitFileTest() { @Test fun testInvalidStreamAndDatagramAddresses() { + // `0` and `65536` are ports out of range for ListenStream= (they would be netlink family numbers, + // which only ListenNetlink= takes); all 23 confirmed rejected by sd-check at 0f0682b773. // language="unit file (systemd)" val file = """ [Socket] @@ -114,6 +116,9 @@ class ConfigParseSocketListenSocketOptionValueTest : AbstractUnitFileTest() { @Test fun testSequentialPacketIsUnixOnly() { + // The last three are refused by config_parse_socket_listen ("Address family not supported"), checked + // with sd-check at 0f0682b773. The inspection dispatches on the key; the key-agnostic union grammar + // (`combinator`) accepts them, which is why a harness that only reads `combinator` sees them as valid. // language="unit file (systemd)" val file = """ [Socket] diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerCalendarOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerCalendarOptionValueTest.kt index dc75ae05..f0c81ba8 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerCalendarOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerCalendarOptionValueTest.kt @@ -128,6 +128,10 @@ class ConfigParseTimerCalendarOptionValueTest : AbstractUnitFileTest() { // Extra spaces before the zone survive only after a weekday list or a bare date (both skip a run of // spaces); @EPOCH is read by strtoul, so whitespace and '+' may follow the '@'. CET/-03 stand for the // host's tzname abbreviations, posix/ and Etc/ zones are real files under /usr/share/zoneinfo. + // Whether systemd accepts the zone depends on the host (sd-check at 0f0682b773): `daily CET` passes + // only with TZ=Europe/Berlin (or a tzdata that ships a CET file), `12:00 -03` only with + // TZ=America/Sao_Paulo, and posix/Europe/Berlin only where tzdata installs the posix/ tree. The plugin + // cannot know the target host, so it accepts these shapes. // language="unit file (systemd)" val file = """ [Timer] @@ -140,6 +144,7 @@ class ConfigParseTimerCalendarOptionValueTest : AbstractUnitFileTest() { OnCalendar=12:00 -03 OnCalendar=Mon 12:00 posix/Europe/Berlin OnCalendar=daily Etc/GMT+5 + OnCalendar=daily Factory OnCalendar=Mon,Fri UTC """.trimIndent() From f543ef80786a6d2014b9b84a3331e0425f798bc9 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sat, 24 Oct 2026 07:39:40 -0700 Subject: [PATCH 7/8] refactor: use the shared case-insensitive BOOLEAN in unit-core validators 242.x's BOOLEAN now ignores case like parse_boolean(), so the batch-local BOOLEAN_ANY_CASE copy is redundant. RefreshOnReload= uses BOOLEAN directly and its tests gain more mixed-case booleans and mixed-case resource names (the table lookup is streq, so those stay invalid). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ConfigParseServiceRefreshOnReloadOptionValue.kt | 4 ++-- .../optionvalues/grammar/BatchUnitCoreCombinators.kt | 11 ----------- ...onfigParseServiceRefreshOnReloadOptionValueTest.kt | 7 ++++++- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseServiceRefreshOnReloadOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseServiceRefreshOnReloadOptionValue.kt index 3ed68a91..a62ca7a2 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseServiceRefreshOnReloadOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseServiceRefreshOnReloadOptionValue.kt @@ -2,7 +2,7 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.AlternativeCombinator -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.BOOLEAN_ANY_CASE +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BOOLEAN import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.FlexibleLiteralChoiceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal @@ -43,7 +43,7 @@ private val OPTIONAL_WHITESPACE = ZeroOrOne(WhitespaceTerminal()) class ConfigParseServiceRefreshOnReloadOptionValue : SimpleGrammarOptionValues( "config_parse_service_refresh_on_reload", AlternativeCombinator( - SequenceCombinator(BOOLEAN_ANY_CASE, EOF()), + SequenceCombinator(BOOLEAN, EOF()), SequenceCombinator( ZeroOrOne(LiteralChoiceTerminal("~")), OPTIONAL_WHITESPACE, diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt index 8e7fc6f9..26ea40b7 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt @@ -62,17 +62,6 @@ object BatchUnitCoreGrammar { AlternativeCombinator(grammar, SequenceCombinator(UNIT_SPECIFIED_VALUE, EOF())) - // --------------------------------------------------------------------------------------------------- - // Booleans. - - /** - * A boolean as parse_boolean() (src/basic/parse-util.c) really reads it: STRCASE_IN_SET, so `Yes`, - * `TRUE` and `oN` are all fine. The shared [BOOLEAN] in Combinators.kt is case-sensitive; this is the - * batch-local case-insensitive one (see RECOMMENDATIONS.md about promoting it). - */ - val BOOLEAN_ANY_CASE = FlexibleLiteralChoiceTerminal(*BOOLEAN_SPELLINGS, ignoreCase = true) - - // --------------------------------------------------------------------------------------------------- // Time spans — parse_time(…, USEC_PER_SEC) a.k.a. parse_sec (src/basic/time-util.c). // diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseServiceRefreshOnReloadOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseServiceRefreshOnReloadOptionValueTest.kt index 892c4228..465840ed 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseServiceRefreshOnReloadOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseServiceRefreshOnReloadOptionValueTest.kt @@ -19,6 +19,9 @@ class ConfigParseServiceRefreshOnReloadOptionValueTest : AbstractUnitFileTest() RefreshOnReload=TRUE RefreshOnReload=oN RefreshOnReload=OFF + RefreshOnReload=oFf + RefreshOnReload=fAlSe + RefreshOnReload=T RefreshOnReload=extensions RefreshOnReload=credentials RefreshOnReload=extensions credentials @@ -46,12 +49,14 @@ class ConfigParseServiceRefreshOnReloadOptionValueTest : AbstractUnitFileTest() RefreshOnReload=~~extensions RefreshOnReload=extensions ~credentials RefreshOnReload=all + RefreshOnReload=CREDENTIALS + RefreshOnReload=extensions Credentials """.trimIndent() setupFileInEditor("file.service", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(6, highlights) + assertSize(8, highlights) } } From 65e58aab13a174a8cab6761fa1cd5cf787c93ac9 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sat, 24 Oct 2026 11:33:49 -0700 Subject: [PATCH 8/8] refactor: use the shared SafeAtoTerminal in unit-core validators (#563) Replace BatchUnitCoreGrammar.SafeAtoNumberTerminal and safeAtou with the shared SafeAtoTerminal, written out at each call site: - ListenStream=/ListenDatagram= ports: parse_ip_port, so REFUSE_LEADING_WHITESPACE and 1..65535. - vsock CID and port: safe_atou on a field cut at ':', whitespace allowed. - ListenNetlink= family: a whitespace-split word (0..INT_MAX); the group is the rest of the value handed to safe_atou, so whitespace reaches it. The family/group separator is now C's " \t\n\r" only. - MessageQueueMaxMessages= (config_parse_long): signed, INT64 bounds. Verdicts now matching sd-check at 0f0682b773: accepted 0b+1, 0b 1, 0o 7, 0b +1, \v80 and 1.2.3.4:0b 1 as ports, vsock:2: 80, vsock: 2:80, ListenNetlink=route 0b 1, route \v5, 0b+1 5, and MessageQueueMaxMessages=0b-1, 0o-7, 0b -1, 0b+1, 0b 1, \v5, \v-1; flagged ListenNetlink=route\v5. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ai/ConfigParseLongOptionValue.kt | 21 ++++- .../ai/ConfigParseSocketListenOptionValues.kt | 81 ++++++++++++++++--- .../grammar/BatchUnitCoreCombinators.kt | 77 +----------------- .../ai/ConfigParseLongOptionValueTest.kt | 22 ++++- ...gParseSocketListenSocketOptionValueTest.kt | 57 +++++++++++++ 5 files changed, 167 insertions(+), 91 deletions(-) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLongOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLongOptionValue.kt index 8bb06354..6aa7bad9 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLongOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLongOptionValue.kt @@ -1,8 +1,8 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.SafeAtoNumberTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SafeAtoTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator /* @@ -20,8 +20,10 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram /** * Validator for config_parse_long, used only by `[Socket] MessageQueueMaxMessages=`. * - * A signed `long` as safe_atoli() reads it: an optional sign, then decimal, `0x` hex, leading-zero - * octal, or (unsigned only) a `0b`/`0o` prefix — anything from LONG_MIN to LONG_MAX on a 64-bit host. + * A signed `long` as safe_atoli() reads it ([SafeAtoTerminal], signed, base 0): optional leading + * whitespace and sign, then decimal, `0x` hex, leading-zero octal, or a `0b`/`0o` prefix — after which + * strtoll starts afresh, so `0b-1` and `0b -1` are -1 (sd-check accepts both) — anything from LONG_MIN + * to LONG_MAX on a 64-bit host. * The setting itself only means something when positive (socket.c tests `mq_maxmsg > 0`), but the * parser accepts any long and so does this. * @@ -30,5 +32,16 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram */ class ConfigParseLongOptionValue : SimpleGrammarOptionValues( "config_parse_long", - SequenceCombinator(SafeAtoNumberTerminal(Long.MIN_VALUE, Long.MAX_VALUE, signed = true), EOF()) + SequenceCombinator( + SafeAtoTerminal( + signed = true, + base = 0, + flags = emptySet(), + minInclusive = SafeAtoTerminal.INT64_MIN, + maxInclusive = SafeAtoTerminal.INT64_MAX, + acceptsWhitespace = true, + acceptsMinus = true, + ), + EOF(), + ) ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt index caf7944b..ddd372c6 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt @@ -13,14 +13,13 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.IPV6_ADDR import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.RegexTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SafeAtoTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.UNIT_ABSOLUTE_NON_API_VFS_PATH import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.UNIT_ABSOLUTE_PATH import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.UNIT_TEXT_CHAR -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.WhitespaceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.safeAtou import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.withUnitSpecifiers /* @@ -43,11 +42,13 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L1754 socket_address_parse_vsock * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L1708 vsock_parse_port * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L1731 vsock_parse_cid - * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L718 parse_ip_port (1..65535, safe_atou16 so hex/octal are fine) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L718 parse_ip_port (1..65535, safe_atou16_full with SAFE_ATO_REFUSE_LEADING_WHITESPACE) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L429-L478 safe_atou_full (vsock CID/port, netlink group) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/socket-netlink.c#L220 in_addr_port_ifindex_name_from_string_auto * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L785 ifname_valid_full (IFNAME_VALID_ALTERNATIVE | IFNAME_VALID_NUMERIC) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/socket-netlink.c#L119-L152 socket_address_parse_netlink * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L656 netlink_family_table (WITH_FALLBACK: a number up to INT_MAX also works) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/string-table.c#L65-L80 string_table_lookup_from_string_fallback (safe_atou, then <= max) * tests https://github.com/systemd/systemd/blob/0f0682b773/src/test/test-socket-netlink.c#L40 TEST(socket_address_parse) * tests https://github.com/systemd/systemd/blob/0f0682b773/src/test/test-socket-netlink.c#L105 TEST(socket_address_parse_netlink) * linux https://man7.org/linux/man-pages/man7/unix.7.html @@ -73,20 +74,49 @@ private val UNIX_SOCKET_ADDRESS = RegexTerminal( """(?:(?=[^%]*$)(?:/+var/+run/+[\s\S]{0,102}|[/@][\s\S]{1,106})|(?=[\s\S]*%)(?:[/@]|$PERCENT_START_SPECIFIER)$UNIT_TEXT_CHAR*)$""" ) -private val IP_PORT = safeAtou(1, 65535) +// parse_ip_port: safe_atou16_full(…, SAFE_ATO_REFUSE_LEADING_WHITESPACE), then 0 is refused. The port is a +// whole value or the text after the last ':' (cut before a '%' scope), so a leading `\v` and `0b 1` reach +// the parser (accepted) while a leading space is refused (sd-check: `ListenStream=1.2.3.4: 80`). +private val IP_PORT = SafeAtoTerminal( + signed = false, + base = 0, + flags = setOf(SafeAtoTerminal.Flag.REFUSE_LEADING_WHITESPACE), + minInclusive = 1L, + maxInclusive = 65_535L, + acceptsWhitespace = true, + acceptsMinus = true, +) // AF_VSOCK: vsock[-stream|-dgram|-seqpacket]:CID:PORT; the CID may be empty (VMADDR_CID_ANY), one of the -// named CIDs, "-1", or a number; the port is any unsigned except VMADDR_PORT_ANY (UINT32_MAX). +// named CIDs, "-1", or a number; the port is any unsigned except VMADDR_PORT_ANY (UINT32_MAX). Both numbers +// are plain safe_atou() on a field cut at the first ':', so whitespace reaches the parser +// (sd-check accepts `vsock:2: 80` and `vsock: 2:80`). private val VSOCK_CID = AlternativeCombinator( FlexibleLiteralChoiceTerminal("hypervisor", "local", "host", "any"), LiteralChoiceTerminal("-1"), - safeAtou(0, 4_294_967_295), + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 4_294_967_295L, + acceptsWhitespace = true, + acceptsMinus = true, + ), ) private val VSOCK_ADDRESS = SequenceCombinator( LiteralChoiceTerminal("vsock-seqpacket:", "vsock-stream:", "vsock-dgram:", "vsock:"), ZeroOrOne(VSOCK_CID), COLON, - safeAtou(0, 4_294_967_294), + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 4_294_967_294L, + acceptsWhitespace = true, + acceptsMinus = true, + ), ) // An interface scope after the port. The `%` that introduces it has to be written `%%` in a unit file: @@ -115,18 +145,47 @@ private val SOCKET_ADDRESS = withUnitSpecifiers( /** ListenSequentialPacket=: config_parse_socket_listen refuses every family but AF_UNIX for SOCK_SEQPACKET. */ private val SEQPACKET_ADDRESS = withUnitSpecifiers(SequenceCombinator(UNIX_SOCKET_ADDRESS, EOF())) -/** ListenNetlink=: `FAMILY [GROUP]`, the family a name from netlink_family_table or a number. */ +/** + * ListenNetlink=: `FAMILY [GROUP]`, the family a name from netlink_family_table or a number. + * + * socket_address_parse_netlink() takes the family as the first word (extract_first_word, split on + * `" \t\n\r"` only, so `route\v5` is one word and refused) and hands the whole REST to safe_atou(), so + * the group may contain whitespace that strtoul skips (sd-check accepts `route 0b 1` and `route \v5`). + */ private val NETLINK_ADDRESS = withUnitSpecifiers( SequenceCombinator( + // The number goes first: the name list contains digits and '-' (ip6-fw), so its syntactic shape also + // matches the `0b` of `0b+1`, and the classic engine commits to the first syntactic match. AlternativeCombinator( + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = Int.MAX_VALUE.toLong(), + acceptsWhitespace = false, + acceptsMinus = true, + ), FlexibleLiteralChoiceTerminal( "route", "firewall", "inet-diag", "nflog", "xfrm", "selinux", "iscsi", "audit", "fib-lookup", "connector", "netfilter", "ip6-fw", "dnrtmsg", "kobject-uevent", "generic", "scsitransport", "ecryptfs", "rdma", ), - safeAtou(0, Int.MAX_VALUE.toLong()), ), - ZeroOrOne(SequenceCombinator(WhitespaceTerminal(), safeAtou(0, 4_294_967_295))), + ZeroOrOne( + SequenceCombinator( + RegexTerminal("[ \t\n\r]+", "[ \t\n\r]+"), + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 4_294_967_295L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + ) + ), EOF(), ) ) @@ -139,7 +198,7 @@ private val NETLINK_ADDRESS = withUnitSpecifiers( * * - `ListenStream=` / `ListenDatagram=`: any socket_address_parse() address — an absolute path or * `@abstract` name (2..107 bytes), `vsock:CID:PORT` (also `vsock-stream:` etc.), a bare port - * (1..65535; safe_atou16, so `0x50` is port 80), `IPv4:PORT` or `[IPv6]:PORT`, the latter two + * (1..65535; parse_ip_port, so `0x50` is port 80), `IPv4:PORT` or `[IPv6]:PORT`, the latter two * optionally followed by an interface scope. Because unit_path_printf() runs first, that scope's * `%` must be written `%%` (`[fe80::1]:80%%eth0`). * - `ListenSequentialPacket=`: AF_UNIX only. diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt index 26ea40b7..968a388d 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt @@ -10,8 +10,8 @@ import java.math.BigInteger * systemd-build/build/last_commit_hash: https://github.com/systemd/systemd/blob/0f0682b773 * * These were written for one batch and live in their own file so parallel work on Combinators.kt does - * not collide; several of them (the time span, the unit-specifier escape hatch, the safe_ato* number) - * are general enough to be promoted there. + * not collide; some of them (the time span, the unit-specifier escape hatch) are general enough to be + * promoted there. Integers read by the safe_ato* family use the shared [SafeAtoTerminal] instead. * * Every grammar here is careful about the classic engine: no repetition wraps something that can match * the empty string (classic ZeroOrMore/OneOrMore would loop forever), and every alternative that @@ -353,79 +353,6 @@ object BatchUnitCoreGrammar { ) - // --------------------------------------------------------------------------------------------------- - // Numbers as the safe_ato* family reads them (src/basic/parse-util.c): leading whitespace is skipped, - // mangle_base() turns a `0b`/`0o` prefix into base 2/8, and otherwise strtoul()/strtoll() run with base - // 0, so `0x` is hexadecimal and a leading `0` is octal. The unsigned helpers accept a leading `+` and - // refuse `-` with ERANGE — but only when the parsed value is non-zero (`l != 0 && s[0] == '-'`), so - // `-0` and `-0x0` are a valid 0. The signed ones accept both signs. `0b`/`0o` are only recognised unsigned. - - /** - * An integer spelled any way the safe_atou and safe_atoi helpers accept, range-checked on the value it denotes. - * - * Unlike [UnsignedNumberTerminal] this also accepts `0b…`, `0o…` and a leading `+`, and with - * [signed] a leading `-`. - */ - class SafeAtoNumberTerminal( - private val minInclusive: BigInteger, - private val maxInclusive: BigInteger, - private val signed: Boolean, - ) : TerminalCombinator { - - constructor(minInclusive: Long, maxInclusive: Long, signed: Boolean) : - this(BigInteger.valueOf(minInclusive), BigInteger.valueOf(maxInclusive), signed) - - private fun span(value: String, offset: Int): String? = SHAPE.matchAt(value, offset)?.value - - private fun valueOf(text: String): BigInteger? { - var s = text - if (s.length > 2 && (s.startsWith("0b") || s.startsWith("0B"))) return s.drop(2).toBigIntegerOrNull(2) - if (s.length > 2 && (s.startsWith("0o") || s.startsWith("0O"))) return s.drop(2).toBigIntegerOrNull(8) - var negative = false - if (s.startsWith("+") || s.startsWith("-")) { - negative = s[0] == '-' - s = s.drop(1) - } - val magnitude = when { - s.length > 2 && (s.startsWith("0x") || s.startsWith("0X")) -> s.drop(2).toBigIntegerOrNull(16) - s.length > 1 && s.startsWith("0") -> s.drop(1).toBigIntegerOrNull(8) - else -> s.toBigIntegerOrNull(10) - } ?: return null - if (negative && !signed && magnitude.signum() != 0) return null // safe_atou*: ERANGE unless the value is 0 - return if (negative) magnitude.negate() else magnitude - } - - private fun inRange(text: String): Boolean = valueOf(text)?.let { it >= minInclusive && it <= maxInclusive } ?: false - - override fun SyntacticMatch(value: String, offset: Int): MatchResult { - val text = span(value, offset) ?: return NoMatch - return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) - } - - override fun SemanticMatch(value: String, offset: Int): MatchResult { - val text = span(value, offset) ?: return NoMatch - if (!inRange(text)) return NoMatch.copy(longestMatch = offset) - return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) - } - - override fun parse(value: String, offset: Int): Sequence { - val text = span(value, offset) ?: return sequenceOf(Stuck(offset, setOf(this))) - return sequenceOf(Parse(offset + text.length, listOf(ParsedToken(offset, offset + text.length, text, this, inRange(text))))) - } - - override fun toString(): String = "SafeAto($minInclusive,$maxInclusive${if (signed) ",signed" else ""})" - - private companion object { - // Prefixed forms first so "0x10" isn't read as "0". Over-long digit runs (e.g. "08", an invalid - // octal) are still matched whole so the bad token can be highlighted. - val SHAPE = Regex("""0[bB][0-9]+|0[oO][0-9]+|[+-]?(?:0[xX][0-9a-fA-F]+|[0-9]+)""") - } - } - - /** An unsigned number in `[minInclusive, maxInclusive]` as safe_atou*() reads it. */ - fun safeAtou(minInclusive: Long, maxInclusive: Long): Combinator = SafeAtoNumberTerminal(minInclusive, maxInclusive, signed = false) - - // --------------------------------------------------------------------------------------------------- // Byte sizes — parse_size(…, 1024) (src/basic/parse-util.c). // diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLongOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLongOptionValueTest.kt index 102890f5..aa4c466f 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLongOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLongOptionValueTest.kt @@ -2,13 +2,17 @@ package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai.ConfigParseLongOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.GrammarEngineVerdict import org.junit.Test class ConfigParseLongOptionValueTest : AbstractUnitFileTest() { @Test fun testValidValues() { - // safe_atoli: signed long, base 0 plus the 0b/0o prefixes mangle_base adds. + // safe_atoli: signed long, base 0 plus the 0b/0o prefixes mangle_base adds. strtoll starts afresh + // after the prefix, so a sign or whitespace may follow it (the last five confirmed accepted one per + // file with sd-check at 0f0682b773). // language="unit file (systemd)" val file = """ [Socket] @@ -23,6 +27,11 @@ class ConfigParseLongOptionValueTest : AbstractUnitFileTest() { MessageQueueMaxMessages=0o17 MessageQueueMaxMessages=9223372036854775807 MessageQueueMaxMessages=-9223372036854775808 + MessageQueueMaxMessages=0b-1 + MessageQueueMaxMessages=0o-7 + MessageQueueMaxMessages=0b -1 + MessageQueueMaxMessages=0b+1 + MessageQueueMaxMessages=0b 1 """.trimIndent() setupFileInEditor("file.socket", file) @@ -32,6 +41,17 @@ class ConfigParseLongOptionValueTest : AbstractUnitFileTest() { assertSize(0, highlights) } + @Test + fun testVerticalTabSpellings() { + // strtoll skips a leading \v (C isspace); sd-check at 0f0682b773 accepts both. The plugin's lexer does + // not take a raw \v, so this checks the grammar directly on both engines. + val grammar = ConfigParseLongOptionValue().combinator + for (engine in listOf(GrammarEngineVerdict::classic, GrammarEngineVerdict::parseEngine)) { + assertEquals(GrammarEngineVerdict.VALID, engine(grammar, "\u000B5")) + assertEquals(GrammarEngineVerdict.VALID, engine(grammar, "\u000B-1")) + } + } + @Test fun testInvalidValues() { // language="unit file (systemd)" diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt index b8e0bfa5..9223db2c 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt @@ -2,6 +2,8 @@ package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai.ConfigParseSocketListenSocketOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.GrammarEngineVerdict import org.junit.Test class ConfigParseSocketListenSocketOptionValueTest : AbstractUnitFileTest() { @@ -167,6 +169,61 @@ class ConfigParseSocketListenSocketOptionValueTest : AbstractUnitFileTest() { assertSize(5, highlights) } + @Test + fun testSafeAtoSpellings() { + // The numbers go through the shared SafeAtoTerminal. Every value here was checked one per file with + // sd-check at 0f0682b773: the first 15 are accepted, the last 5 rejected. Ports are parse_ip_port + // (SAFE_ATO_REFUSE_LEADING_WHITESPACE, then 0 refused); vsock CID/port and the netlink group are plain + // safe_atou() on the rest of the text; the netlink family is the first " \t\n\r"-separated word. + // The plugin's lexer does not take a raw \v, so those spellings are in testVerticalTabSpellings. + // language="unit file (systemd)" + val file = """ + [Socket] + ListenStream=0b+1 + ListenStream=0b 1 + ListenStream=0b +1 + ListenStream=0o 7 + ListenStream=1.2.3.4:0b 1 + ListenStream=[::1]:0b+1 + ListenStream=1.2.3.4:0b+1%%lo + ListenStream=vsock:2: 80 + ListenStream=vsock: 2:80 + ListenStream=vsock:2:0b 1 + ListenStream=vsock:0b+1:80 + ListenNetlink=route 0b 1 + ListenNetlink=route 0b+1 + ListenNetlink=0b+1 5 + ListenNetlink=route 5 + ListenStream=0b-0 + ListenStream=1.2.3.4: 80 + ListenStream=+0b1 + ListenStream=vsock:2:0b -1 + ListenNetlink=route 0b -1 + """.trimIndent() + + setupFileInEditor("file.socket", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(5, highlights) + } + + @Test + fun testVerticalTabSpellings() { + // strtoul skips a leading \v, but neither SAFE_ATO_REFUSE_LEADING_WHITESPACE nor extract_first_word + // treat it as whitespace. sd-check at 0f0682b773, one value per file: the first three are accepted, + // `route\v5` is rejected (one word, not a netlink family). + val validator = ConfigParseSocketListenSocketOptionValue() + val stream = validator.combinatorForKey("ListenStream") + val netlink = validator.combinatorForKey("ListenNetlink") + for (engine in listOf(GrammarEngineVerdict::classic, GrammarEngineVerdict::parseEngine)) { + assertEquals(GrammarEngineVerdict.VALID, engine(stream, "\u000B80")) + assertEquals(GrammarEngineVerdict.VALID, engine(stream, "1.2.3.4:\u000B80")) + assertEquals(GrammarEngineVerdict.VALID, engine(netlink, "route \u000B5")) + assertEquals(GrammarEngineVerdict.Kind.SYNTAX, engine(netlink, "route\u000B5").kind) + } + } + @Test fun testEmptyValueResets() { // language="unit file (systemd)"