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..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 @@ -413,6 +413,33 @@ 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, + // 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, + 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, + ) return allValidators 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..6aa7bad9 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLongOptionValue.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.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SafeAtoTerminal +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 ([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. + * + * 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( + 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/ConfigParseOpenFileOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt new file mode 100644 index 00000000..0d5b7a7d --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOpenFileOptionValue.kt @@ -0,0 +1,94 @@ +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.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 (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) + * 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}(?=:|$)""") + +// 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 +// field of nothing but commas, which yields no flags). +private val OPEN_FILE_OPTIONS = SequenceCombinator( + ZeroOrMore(COMMA), + ZeroOrOne( + SequenceCombinator( + 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), + ) + ), +) + +/** + * 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 except one trailing `:`: + * + * - 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 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 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), + // 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/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..a62ca7a2 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseServiceRefreshOnReloadOptionValue.kt @@ -0,0 +1,56 @@ +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/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) + */ + +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 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( + 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 new file mode 100644 index 00000000..ddd372c6 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSocketListenOptionValues.kt @@ -0,0 +1,252 @@ +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.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.ZeroOrOne +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.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/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) + * 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_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 + * 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). +// 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]*""", + """(?:(?=[^%]*$)(?:/+var/+run/+[\s\S]{0,102}|[/@][\s\S]{1,106})|(?=[\s\S]*%)(?:[/@]|$PERCENT_START_SPECIFIER)$UNIT_TEXT_CHAR*)$""" +) + +// 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). 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"), + 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, + 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: +// 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. + * + * 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", + ), + ), + 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(), + ) +) + +/** + * 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; 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. + * - `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/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..5175d980 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTimerCalendarOptionValue.kt @@ -0,0 +1,53 @@ +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 + +/* + * [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 (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 + * 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/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) + */ + +/** + * 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 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 + * 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..3eff2f67 --- /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.BatchUnitCoreGrammar.TIMESPAN +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar.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 (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) + * 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..968a388d --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt @@ -0,0 +1,403 @@ +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; 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 + * could stop on a prefix of another is either ordered after it or terminated with EOF(). + * + * 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. + */ + +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 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("*") + 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. 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. */ + 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), + ) + + /** 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, + ) + + /** + * 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`). + */ + 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), + ) + + /** A complete calendar event expression, ending at [EOF]. Every branch is EOF-terminated. */ + val CALENDAR_SPEC = AlternativeCombinator( + *( + 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() + ) + + + // --------------------------------------------------------------------------------------------------- + // 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. + const val NO_DOT_DOT_COMPONENT = """(?!(?:[\s\S]*/)?\.\.(?:/|$))""" + + // 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])""" + + /** 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/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..aa4c466f --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLongOptionValueTest.kt @@ -0,0 +1,75 @@ +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. 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] + MessageQueueMaxMessages=10 + MessageQueueMaxMessages=0 + MessageQueueMaxMessages=-5 + MessageQueueMaxMessages=+5 + MessageQueueMaxMessages=0x10 + MessageQueueMaxMessages=-0x10 + MessageQueueMaxMessages=010 + MessageQueueMaxMessages=0b101 + 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) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + 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)" + 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..14244208 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOpenFileOptionValueTest.kt @@ -0,0 +1,73 @@ +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=/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= + """.trimIndent() + + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @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] + 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 + 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(12, 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..465840ed --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseServiceRefreshOnReloadOptionValueTest.kt @@ -0,0 +1,62 @@ +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 (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=oFf + RefreshOnReload=fAlSe + RefreshOnReload=T + 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 + RefreshOnReload=CREDENTIALS + RefreshOnReload=extensions Credentials + """.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/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..9223db2c --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSocketListenSocketOptionValueTest.kt @@ -0,0 +1,242 @@ +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() { + + @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 + ListenStream=vsock:-0:5 + 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() { + // `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] + 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. 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 = """ + [Socket] + 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(2, highlights) + } + + @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] + 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=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(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)" + 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) + } +} 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..f0c81ba8 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTimerCalendarOptionValueTest.kt @@ -0,0 +1,206 @@ +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 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. + // 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] + 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=daily Factory + 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, + // 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 + 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(33, 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) + } +}