From 5783ad3df38aa167826f39970e033c99e29d86d4 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Fri, 23 Oct 2026 18:23:39 -0700 Subject: [PATCH 1/7] feat: add grammar validators for .nspawn settings (nspawn batch) Validators for the 21 (parser, ltype) pairs in nspawn-gperf.gperf that had none: Capability=/DropCapability=/AmbientCapability=, Volatile=, ResolvConf=, LinkJournal=, OOMScoreAdjust=, MachineID=, Zone=, Port=, Interface=/MACVLAN=/ IPVLAN=, VirtualEthernetExtra=, Bind=/BindReadOnly=, TemporaryFileSystem=, Inaccessible=, PivotRoot=, Overlay=/OverlayReadOnly=, BindUser= and RestrictAddressFamilies=. Each is modelled on the C parser at systemd 0f0682b773 and its leaf helpers, with tests drawn from TEST-13-NSPAWN and test-id128.c. Shared helpers live in BatchNspawnCombinators.kt: a safe_atoi/safe_atou number terminal (sign, 0x, octal, 0o, 0b), a case-insensitive parse_boolean, a colon-delimited interface name, colon-field path terminals, strict user names, 128-bit IDs and the case-insensitive, prefix-optional address family names. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../semanticdata/optionvalues/AiGenerated.kt | 26 ++ .../ai/ConfigParseBindOptionValue.kt | 80 +++++++ .../ai/ConfigParseBindUserOptionValue.kt | 37 +++ .../ai/ConfigParseCapabilityOptionValue.kt | 61 +++++ .../ai/ConfigParseExposePortOptionValue.kt | 44 ++++ .../ai/ConfigParseId128OptionValue.kt | 46 ++++ .../ai/ConfigParseInaccessibleOptionValue.kt | 32 +++ .../ConfigParseInterfacePairOptionValues.kt | 63 +++++ .../ai/ConfigParseLinkJournalOptionValue.kt | 27 +++ .../ai/ConfigParseNetworkZoneOptionValue.kt | 34 +++ .../ConfigParseOomScoreAdjustOptionValue.kt | 33 +++ .../ai/ConfigParseOverlayOptionValue.kt | 68 ++++++ .../ai/ConfigParsePivotRootOptionValue.kt | 41 ++++ .../ai/ConfigParseResolvConfOptionValue.kt | 36 +++ ...ParseRestrictAddressFamiliesOptionValue.kt | 66 +++++ .../ai/ConfigParseTmpfsOptionValue.kt | 45 ++++ .../ai/ConfigParseVethExtraOptionValue.kt | 50 ++++ .../ai/ConfigParseVolatileModeOptionValue.kt | 28 +++ .../grammar/BatchNspawnCombinators.kt | 225 ++++++++++++++++++ .../ai/ConfigParseBindOptionValueTest.kt | 63 +++++ .../ai/ConfigParseBindUserOptionValueTest.kt | 49 ++++ .../ConfigParseCapabilityOptionValueTest.kt | 55 +++++ .../ConfigParseExposePortOptionValueTest.kt | 55 +++++ .../ai/ConfigParseId128OptionValueTest.kt | 50 ++++ .../ConfigParseInaccessibleOptionValueTest.kt | 46 ++++ ...ConfigParseInterfacePairOptionValueTest.kt | 52 ++++ .../ConfigParseLinkJournalOptionValueTest.kt | 51 ++++ .../ConfigParseNetworkZoneOptionValueTest.kt | 47 ++++ ...onfigParseOomScoreAdjustOptionValueTest.kt | 55 +++++ .../ai/ConfigParseOverlayOptionValueTest.kt | 54 +++++ .../ai/ConfigParsePivotRootOptionValueTest.kt | 52 ++++ .../ConfigParseResolvConfOptionValueTest.kt | 60 +++++ ...eRestrictAddressFamiliesOptionValueTest.kt | 72 ++++++ .../ai/ConfigParseTmpfsOptionValueTest.kt | 48 ++++ .../ai/ConfigParseVethExtraOptionValueTest.kt | 49 ++++ .../ConfigParseVolatileModeOptionValueTest.kt | 53 +++++ 36 files changed, 1953 insertions(+) create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBindOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBindUserOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCapabilityOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExposePortOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseId128OptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInaccessibleOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInterfacePairOptionValues.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLinkJournalOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNetworkZoneOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOverlayOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePivotRootOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseResolvConfOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseRestrictAddressFamiliesOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTmpfsOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVethExtraOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVolatileModeOptionValue.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindUserOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCapabilityOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExposePortOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseId128OptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInaccessibleOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInterfacePairOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLinkJournalOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseNetworkZoneOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOverlayOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePivotRootOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseResolvConfOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseRestrictAddressFamiliesOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTmpfsOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVethExtraOptionValueTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVolatileModeOptionValueTest.kt diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/AiGenerated.kt index 36704512..7dac176c 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,32 @@ 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: nspawn + // .nspawn settings (src/nspawn/nspawn-settings.c at 0f0682b773). None of these parsers is used by + // any other gperf table, and every (parser, ltype) below is shared only by keys of one value type. + Validator("config_parse_capability", "0") to ConfigParseCapabilityOptionValue() as OptionValueInformation, + Validator("config_parse_volatile_mode", "0") to ConfigParseVolatileModeOptionValue() as OptionValueInformation, + Validator("config_parse_veth_extra", "0") to ConfigParseVethExtraOptionValue() as OptionValueInformation, + Validator("config_parse_tmpfs", "0") to ConfigParseTmpfsOptionValue() as OptionValueInformation, + Validator("config_parse_restrict_address_families", "0") to ConfigParseRestrictAddressFamiliesOptionValue() as OptionValueInformation, + Validator("config_parse_resolv_conf", "0") to ConfigParseResolvConfOptionValue() as OptionValueInformation, + Validator("config_parse_pivot_root", "0") to ConfigParsePivotRootOptionValue() as OptionValueInformation, + // ltype only selects read-only for Overlay=/OverlayReadOnly= and Bind=/BindReadOnly=. + Validator("config_parse_overlay", "0") to ConfigParseOverlayOptionValue() as OptionValueInformation, + Validator("config_parse_overlay", "1") to ConfigParseOverlayOptionValue() as OptionValueInformation, + Validator("config_parse_oom_score_adjust", "0") to ConfigParseOomScoreAdjustOptionValue() as OptionValueInformation, + Validator("config_parse_network_zone", "0") to ConfigParseNetworkZoneOptionValue() as OptionValueInformation, + Validator("config_parse_network_iface_pair", "0") to ConfigParseNetworkIfacePairOptionValue() as OptionValueInformation, + Validator("config_parse_macvlan_iface_pair", "0") to ConfigParseMacvlanIfacePairOptionValue() as OptionValueInformation, + Validator("config_parse_ipvlan_iface_pair", "0") to ConfigParseIpvlanIfacePairOptionValue() as OptionValueInformation, + Validator("config_parse_link_journal", "0") to ConfigParseLinkJournalOptionValue() as OptionValueInformation, + Validator("config_parse_inaccessible", "0") to ConfigParseInaccessibleOptionValue() as OptionValueInformation, + Validator("config_parse_id128", "0") to ConfigParseId128OptionValue() as OptionValueInformation, + Validator("config_parse_expose_port", "0") to ConfigParseExposePortOptionValue() as OptionValueInformation, + Validator("config_parse_bind_user", "0") to ConfigParseBindUserOptionValue() as OptionValueInformation, + Validator("config_parse_bind", "0") to ConfigParseBindOptionValue() as OptionValueInformation, + Validator("config_parse_bind", "1") to ConfigParseBindOptionValue() as OptionValueInformation, + ) return allValidators diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBindOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBindOptionValue.kt new file mode 100644 index 00000000..de7943b6 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBindOptionValue.kt @@ -0,0 +1,80 @@ +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.COLON_FIELD_ABSOLUTE_PATH +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON_FIELD_SOURCE_PATH +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.FlexibleLiteralChoiceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.OneOrMore +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.RegexTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrMore +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne + +/* + * [Files] Bind= / BindReadOnly= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#Bind= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--bind= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L351-L375 config_parse_bind (0 = Bind=, 1 = BindReadOnly=) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-mount.c#L234-L275 bind_mount_parse + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-mount.c#L86-L118 source_path_parse / source_path_parse_nullable + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L240-L281 extract_many_words (":", EXTRACT_DONT_COALESCE_SEPARATORS) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L10-L188 extract_first_word + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-mount.c#L759-L800 parse_mount_bind_options (checked when the mount is set up) + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L264-L287 + */ + +/** The options parse_mount_bind_options() knows; anything else fails the container start. */ +private val BIND_OPTION = FlexibleLiteralChoiceTerminal("rbind", "norbind", "idmap", "noidmap", "rootidmap", "owneridmap") + +private val COMMA = LiteralChoiceTerminal(",") + +/** + * The option string: split with extract_first_word(",", 0), which coalesces repeated commas and ignores + * leading and trailing ones (so a string of nothing but commas means "no options"). + */ +private val BIND_OPTIONS = SequenceCombinator( + ZeroOrMore(COMMA), + ZeroOrOne(SequenceCombinator(BIND_OPTION, ZeroOrMore(SequenceCombinator(OneOrMore(COMMA), BIND_OPTION)))), + ZeroOrMore(COMMA), +) + +/** A lone path is both source and destination, so it must be absolute (optionally `+`-prefixed). */ +private val SINGLE_PATH = RegexTerminal("""(?:[^:\\]|\\[\s\S])+""", """\+?\\?/(?:[^:\\]|\\[\s\S])*""") + +/** + * Validator for the .nspawn `[Files] Bind=` and `BindReadOnly=` settings (config_parse_bind; ltype 1 + * only sets the read-only flag, so both share one grammar). + * + * bind_mount_parse() splits the value into at most two colon-separated fields with extract_many_words(":", + * EXTRACT_DONT_COALESCE_SEPARATORS) — backslash makes the next character literal, so `\:` embeds a colon; + * quotes and whitespace are ordinary characters — and keeps whatever follows the second colon verbatim as + * the option string: + * + * - `PATH` alone is used as both source and destination: it must be absolute, optionally prefixed with + * `+` (meaning "relative to the container image"), which is dropped for the destination. + * - `SOURCE:DEST[:OPTIONS]` — SOURCE may be empty (a temporary directory under /var/tmp), `+/abs`, or any + * other path (a relative one is resolved against the working directory). DEST must be absolute. + * `SOURCE:` (an empty DEST) is invalid; an empty OPTIONS after the second colon is ignored. + * + * OPTIONS is not inspected at config-parse time, but parse_mount_bind_options() refuses anything other + * than a comma-separated list of `rbind`, `norbind`, `idmap`, `noidmap`, `rootidmap`, `owneridmap` when + * the container starts, so that list is enforced here. Parse errors are logged and the setting ignored. + */ +class ConfigParseBindOptionValue : SimpleGrammarOptionValues( + "config_parse_bind", + AlternativeCombinator( + SequenceCombinator( + ZeroOrOne(COLON_FIELD_SOURCE_PATH), + COLON, + COLON_FIELD_ABSOLUTE_PATH, + ZeroOrOne(SequenceCombinator(COLON, BIND_OPTIONS)), + EOF(), + ), + SequenceCombinator(SINGLE_PATH, EOF()), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBindUserOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBindUserOptionValue.kt new file mode 100644 index 00000000..32d4cd52 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBindUserOptionValue.kt @@ -0,0 +1,37 @@ +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.STRICT_USER_NAME +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 + +/* + * [Files] BindUser= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#BindUser= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--bind-user= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L979-L1024 config_parse_bind_user (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L10-L188 extract_first_word (WHITESPACE, flags 0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/user-util.c#L718-L832 valid_user_group_name (flags 0 = strict) + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L631-L632 + */ + +/** + * Validator for the .nspawn `[Files] BindUser=` setting (config_parse_bind_user, ltype 0). + * + * The value is a whitespace-separated list of user names (an empty value resets the list and never + * reaches the grammar). Each name must pass valid_user_group_name() in its STRICT form (no + * VALID_USER_RELAX): a letter or `_`, then letters, digits, `_` or `-`, at most 31 bytes (the utmp + * ut_user field). Dots, leading digits or dashes, and numeric UIDs are all refused. On the first invalid + * name the rest of the line is ignored with a warning. + */ +class ConfigParseBindUserOptionValue : SimpleGrammarOptionValues( + "config_parse_bind_user", + SequenceCombinator( + STRICT_USER_NAME, + ZeroOrMore(SequenceCombinator(WhitespaceTerminal(), STRICT_USER_NAME)), + EOF(), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCapabilityOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCapabilityOptionValue.kt new file mode 100644 index 00000000..e28f0051 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCapabilityOptionValue.kt @@ -0,0 +1,61 @@ +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.CAPABILITY_NAME +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.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.safeAtoNumber + +/* + * [Exec] Capability= / AmbientCapability= / DropCapability= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#Capability= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#AmbientCapability= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L276-L323 config_parse_capability (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L10-L188 extract_first_word (WHITESPACE, flags 0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/capability-list.c#L45-L66 capability_from_name + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L497-L520 safe_atoi (numeric capabilities) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/capability-util.h#L17 CAP_LIMIT + * check https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn.c#L1559-L1561 verify_arguments (refuses AmbientCapability=all after parsing) + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L518-L520 + * linux https://man7.org/linux/man-pages/man7/capabilities.7.html + */ + +/** One capability word: the literal `all`, a capability number, or a capability name. */ +private val CAPABILITY_WORD = AlternativeCombinator( + LiteralChoiceTerminal("all"), + safeAtoNumber(0, 63), + CAPABILITY_NAME, +) + +/** + * Validator for the .nspawn `[Exec] Capability=`, `AmbientCapability=` and `DropCapability=` settings, + * all of which use config_parse_capability with ltype 0. + * + * The value is a whitespace-separated list (extract_first_word with the default WHITESPACE separators, + * so runs of whitespace coalesce). Each word is either: + * + * - `all` — matched with a case-sensitive streq(), so `ALL` is not special and fails the name lookup; + * - a capability number — capability_from_name() first tries safe_atoi() (base 0, so `0x15` works) + * and accepts 0…CAP_LIMIT (62), even above the running kernel's last capability; + * - a capability name — looked up in a gperf table generated with `--ignore-case`, so `cap_net_admin` + * is as good as `CAP_NET_ADMIN`. + * + * An unknown word is logged and skipped rather than failing the whole line; it is still flagged here. + * + * Deliberately lenient: the man page and nspawn itself refuse `all` for AmbientCapability= — that check + * happens later, in verify_arguments() ("AmbientCapability= does not support the value all"), and the + * grammar is shared by all three keys via (parser, ltype), so it cannot single that key out. + */ +class ConfigParseCapabilityOptionValue : SimpleGrammarOptionValues( + "config_parse_capability", + SequenceCombinator( + CAPABILITY_WORD, + ZeroOrMore(SequenceCombinator(WhitespaceTerminal(), CAPABILITY_WORD)), + EOF(), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExposePortOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExposePortOptionValue.kt new file mode 100644 index 00000000..edb1de6f --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExposePortOptionValue.kt @@ -0,0 +1,44 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.safeAtoNumber + +/* + * [Network] Port= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#Port= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--port= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L248-L274 config_parse_expose_port (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-expose-ports.c#L17-L73 expose_port_parse + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L718-L734 parse_ip_port + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L429-L478 safe_atou_full (base 0, SAFE_ATO_REFUSE_LEADING_WHITESPACE) + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L557-L560 + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L315-L326 (accepted and rejected --port= spellings) + */ + +/** parse_ip_port(): a safe_atou16() number (base 0) that is not zero. */ +private val PORT = safeAtoNumber(1, 65536) + +/** + * Validator for the .nspawn `[Network] Port=` setting (config_parse_expose_port, ltype 0). + * + * expose_port_parse() strips an optional `tcp:` or `udp:` prefix (case-sensitive startswith(); the + * default is TCP), then splits the rest at the first `:` into a host port and a container port. With no + * colon the one number is used for both. Each port goes through parse_ip_port(): safe_atou16() with + * base 0 (so `0x50` is port 80) and no leading whitespace, then 1…65535. A malformed or duplicate + * specification is logged and ignored. + */ +class ConfigParseExposePortOptionValue : SimpleGrammarOptionValues( + "config_parse_expose_port", + SequenceCombinator( + ZeroOrOne(SequenceCombinator(LiteralChoiceTerminal("tcp", "udp"), COLON)), + PORT, + ZeroOrOne(SequenceCombinator(COLON, PORT)), + EOF(), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseId128OptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseId128OptionValue.kt new file mode 100644 index 00000000..b64c4969 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseId128OptionValue.kt @@ -0,0 +1,46 @@ +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.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ID128_URN_PREFIX +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.Labeled +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.NONZERO_ID128 +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.NONZERO_ID128_UUID +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.Role +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator + +/* + * [Exec] MachineID= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#MachineID= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--uuid= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L1190-L1218 config_parse_id128 (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/libsystemd/sd-id128/id128-util.c#L25-L40 id128_from_string_nonzero + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/libsystemd/sd-id128/sd-id128.c#L60-L112 sd_id128_from_string + * tests https://github.com/systemd/systemd/blob/0f0682b773/src/libsystemd/sd-id128/test-id128.c#L68-L97 + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L522 + */ + +/** + * Validator for the .nspawn `[Exec] MachineID=` setting (config_parse_id128, ltype 0; no other gperf + * table uses this parser). + * + * id128_from_string_nonzero() hands the value to sd_id128_from_string(), which accepts either 32 hex + * digits or the dashed 8-4-4-4-12 UUID form (hex digits in any case), optionally behind a + * case-insensitive `urn:uuid:` prefix — which then requires the dashed form. The all-zero ID is + * refused (logged and ignored). + */ +class ConfigParseId128OptionValue : SimpleGrammarOptionValues( + "config_parse_id128", + SequenceCombinator( + Labeled( + Role.LITERAL, + AlternativeCombinator( + SequenceCombinator(ID128_URN_PREFIX, NONZERO_ID128_UUID), + NONZERO_ID128, + ), + ), + EOF(), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInaccessibleOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInaccessibleOptionValue.kt new file mode 100644 index 00000000..139c4fd3 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInaccessibleOptionValue.kt @@ -0,0 +1,32 @@ +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.RegexTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator + +/* + * [Files] Inaccessible= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#Inaccessible= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--inaccessible= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L403-L427 config_parse_inaccessible (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-mount.c#L387-L408 inaccessible_mount_parse + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/path-util.h#L30-L35 path_is_absolute + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L542-L543 + */ + +/** + * Validator for the .nspawn `[Files] Inaccessible=` setting (config_parse_inaccessible, ltype 0). + * + * inaccessible_mount_parse() takes the whole value verbatim — no splitting, unquoting or unescaping, so + * spaces, colons and backslashes are ordinary characters — and only requires path_is_absolute(), i.e. a + * leading `/`. A relative path is logged and ignored. + */ +class ConfigParseInaccessibleOptionValue : SimpleGrammarOptionValues( + "config_parse_inaccessible", + SequenceCombinator( + RegexTerminal("""[\s\S]+""", """/[\s\S]*"""), + EOF(), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInterfacePairOptionValues.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInterfacePairOptionValues.kt new file mode 100644 index 00000000..ef72c63d --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInterfacePairOptionValues.kt @@ -0,0 +1,63 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON_DELIMITED_INTERFACE_NAME +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.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 + +/* + * [Network] Interface= / MACVLAN= / IPVLAN= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#Interface= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#MACVLAN= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L481-L500 config_parse_network_iface_pair (0) + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L502-L521 config_parse_macvlan_iface_pair (0) + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L523-L542 config_parse_ipvlan_iface_pair (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-network.c#L999-L1045 network_iface_pair_parse + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L10-L188 extract_first_word + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L785-L830 ifname_valid_full (flags 0) + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L553-L555 + * linux https://man7.org/linux/man-pages/man7/netdevice.7.html + */ + +/** `HOST[:CONTAINER]`, where an empty CONTAINER after the colon means "not given". */ +private val INTERFACE_PAIR = SequenceCombinator( + COLON_DELIMITED_INTERFACE_NAME, + ZeroOrOne(SequenceCombinator(COLON, ZeroOrOne(COLON_DELIMITED_INTERFACE_NAME))), +) + +private val INTERFACE_PAIR_LIST: Combinator = SequenceCombinator( + INTERFACE_PAIR, + ZeroOrMore(SequenceCombinator(WhitespaceTerminal(), INTERFACE_PAIR)), + EOF(), +) + +/** + * The shared grammar of the .nspawn `[Network] Interface=`, `MACVLAN=` and `IPVLAN=` settings, whose + * three parsers are thin wrappers around network_iface_pair_parse() (they differ only in the `mv-` / + * `iv-` prefix used when the container-side name is omitted). + * + * The value is a whitespace-separated list (extract_first_word with WHITESPACE). Each word is split once + * at the first `:`: the host-side name must pass ifname_valid() (printable ASCII except `:`, `/`, `%`; at + * most 15 bytes; not all digits, `.`, `..`, `all` or `default`), and the container-side name, if present + * and non-empty, must too — so `eth0:` is the same as `eth0`, and a second colon is an invalid name. + * Unlike most nspawn settings an invalid word is a hard error that fails loading the file. + * + * The prefixed default name (`mv-eth0`) may exceed 15 bytes; nspawn shortens it later, so the + * host-side name alone is what is length-checked. + */ +open class ConfigParseInterfacePairOptionValue(validatorName: String) : SimpleGrammarOptionValues(validatorName, INTERFACE_PAIR_LIST) + +/** `[Network] Interface=` — config_parse_network_iface_pair (0). */ +class ConfigParseNetworkIfacePairOptionValue : ConfigParseInterfacePairOptionValue("config_parse_network_iface_pair") + +/** `[Network] MACVLAN=` — config_parse_macvlan_iface_pair (0). */ +class ConfigParseMacvlanIfacePairOptionValue : ConfigParseInterfacePairOptionValue("config_parse_macvlan_iface_pair") + +/** `[Network] IPVLAN=` — config_parse_ipvlan_iface_pair (0). */ +class ConfigParseIpvlanIfacePairOptionValue : ConfigParseInterfacePairOptionValue("config_parse_ipvlan_iface_pair") diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLinkJournalOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLinkJournalOptionValue.kt new file mode 100644 index 00000000..49c00a6e --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLinkJournalOptionValue.kt @@ -0,0 +1,27 @@ +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.enumWithBoolean + +/* + * [Exec] LinkJournal= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#LinkJournal= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--link-journal= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L888-L910 config_parse_link_journal (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L852-L886 parse_link_journal + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L23-L46 parse_boolean + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L534 + */ + +/** + * Validator for the .nspawn `[Exec] LinkJournal=` setting (config_parse_link_journal, ltype 0). + * + * parse_link_journal() compares with streq() against `auto`, `guest`, `host`, `try-guest` and + * `try-host` (case-sensitive), and otherwise falls back to parse_boolean() (case-insensitive; true is + * treated as `auto`, false as `no`). Invalid input is logged and ignored. + */ +class ConfigParseLinkJournalOptionValue : SimpleGrammarOptionValues( + "config_parse_link_journal", + enumWithBoolean("auto", "guest", "host", "try-guest", "try-host"), +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNetworkZoneOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNetworkZoneOptionValue.kt new file mode 100644 index 00000000..15d5f099 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNetworkZoneOptionValue.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.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.RegexTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator + +/* + * [Network] Zone= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#Zone= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--network-zone= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L544-L570 config_parse_network_zone (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L785-L830 ifname_valid_full (flags 0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L769-L783 ifname_valid_char + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L556 + * linux https://man7.org/linux/man-pages/man7/netdevice.7.html + */ + +/** + * Validator for the .nspawn `[Network] Zone=` setting (config_parse_network_zone, ltype 0). + * + * The raw value is prefixed with `vz-` and the result must pass ifname_valid(): every character + * printable ASCII (33…126) other than `:`, `/` and `%`, and the whole name at most IFNAMSIZ-1 = 15 + * bytes — leaving 12 for the zone name itself. The prefix makes the numeric / `.` / `all` / `default` + * exclusions irrelevant. An invalid name is logged and ignored. + */ +class ConfigParseNetworkZoneOptionValue : SimpleGrammarOptionValues( + "config_parse_network_zone", + SequenceCombinator( + RegexTerminal("""\S+""", """[\x21-\x7E&&[^:/%]]{1,12}"""), + EOF(), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt new file mode 100644 index 00000000..9a7cce07 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.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.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.safeAtoNumber + +/* + * [Exec] OOMScoreAdjust= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#OOMScoreAdjust= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--oom-score-adjust= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L792-L828 config_parse_oom_score_adjust (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L762-L777 parse_oom_score_adjust + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L497-L520 safe_atoi + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L403-L427 mangle_base + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/process-util.c#L1934-L1936 oom_score_adjust_is_valid + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L529 + * linux https://man7.org/linux/man-pages/man5/proc_pid_oom_score_adj.5.html + */ + +/** + * Validator for the .nspawn `[Exec] OOMScoreAdjust=` setting (config_parse_oom_score_adjust, ltype 0). + * + * parse_oom_score_adjust() reads the value with safe_atoi() — base 0 after mangle_base(), so besides + * plain decimal it takes a sign, `0x` hex, leading-zero octal, `0o` octal and `0b` binary — and then + * requires OOM_SCORE_ADJ_MIN…OOM_SCORE_ADJ_MAX, i.e. -1000…1000 inclusive. An empty value resets the + * setting and never reaches the grammar. Out-of-range or malformed input is logged and ignored. + */ +class ConfigParseOomScoreAdjustOptionValue : SimpleGrammarOptionValues( + "config_parse_oom_score_adjust", + SequenceCombinator(safeAtoNumber(-1000, 1001), EOF()), +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOverlayOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOverlayOptionValue.kt new file mode 100644 index 00000000..a33a4f86 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOverlayOptionValue.kt @@ -0,0 +1,68 @@ +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.COLON_FIELD_ABSOLUTE_PATH +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON_FIELD_SOURCE_PATH +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 +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrMore +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne + +/* + * [Files] Overlay= / OverlayReadOnly= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#Overlay= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--overlay= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L429-L453 config_parse_overlay (0 = Overlay=, 1 = OverlayReadOnly=) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-mount.c#L312-L385 overlay_mount_parse + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-mount.c#L86-L118 source_path_parse / source_path_parse_nullable + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/strv.c#L415-L448 strv_split_full (":", EXTRACT_DONT_COALESCE_SEPARATORS) + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L545 + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L303 + */ + +/** + * A middle source path — one that is followed by another `:`. Same rules as [COLON_FIELD_SOURCE_PATH], + * with a `(?=:)` lookahead so the repetition never even tentatively consumes the final DEST field: under + * the classic matcher a tentative semantic match there would report a match length past the last + * syntactic token and break error localization. + */ +private val MIDDLE_SOURCE_PATH = RegexTerminal( + """(?:[^:\\]|\\[\s\S])+(?=:)""", + """(?:\+\\?/(?:[^:\\]|\\[\s\S])*|(?!\+)(?:[^:\\]|\\[\s\S])+)(?=:)""", +) + +/** + * Validator for the .nspawn `[Files] Overlay=` and `OverlayReadOnly=` settings (config_parse_overlay; + * ltype 1 only sets the read-only flag, so both share one grammar). + * + * overlay_mount_parse() splits the value on every unescaped `:` (strv_split_full with + * EXTRACT_DONT_COALESCE_SEPARATORS, so empty fields are kept and `\:` embeds a colon). At least two + * fields are required: + * + * - two fields, `LOWER:UPPER` — both are source paths (non-empty; `+/abs` relative to the image, or any + * other path, a relative one being resolved against the working directory) and UPPER is also the + * mount point; + * - three or more, `LOWER…:UPPER:DEST` — every LOWER is a non-empty source path, UPPER is a source path + * or empty (a throw-away directory under /var/tmp, as in `+/var::/var`), and DEST must be absolute. + * + * The grammar only lets an empty field appear immediately before DEST, which is exactly the UPPER slot + * when there are three or more fields. Parse errors are logged and the setting ignored. + */ +class ConfigParseOverlayOptionValue : SimpleGrammarOptionValues( + "config_parse_overlay", + AlternativeCombinator( + SequenceCombinator(COLON_FIELD_SOURCE_PATH, COLON, COLON_FIELD_SOURCE_PATH, EOF()), + SequenceCombinator( + COLON_FIELD_SOURCE_PATH, + COLON, + ZeroOrMore(SequenceCombinator(MIDDLE_SOURCE_PATH, COLON)), + ZeroOrOne(COLON), + COLON_FIELD_ABSOLUTE_PATH, + EOF(), + ), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePivotRootOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePivotRootOptionValue.kt new file mode 100644 index 00000000..4c7b92c2 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePivotRootOptionValue.kt @@ -0,0 +1,41 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON_FIELD_NORMALIZED_PATH +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.REST_NORMALIZED_PATH +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne + +/* + * [Exec] PivotRoot= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#PivotRoot= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--pivot-root= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L325-L349 config_parse_pivot_root (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-mount.c#L1350-L1383 pivot_root_parse + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L10-L188 extract_first_word (":", EXTRACT_DONT_COALESCE_SEPARATORS) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/path-util.c#L1192-L1203 path_is_normalized + */ + +/** + * Validator for the .nspawn `[Exec] PivotRoot=` setting (config_parse_pivot_root, ltype 0). + * + * pivot_root_parse() extracts the new root up to the first unescaped `:` (backslash makes the next + * character literal) and keeps the rest verbatim as the place to pivot the old root to; an empty rest + * means "unmount the old root". Both paths must be absolute AND normalized — path_is_normalized() + * refuses `//`, `.` and `..` components, though a trailing slash is fine. The man page only says + * "absolute"; this follows the C. Invalid values are logged and ignored. + * + * Deliberately lenient: path_is_normalized() also caps each component at NAME_MAX bytes and the whole + * path at PATH_MAX; those limits are not modelled. + */ +class ConfigParsePivotRootOptionValue : SimpleGrammarOptionValues( + "config_parse_pivot_root", + SequenceCombinator( + COLON_FIELD_NORMALIZED_PATH, + ZeroOrOne(SequenceCombinator(COLON, ZeroOrOne(REST_NORMALIZED_PATH))), + EOF(), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseResolvConfOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseResolvConfOptionValue.kt new file mode 100644 index 00000000..46a281e2 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseResolvConfOptionValue.kt @@ -0,0 +1,36 @@ +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.enumWithBoolean + +/* + * [Exec] ResolvConf= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#ResolvConf= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--resolv-conf= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L830 config_parse_resolv_conf (0), DEFINE_CONFIG_PARSE_ENUM + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L832-L850 resolv_conf_mode_table, DEFINE_STRING_TABLE_LOOKUP_WITH_BOOLEAN + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/string-table.c#L32-L43 string_table_lookup_from_string_with_boolean + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L23-L46 parse_boolean + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L532 + */ + +/** + * Validator for the .nspawn `[Exec] ResolvConf=` setting (config_parse_resolv_conf, ltype 0). + * + * resolv_conf_mode_from_string() is a DEFINE_STRING_TABLE_LOOKUP_WITH_BOOLEAN lookup: any + * parse_boolean() spelling (case-insensitive — false means `off`, true means `auto`), or one of the + * table names, matched case-sensitively: `off`, `copy-host`, `copy-static`, `copy-uplink`, `copy-stub`, + * `replace-host`, `replace-static`, `replace-uplink`, `replace-stub`, `bind-host`, `bind-static`, + * `bind-uplink`, `bind-stub`, `delete`, `auto`. + */ +class ConfigParseResolvConfOptionValue : SimpleGrammarOptionValues( + "config_parse_resolv_conf", + enumWithBoolean( + "off", + "copy-host", "copy-static", "copy-uplink", "copy-stub", + "replace-host", "replace-static", "replace-uplink", "replace-stub", + "bind-host", "bind-static", "bind-uplink", "bind-stub", + "delete", "auto", + ), +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseRestrictAddressFamiliesOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseRestrictAddressFamiliesOptionValue.kt new file mode 100644 index 00000000..27d756f4 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseRestrictAddressFamiliesOptionValue.kt @@ -0,0 +1,66 @@ +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.ADDRESS_FAMILY_NAME +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.AlternativeCombinator +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.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 + +/* + * [Exec] RestrictAddressFamilies= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#RestrictAddressFamilies= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--restrict-address-families= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L1068-L1095 config_parse_restrict_address_families (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L90-L145 parse_address_families + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L10-L188 extract_first_word (WHITESPACE ",", EXTRACT_UNQUOTE) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/af-list.c#L39-L51 af_from_name + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/meson.build#L168 gperf --ignore-case for the af lookup table + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L412-L419 + * linux https://man7.org/linux/man-pages/man7/address_families.7.html + */ + +/** One or more separators: parse_address_families splits on whitespace and commas, coalescing runs. */ +private val SEPARATORS = RegexTerminal("""[\s,]+""", """[\s,]+""") + +/** One family name, optionally in single or double quotes (EXTRACT_UNQUOTE strips them). */ +private val FAMILY_WORD = AlternativeCombinator( + SequenceCombinator(LiteralChoiceTerminal("'"), ADDRESS_FAMILY_NAME, LiteralChoiceTerminal("'")), + SequenceCombinator(LiteralChoiceTerminal("\""), ADDRESS_FAMILY_NAME, LiteralChoiceTerminal("\"")), + ADDRESS_FAMILY_NAME, +) + +/** + * Validator for the .nspawn `[Exec] RestrictAddressFamilies=` setting + * (config_parse_restrict_address_families, ltype 0), which hands the value to the same + * parse_address_families() helper as the unit-file RestrictAddressFamilies=. + * + * At 0f0682b773 that helper accepts: + * + * - exactly `none` (allow nothing); + * - an optional leading `~` (deny-list), immediately followed by a list of family names separated by + * whitespace and/or commas (runs coalesce; leading/trailing separators are fine; a bare `~` is an + * empty deny-list). Words are unquoted, so `'AF_INET'` is fine. + * + * Names go through af_from_name(), whose gperf table is case-insensitive and which retries with `AF_` + * prepended: `AF_INET`, `af_inet`, `inet` and `INET6` all resolve. Kernel-removed families (AF_DECnet, + * AF_IRDA, AF_ECONET, AF_WANPIPE) still resolve and are marked deprecated. An unknown name is logged and + * the rest of the line ignored. + */ +class ConfigParseRestrictAddressFamiliesOptionValue : SimpleGrammarOptionValues( + "config_parse_restrict_address_families", + AlternativeCombinator( + SequenceCombinator(LiteralChoiceTerminal("none"), EOF()), + SequenceCombinator( + ZeroOrOne(LiteralChoiceTerminal("~")), + ZeroOrMore(SEPARATORS), + ZeroOrOne(SequenceCombinator(FAMILY_WORD, ZeroOrMore(SequenceCombinator(SEPARATORS, FAMILY_WORD)))), + ZeroOrMore(SEPARATORS), + EOF(), + ), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTmpfsOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTmpfsOptionValue.kt new file mode 100644 index 00000000..3a6a09ec --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTmpfsOptionValue.kt @@ -0,0 +1,45 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON_FIELD_ABSOLUTE_PATH +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 +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne + +/* + * [Files] TemporaryFileSystem= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#TemporaryFileSystem= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--tmpfs= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L377-L401 config_parse_tmpfs (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-mount.c#L277-L310 tmpfs_mount_parse + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L10-L188 extract_first_word (":", EXTRACT_DONT_COALESCE_SEPARATORS) + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L540-L541 + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L297 + * linux https://man7.org/linux/man-pages/man5/tmpfs.5.html + */ + +/** The tmpfs mount options: the raw remainder, handed to mount(2) and only checked by the kernel. */ +private val TMPFS_OPTIONS = RegexTerminal("""[\s\S]+""", """[\s\S]+""") + +/** + * Validator for the .nspawn `[Files] TemporaryFileSystem=` setting (config_parse_tmpfs, ltype 0). + * + * tmpfs_mount_parse() extracts the path up to the first unescaped `:` (backslash makes the next + * character literal, so `\:` embeds a colon) and requires it to be absolute (path_is_absolute(): just + * a leading `/`). Everything after the colon is kept verbatim as the mount option string; when it is + * absent or empty `mode=0755` is used. + * + * Deliberately lenient: the option string is not checked — it is passed to the kernel's tmpfs, which + * is the only thing that validates it. + */ +class ConfigParseTmpfsOptionValue : SimpleGrammarOptionValues( + "config_parse_tmpfs", + SequenceCombinator( + COLON_FIELD_ABSOLUTE_PATH, + ZeroOrOne(SequenceCombinator(COLON, ZeroOrOne(TMPFS_OPTIONS))), + EOF(), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVethExtraOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVethExtraOptionValue.kt new file mode 100644 index 00000000..2a42d6b3 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVethExtraOptionValue.kt @@ -0,0 +1,50 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON_DELIMITED_INTERFACE_NAME +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 +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne + +/* + * [Network] VirtualEthernetExtra= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#VirtualEthernetExtra= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--network-veth-extra= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L455-L479 config_parse_veth_extra (0) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-network.c#L947-L975 veth_extra_parse + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L10-L188 extract_first_word (":", EXTRACT_DONT_COALESCE_SEPARATORS) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/socket-util.c#L785-L830 ifname_valid_full (flags 0) + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L551-L552 + * linux https://man7.org/linux/man-pages/man7/netdevice.7.html + */ + +/** + * The container-side name. veth_extra_parse() does not reject an invalid one: if it is empty or fails + * ifname_valid() it is silently replaced by the host-side name. So anything without a further colon is + * accepted here. + */ +private val CONTAINER_SIDE = RegexTerminal("""[^:]+""", """[^:]+""") + +/** + * Validator for the .nspawn `[Network] VirtualEthernetExtra=` setting (config_parse_veth_extra, ltype 0). + * + * veth_extra_parse() takes `HOST[:CONTAINER]`, split with extract_first_word(":", + * EXTRACT_DONT_COALESCE_SEPARATORS). HOST must pass ifname_valid() (printable ASCII except `:`, `/`, `%`; + * at most 15 bytes; not all digits, `.`, `..`, `all` or `default`). CONTAINER may be omitted or empty. + * Anything after a second colon — even an empty field, as in `a:b:` — is an error. Errors are logged + * and the setting ignored. + * + * Deliberately lenient: an invalid CONTAINER name (too long, containing `/`, …) is accepted, because + * systemd silently falls back to the host-side name rather than rejecting it. + */ +class ConfigParseVethExtraOptionValue : SimpleGrammarOptionValues( + "config_parse_veth_extra", + SequenceCombinator( + COLON_DELIMITED_INTERFACE_NAME, + ZeroOrOne(SequenceCombinator(COLON, ZeroOrOne(CONTAINER_SIDE))), + EOF(), + ) +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVolatileModeOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVolatileModeOptionValue.kt new file mode 100644 index 00000000..6397c707 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVolatileModeOptionValue.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.enumWithBoolean + +/* + * [Files] Volatile= (.nspawn). + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.nspawn.html#Volatile= + * man https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html#--volatile=MODE + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L246 config_parse_volatile_mode (0), DEFINE_CONFIG_PARSE_ENUM + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/volatile-util.c#L36-L43 volatile_mode_table, DEFINE_STRING_TABLE_LOOKUP_WITH_BOOLEAN + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/string-table.c#L32-L43 string_table_lookup_from_string_with_boolean + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L23-L46 parse_boolean + * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L539 + */ + +/** + * Validator for the .nspawn `[Files] Volatile=` setting (config_parse_volatile_mode, ltype 0). + * + * volatile_mode_from_string() is a DEFINE_STRING_TABLE_LOOKUP_WITH_BOOLEAN lookup: any parse_boolean() + * spelling (case-insensitive — false means `no`, true means `yes`), or one of the table names `no`, + * `yes`, `state`, `overlay`, matched case-sensitively. + */ +class ConfigParseVolatileModeOptionValue : SimpleGrammarOptionValues( + "config_parse_volatile_mode", + enumWithBoolean("no", "yes", "state", "overlay"), +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt new file mode 100644 index 00000000..76815865 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt @@ -0,0 +1,225 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar + +/* + * Shared value grammars for the .nspawn settings parsers (systemd src/nspawn/nspawn-settings.c and the + * helpers it calls in nspawn-mount.c / nspawn-network.c / nspawn-expose-ports.c). + * + * Everything here is pinned to systemd 0f0682b773, the commit recorded in + * systemd-build/build/last_commit_hash. Browse it at https://github.com/systemd/systemd/blob/0f0682b773. + * + * Several of these are general-purpose (the safe_ato* number terminal, the case-insensitive boolean, + * the colon-delimited interface name, the address-family list) and are candidates for promotion into + * Combinators.kt; they live here only so this batch does not collide with parallel edits there. + */ + +// --------------------------------------------------------------------------------------------------- +// Numbers as systemd's safe_atoi()/safe_atou*() read them (src/basic/parse-util.c). +// +// Both helpers call mangle_base() first, which recognises the Python-style `0b`/`0B` (binary) and +// `0o`/`0O` (octal) prefixes, and then strtol()/strtoul() with base 0, which adds C's `0x` hex and +// leading-zero octal plus an optional `+`/`-` sign. mangle_base() only looks at the very start of the +// string, so a sign in front of `0b`/`0o` defeats it and strtol() then stops at the `b`/`o` — such +// spellings are rejected, and the shape below does not allow them. + +/** + * An integer in `[minInclusive, maxExclusive)` spelled any way safe_atoi()/safe_atou*() accept: + * decimal, `0x` hex, leading-zero octal, `0o` octal or `0b` binary, with an optional sign on the + * decimal/hex/octal forms. The range check is done on the real value, so it holds however the number + * is spelled. + * + * A negative value is only in range when [minInclusive] allows it. safe_atou() accepts `-0` (it only + * refuses a minus sign on a non-zero result) and so does this terminal, by treating it as zero. + * + * Unlike [UnsignedNumberTerminal] this also understands the `0b`/`0o` prefixes and signs. + */ +class SafeAtoNumberTerminal( + private val minInclusive: Long, + private val maxExclusive: Long, +) : TerminalCombinator { + + private fun span(value: String, offset: Int): String? = SHAPE.matchAt(value, offset)?.value + + /** The numeric value of [text], or null when it does not fit a Long (and so is out of range anyway). */ + private fun valueOf(text: String): Long? { + if (text.length > 2 && (text.startsWith("0b") || text.startsWith("0B"))) return text.drop(2).toLongOrNull(2) + if (text.length > 2 && (text.startsWith("0o") || text.startsWith("0O"))) return text.drop(2).toLongOrNull(8) + val negative = text.startsWith("-") + val body = text.removePrefix("-").removePrefix("+") + val magnitude = when { + body.length > 2 && (body.startsWith("0x") || body.startsWith("0X")) -> body.drop(2).toLongOrNull(16) + body.length > 1 && body.startsWith("0") -> body.drop(1).toLongOrNull(8) + else -> body.toLongOrNull() + } ?: return null + return if (negative) -magnitude else magnitude + } + + private fun inRange(text: String): Boolean = valueOf(text)?.let { it >= minInclusive && it < maxExclusive } ?: false + + override fun SyntacticMatch(value: String, offset: Int): MatchResult { + val text = span(value, offset) ?: return NoMatch + return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) + } + + override fun SemanticMatch(value: String, offset: Int): MatchResult { + val text = span(value, offset) ?: return NoMatch + if (!inRange(text)) return NoMatch.copy(longestMatch = offset) + return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) + } + + override fun parse(value: String, offset: Int): Sequence { + val text = span(value, offset) ?: return sequenceOf(Stuck(offset, setOf(this))) + return sequenceOf(Parse(offset + text.length, listOf(ParsedToken(offset, offset + text.length, text, this, inRange(text))))) + } + + override fun toString(): String = "SafeAto($minInclusive,$maxExclusive)" + + private companion object { + // The prefixed forms come first so `0b1`/`0x1f` are not read as a lone octal `0`. + val SHAPE = Regex("""0[bB][01]+|0[oO][0-7]+|[+-]?(?:0[xX][0-9a-fA-F]+|0[0-7]*|[1-9][0-9]*)""") + } +} + +/** A [SafeAtoNumberTerminal] coloured as a literal, like the other number terminals. */ +fun safeAtoNumber(minInclusive: Long, maxExclusive: Long): Combinator = + Labeled(Role.LITERAL, SafeAtoNumberTerminal(minInclusive, maxExclusive)) + + +// --------------------------------------------------------------------------------------------------- +// Booleans — parse_boolean() (src/basic/parse-util.c) compares with STRCASE_IN_SET, so `Yes`, `TRUE` +// and `Off` are as good as their lower-case spellings. The shared [BOOLEAN] is case-sensitive; this +// one is not. + +/** parse_boolean(), case-insensitively. */ +val CASE_INSENSITIVE_BOOLEAN = FlexibleLiteralChoiceTerminal(*BOOLEAN_SPELLINGS, ignoreCase = true) + +/** + * A DEFINE_STRING_TABLE_LOOKUP_WITH_BOOLEAN enum: string_table_lookup_from_string_with_boolean() + * (src/basic/string-table.c) tries parse_boolean() first and then the table with a case-sensitive + * streq(). The names come first so that [CASE_INSENSITIVE_BOOLEAN] cannot strand part of a name. + */ +fun enumWithBoolean(vararg names: String): Combinator = + SequenceCombinator(AlternativeCombinator(FlexibleLiteralChoiceTerminal(*names), CASE_INSENSITIVE_BOOLEAN), EOF()) + + +// --------------------------------------------------------------------------------------------------- +// Interface names — ifname_valid() (ifname_valid_full with no flags, src/basic/socket-util.c): +// printable ASCII 33…126 except `:`, `/` and `%`, at most IFNAMSIZ-1 = 15 bytes, not `.`/`..`, not +// `all`/`default`, and not all digits. (parse_ifindex() also refuses anything safe_atoi() reads as a +// positive number, e.g. `0x10` or `+5`; those rare spellings are not modelled and are accepted.) +// +// Unlike [INTERFACE_NAME] the syntactic form stops at `:` as well as whitespace, because every nspawn +// caller splits `NAME:NAME` pairs on the colon. + +private const val NSPAWN_IFNAME_CHAR = """[\x21-\x7E&&[^:/%]]""" + +private fun nspawnIfnameSemantic(maxLength: Int) = + """(?!(?:[0-9]+|\.\.?|all|default)(?!$NSPAWN_IFNAME_CHAR))$NSPAWN_IFNAME_CHAR{1,$maxLength}""" + +/** One interface name in a `NAME[:NAME]` pair. */ +val COLON_DELIMITED_INTERFACE_NAME = RegexTerminal("""[^\s:]+""", nspawnIfnameSemantic(15)) + + +// --------------------------------------------------------------------------------------------------- +// Colon-separated path fields, as split by extract_first_word(..., ":", EXTRACT_DONT_COALESCE_SEPARATORS) +// (src/basic/extract-word.c). With no EXTRACT_UNQUOTE/EXTRACT_CUNESCAPE flag a backslash simply makes +// the next character literal (so `\:` embeds a colon) and quotes are ordinary characters; a trailing lone +// backslash is an error. Whitespace is NOT a separator here, so a path may contain spaces. + +/** The characters one colon-delimited field may contain. */ +private const val COLON_FIELD_CHAR = """(?:[^:\\]|\\[\s\S])""" + +/** Any non-empty colon-delimited field — the lenient (syntactic) shape shared by the terminals below. */ +private const val COLON_FIELD = """$COLON_FIELD_CHAR+""" + +/** A colon-delimited field that must be an absolute path: path_is_absolute() only checks for a leading `/`. */ +val COLON_FIELD_ABSOLUTE_PATH = RegexTerminal(COLON_FIELD, """\\?/$COLON_FIELD_CHAR*""") + +/** + * A colon-delimited nspawn *source* path — source_path_parse() (src/nspawn/nspawn-mount.c): non-empty; + * either `+` followed by an absolute path (relative to the container image) or any other path, which + * is made absolute against the working directory, so relative paths are fine. + */ +val COLON_FIELD_SOURCE_PATH = RegexTerminal(COLON_FIELD, """\+\\?/$COLON_FIELD_CHAR*|(?!\+)$COLON_FIELD_CHAR+""") + +/** + * A colon-delimited field that must be absolute *and* normalized — path_is_absolute() && + * path_is_normalized() (src/basic/path-util.c): no empty (`//`), `.` or `..` component. A trailing + * slash is fine. The component length limit (NAME_MAX) is not modelled. + */ +val COLON_FIELD_NORMALIZED_PATH = RegexTerminal(COLON_FIELD, normalizedAbsolutePath("""[^/:\\]|\\[\s\S]""", fieldEnd = """:|$""")) + +/** + * The rest of the value as an absolute, normalized path, colons and backslashes included verbatim — + * for the trailing field that a parser strdup()s instead of extracting (PivotRoot='s second path). + */ +val REST_NORMALIZED_PATH = RegexTerminal("""[\s\S]+""", normalizedAbsolutePath("""[^/]""", fieldEnd = """$""")) + +/** + * `/`, or one or more `/component`s with an optional trailing slash, where no component is `.`/`..`. + * [fieldEnd] is what may follow the path (the end of the value, or also a `:` separator). + */ +private fun normalizedAbsolutePath(componentChar: String, fieldEnd: String): String { + val component = """(?!\.\.?(?:/|$fieldEnd))(?:$componentChar)+""" + return """(?:/$component)+/?|/""" +} + + +// --------------------------------------------------------------------------------------------------- +// User names — valid_user_group_name(name, 0), i.e. the STRICT flavour (src/basic/user-util.c): a +// letter or underscore, then letters, digits, `_` or `-`, at most 31 bytes in total (the utmp ut_user +// field). No dots, no leading digit or dash, no numeric UIDs. + +/** One whitespace-separated user name in strict valid_user_group_name() form. */ +val STRICT_USER_NAME = RegexTerminal("""\S+""", """[A-Za-z_][A-Za-z0-9_-]{0,30}(?![A-Za-z0-9_-])""") + + +// --------------------------------------------------------------------------------------------------- +// 128-bit IDs — sd_id128_from_string() (src/libsystemd/sd-id128/sd-id128.c): 32 hex digits, or the +// 8-4-4-4-12 UUID form, optionally behind a case-insensitive `urn:uuid:` prefix, which then requires the +// UUID form. Hex digits are case-insensitive. + +private const val ID128_PLAIN = """[0-9a-fA-F]{32}""" +private const val ID128_UUID = """[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}""" + +// id128_from_string_nonzero() additionally refuses the all-zero ID. +private const val ID128_NONZERO = """(?=[0-]*[1-9a-fA-F])""" + +/** The `urn:uuid:` prefix sd_id128_from_string() strips with startswith_no_case(). */ +val ID128_URN_PREFIX = RegexTerminal("""(?i)urn:uuid:""", """(?i)urn:uuid:""") + +/** A non-zero 128-bit ID in UUID (dashed) form only. */ +val NONZERO_ID128_UUID = RegexTerminal("""[0-9A-Za-z-]+""", """$ID128_NONZERO$ID128_UUID""") + +/** A non-zero 128-bit ID in either the plain 32-hex-digit or the UUID form. */ +val NONZERO_ID128 = RegexTerminal("""[0-9A-Za-z-]+""", """$ID128_NONZERO(?:$ID128_PLAIN|$ID128_UUID)""") + + +// --------------------------------------------------------------------------------------------------- +// Address families — af_from_name() (src/basic/af-list.c). The lookup table is gperf-generated with +// `--ignore-case` (src/basic/meson.build), and af_from_name() retries a failed lookup with `AF_` +// prepended, so `AF_INET`, `af_inet`, `inet` and `INET6` all resolve. +// +// The canonical names are those of ConfigParseAddressFamiliesOptionValue (the AF_* macros in +// , minus AF_UNSPEC and AF_MAX). +private val ADDRESS_FAMILY_CANONICAL_NAMES = arrayOf( + "AF_ALG", "AF_APPLETALK", "AF_ASH", "AF_ATMPVC", "AF_ATMSVC", "AF_AX25", "AF_BLUETOOTH", "AF_BRIDGE", + "AF_CAIF", "AF_CAN", "AF_DECnet", "AF_ECONET", "AF_FILE", "AF_IB", "AF_IEEE802154", "AF_INET", + "AF_INET6", "AF_IPX", "AF_IRDA", "AF_ISDN", "AF_IUCV", "AF_KCM", "AF_KEY", "AF_LLC", "AF_LOCAL", + "AF_MCTP", "AF_MPLS", "AF_NETBEUI", "AF_NETLINK", "AF_NETROM", "AF_NFC", "AF_PACKET", "AF_PHONET", + "AF_PPPOX", "AF_QIPCRTR", "AF_RDS", "AF_ROSE", "AF_ROUTE", "AF_RXRPC", "AF_SECURITY", "AF_SMC", + "AF_SNA", "AF_TIPC", "AF_UNIX", "AF_VSOCK", "AF_WANPIPE", "AF_X25", "AF_XDP", +) + +private val REMOVED_ADDRESS_FAMILIES = mapOf( + "AF_DECnet" to "AF_DECnet is obsolete: DECnet support was removed from the Linux kernel in 6.1.", + "AF_IRDA" to "AF_IRDA is obsolete: IrDA support was removed from the Linux kernel in 4.17.", + "AF_ECONET" to "AF_ECONET is obsolete: Acorn Econet support was removed from the Linux kernel in 3.5.", + "AF_WANPIPE" to "AF_WANPIPE is obsolete: WANPIPE support was removed from the Linux kernel in 2.6.21.", +) + +/** An address family name, with or without the `AF_` prefix, in any case. */ +val ADDRESS_FAMILY_NAME = FlexibleLiteralChoiceTerminal( + *ADDRESS_FAMILY_CANONICAL_NAMES, + *ADDRESS_FAMILY_CANONICAL_NAMES.map { it.removePrefix("AF_") }.toTypedArray(), + ignoreCase = true, +).deprecating(REMOVED_ADDRESS_FAMILIES + REMOVED_ADDRESS_FAMILIES.mapKeys { it.key.removePrefix("AF_") }) diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindOptionValueTest.kt new file mode 100644 index 00000000..5b9e708a --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindOptionValueTest.kt @@ -0,0 +1,63 @@ +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 ConfigParseBindOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // From TEST-13-NSPAWN and systemd-nspawn(1): empty source (temporary dir), relative source, `+` prefix, + // escaped colons, and the bind options parse_mount_bind_options knows. + // language="unit file (systemd)" + val file = """ + [Files] + Bind=/var/lib/foo + BindReadOnly=/tmp/ephemeral-config + Bind=+/sysroot/ostree/deploy/os/var:/var + Bind=/tmp/dir:/foo + Bind=/tmp/dir:/also-foo:noidmap,norbind + BindReadOnly=/etc/ld.so.cache:/etc/ld.so.cache + Bind=/tmp/owneridmap/bind:/home/testuser:owneridmap + BindReadOnly=/tmp/rootidmap/bind:/mnt:rootidmap + Bind=:/scratch + Bind=relative/dir:/mnt + Bind=/path\:with\:colons:/mnt + Bind=/a:/b: + Bind=/a:/b:rbind,,idmap, + Bind=/my dir:/mnt/my dir + Bind=+/foo + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // A lone path must be absolute; empty or relative destination; `+` must be followed by an absolute path; + // unknown bind options. + // language="unit file (systemd)" + val file = """ + [Files] + Bind=relative + Bind=/a: + Bind=/a:relative + Bind=+relative:/b + Bind=/a:/b:bogus + Bind=/a:/b:rbind:x + BindReadOnly=/a:/b:ro + Bind=+:/b + """.trimIndent() + + setupFileInEditor("file.nspawn", 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/ConfigParseBindUserOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindUserOptionValueTest.kt new file mode 100644 index 00000000..d5ce3b14 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindUserOptionValueTest.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 ConfigParseBindUserOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // valid_user_group_name in strict mode: up to 31 characters. + // language="unit file (systemd)" + val file = """ + [Files] + BindUser= + BindUser=testuser + BindUser=root + BindUser=alice bob _svc + BindUser=user-name_1 + BindUser=a234567890123456789012345678901 + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Leading digit or dash, a dot, 32 characters, and a numeric UID are all refused by the strict check. + // language="unit file (systemd)" + val file = """ + [Files] + BindUser=1user + BindUser=john.doe + BindUser=a2345678901234567890123456789012 + BindUser=-dash + BindUser=1000 + """.trimIndent() + + setupFileInEditor("file.nspawn", 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/ConfigParseCapabilityOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCapabilityOptionValueTest.kt new file mode 100644 index 00000000..866a38bc --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCapabilityOptionValueTest.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 ConfigParseCapabilityOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // Values from systemd's TEST-13-NSPAWN plus the C edges: `all`, any-case names (gperf --ignore-case), + // and numeric capabilities read by safe_atoi (base 0: hex, octal, 0b/0o, sign) up to CAP_LIMIT (62). + // language="unit file (systemd)" + val file = """ + [Exec] + Capability=CAP_BLOCK_SUSPEND CAP_BPF CAP_CHOWN + DropCapability=CAP_AUDIT_CONTROL CAP_AUDIT_WRITE + AmbientCapability=CAP_BPF CAP_CHOWN + Capability=all + DropCapability=all + Capability=cap_net_admin Cap_Sys_Admin + Capability=12 0x15 062 0 +3 0b101 0o7 + DropCapability=CAP_NET_ADMIN CAP_SYS_TIME + Capability=62 + AmbientCapability=CAP_CHECKPOINT_RESTORE CAP_WAKE_ALARM + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Unknown name; comma is not a separator; above CAP_LIMIT; `all` is case-sensitive; negative; one bad word in a list. + // language="unit file (systemd)" + val file = """ + [Exec] + Capability=CAP_BOGUS + DropCapability=CAP_NET_ADMIN,CAP_SYS_ADMIN + AmbientCapability=63 + Capability=ALL + Capability=-1 + DropCapability=CAP_NET_ADMIN CAP_NOPE + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(6, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExposePortOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExposePortOptionValueTest.kt new file mode 100644 index 00000000..7f6a410f --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExposePortOptionValueTest.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 ConfigParseExposePortOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // From TEST-13-NSPAWN; ports are safe_atou16 (base 0). + // language="unit file (systemd)" + val file = """ + [Network] + Port=80 + Port=81:8181 + Port=tcp:60 + Port=udp:60:61 + Port=65535 + Port=0x50 + Port=tcp:0x50:010 + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Rejected spellings from TEST-13-NSPAWN (--port=-1, :, icmp:..., tcp::8080, 8080:) plus port 0, overflow, + // an upper-case protocol (startswith is case-sensitive) and a third field. + // language="unit file (systemd)" + val file = """ + [Network] + Port=0 + Port=-1 + Port=: + Port=icmp:80:8080 + Port=tcp::8080 + Port=8080: + Port=65536 + Port=TCP:80 + Port=80:8080:1 + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(9, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseId128OptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseId128OptionValueTest.kt new file mode 100644 index 00000000..e94ec094 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseId128OptionValueTest.kt @@ -0,0 +1,50 @@ +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 ConfigParseId128OptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // From TEST-13-NSPAWN and src/libsystemd/sd-id128/test-id128.c. + // language="unit file (systemd)" + val file = """ + [Exec] + MachineID=f28f129b51874b1280a89421ec4b4ad4 + MachineID=deadbeef-dead-dead-beef-111111111111 + MachineID=F28F129B51874B1280A89421EC4B4AD4 + MachineID=urn:uuid:01020304-0506-0708-090a-0b0c0d0e0f10 + MachineID=URN:UUID:01020304-0506-0708-090a-0b0c0d0e0f10 + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // All-zero IDs, urn: without dashes, misplaced/missing dash, too long, too short (test-id128.c), garbage. + // language="unit file (systemd)" + val file = """ + [Exec] + MachineID=00000000000000000000000000000000 + MachineID=00000000-0000-0000-0000-000000000000 + MachineID=urn:uuid:0102030405060708090a0b0c0d0e0f10 + MachineID=01020304-0506-0708-090a0b0c0d0e0f10 + MachineID=01020304-0506-0708-090a-0b0c0d0e0f101 + MachineID=f28f129b51874b1280a89421ec4b4ad + MachineID=xyz + """.trimIndent() + + setupFileInEditor("file.nspawn", 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/ConfigParseInaccessibleOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInaccessibleOptionValueTest.kt new file mode 100644 index 00000000..60c82e70 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInaccessibleOptionValueTest.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 + +class ConfigParseInaccessibleOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // From TEST-13-NSPAWN; the value is taken verbatim, so spaces and colons are part of the path. + // language="unit file (systemd)" + val file = """ + [Files] + Inaccessible=/opt/inaccessible + Inaccessible=/var + Inaccessible=/path with spaces + Inaccessible=/a:b + Inaccessible=/ + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Only absolute paths are accepted. + // language="unit file (systemd)" + val file = """ + [Files] + Inaccessible=opt/x + Inaccessible=./x + Inaccessible=~/x + """.trimIndent() + + setupFileInEditor("file.nspawn", 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/ConfigParseInterfacePairOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInterfacePairOptionValueTest.kt new file mode 100644 index 00000000..3db4b33a --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInterfacePairOptionValueTest.kt @@ -0,0 +1,52 @@ +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 ConfigParseInterfacePairOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // From TEST-13-NSPAWN; an empty container-side name after the colon means `not given`. + // language="unit file (systemd)" + val file = """ + [Network] + Interface=sd-shared1 sd-shared2:sd-renamed2 sd-shared3:sd-altname3 + MACVLAN=sd-macvlan1 sd-macvlan2:my-macvlan2 sd-macvlanloong + IPVLAN=sd-ipvlan1 sd-ipvlan2:my-ipvlan2 sd-ipvlanlooong + Interface=eth0: + Interface=eth0 wlan0 + MACVLAN=enp0s31f6 + IPVLAN=a.b:c-d + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Reserved name, all digits, a second colon, too long (either side), `/`, and a reserved name later in the list. + // language="unit file (systemd)" + val file = """ + [Network] + Interface=all + MACVLAN=0123 + IPVLAN=eth0:host0:x + Interface=eth0:averyveryverylongname + Interface=averyveryverylongname + MACVLAN=eth/0 + IPVLAN=eth0 default + """.trimIndent() + + setupFileInEditor("file.nspawn", 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/ConfigParseLinkJournalOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLinkJournalOptionValueTest.kt new file mode 100644 index 00000000..804ac90b --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLinkJournalOptionValueTest.kt @@ -0,0 +1,51 @@ +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 ConfigParseLinkJournalOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // parse_link_journal's names plus parse_boolean (case-insensitive). + // language="unit file (systemd)" + val file = """ + [Exec] + LinkJournal=no + LinkJournal=auto + LinkJournal=guest + LinkJournal=host + LinkJournal=try-guest + LinkJournal=try-host + LinkJournal=yes + LinkJournal=false + LinkJournal=0 + LinkJournal=On + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Unknown mode; the names are compared with streq(). + // language="unit file (systemd)" + val file = """ + [Exec] + LinkJournal=try-auto + LinkJournal=Guest + LinkJournal=persistent + """.trimIndent() + + setupFileInEditor("file.nspawn", 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/ConfigParseNetworkZoneOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseNetworkZoneOptionValueTest.kt new file mode 100644 index 00000000..33ccd0e2 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseNetworkZoneOptionValueTest.kt @@ -0,0 +1,47 @@ +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 ConfigParseNetworkZoneOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // `vz-` + name must be a valid ifname: up to 12 characters; digits-only is fine once prefixed. + // language="unit file (systemd)" + val file = """ + [Network] + Zone=sd-zone0 + Zone=foo + Zone=abcdefghijkl + Zone=123 + Zone=a.b + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // 13 characters is too long; `/`, space and `%` are not ifname characters. + // language="unit file (systemd)" + val file = """ + [Network] + Zone=abcdefghijklm + Zone=foo/bar + Zone=my zone + Zone=a%d + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(4, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt new file mode 100644 index 00000000..96fb5991 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.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 ConfigParseOomScoreAdjustOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // safe_atoi reads base 0 after mangle_base: sign, hex, octal, 0o and 0b all work; range is -1000..1000. + // language="unit file (systemd)" + val file = """ + [Exec] + OOMScoreAdjust=32 + OOMScoreAdjust=-1000 + OOMScoreAdjust=1000 + OOMScoreAdjust=0 + OOMScoreAdjust=+5 + OOMScoreAdjust=0x10 + OOMScoreAdjust=-0x3e8 + OOMScoreAdjust=0o17 + OOMScoreAdjust=0b101 + OOMScoreAdjust=017 + OOMScoreAdjust=-0 + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Out of range (also when spelled in hex), not a number, and a sign defeating mangle_base's 0b prefix. + // language="unit file (systemd)" + val file = """ + [Exec] + OOMScoreAdjust=1001 + OOMScoreAdjust=-1001 + OOMScoreAdjust=abc + OOMScoreAdjust=0x3e9 + OOMScoreAdjust=1.5 + OOMScoreAdjust=+0b1 + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(6, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOverlayOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOverlayOptionValueTest.kt new file mode 100644 index 00000000..788bc280 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOverlayOptionValueTest.kt @@ -0,0 +1,54 @@ +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 ConfigParseOverlayOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // From TEST-13-NSPAWN and systemd-nspawn(1): an empty upper directory, relative sources, `+` prefixes, escaped colons. + // language="unit file (systemd)" + val file = """ + [Files] + Overlay=+/var::/var + Overlay=/tmp/1:/tmp/2:/tmp/3:/var + OverlayReadOnly=/tmp/1:/tmp/2:/tmp/3:/var + Overlay=/lower:/upper + Overlay=lower:upper + Overlay=/l1:/l2::/dest + OverlayReadOnly=+/usr:/tmp/upper:/usr + Overlay=/a\:b:/c + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Fewer than two paths, empty lower directories, and a destination that is not absolute + // (with three or more paths the destination keeps any `+`, so `+/c` is not absolute). + // language="unit file (systemd)" + val file = """ + [Files] + Overlay=/only + Overlay=/a: + Overlay=:/b + Overlay=/a:/b:relative + Overlay=/a::/b:/c + OverlayReadOnly=/a:/b:+/c + Overlay=+x:/b + """.trimIndent() + + setupFileInEditor("file.nspawn", 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/ConfigParsePivotRootOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePivotRootOptionValueTest.kt new file mode 100644 index 00000000..f58c8cf1 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePivotRootOptionValueTest.kt @@ -0,0 +1,52 @@ +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 ConfigParsePivotRootOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // Absolute, normalized paths; a trailing slash and dot-prefixed names are fine. + // language="unit file (systemd)" + val file = """ + [Exec] + PivotRoot=/sysroot + PivotRoot=/sysroot/ostree/deploy:/sysroot + PivotRoot=/new/:/old/ + PivotRoot=/new: + PivotRoot=/ + PivotRoot=/a/.hidden:/b/..c + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // path_is_normalized refuses `//`, `.` and `..` components in either path; both must be absolute. + // language="unit file (systemd)" + val file = """ + [Exec] + PivotRoot=relative + PivotRoot=/a//b + PivotRoot=/a/./b + PivotRoot=/a/../b + PivotRoot=/new:relative + PivotRoot=/new:/old//x + PivotRoot=:/old + PivotRoot=/a/. + """.trimIndent() + + setupFileInEditor("file.nspawn", 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/ConfigParseResolvConfOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseResolvConfOptionValueTest.kt new file mode 100644 index 00000000..39fee3dd --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseResolvConfOptionValueTest.kt @@ -0,0 +1,60 @@ +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 ConfigParseResolvConfOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // Every resolv_conf_mode_table entry plus booleans (case-insensitive). + // language="unit file (systemd)" + val file = """ + [Exec] + ResolvConf=off + ResolvConf=copy-host + ResolvConf=copy-static + ResolvConf=copy-uplink + ResolvConf=copy-stub + ResolvConf=replace-host + ResolvConf=replace-static + ResolvConf=replace-uplink + ResolvConf=replace-stub + ResolvConf=bind-host + ResolvConf=bind-static + ResolvConf=bind-uplink + ResolvConf=bind-stub + ResolvConf=delete + ResolvConf=auto + ResolvConf=yes + ResolvConf=no + ResolvConf=True + ResolvConf=OFF + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Not a table entry; table entries are case-sensitive. + // language="unit file (systemd)" + val file = """ + [Exec] + ResolvConf=copy + ResolvConf=Copy-Host + ResolvConf=bind-hosts + """.trimIndent() + + setupFileInEditor("file.nspawn", 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/ConfigParseRestrictAddressFamiliesOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseRestrictAddressFamiliesOptionValueTest.kt new file mode 100644 index 00000000..affaf3af --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseRestrictAddressFamiliesOptionValueTest.kt @@ -0,0 +1,72 @@ +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.ConfigParseRestrictAddressFamiliesOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.deprecatedTokens +import org.junit.Test + +class ConfigParseRestrictAddressFamiliesOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // From TEST-13-NSPAWN (--restrict-address-families=): with or without AF_, any case, comma or space separated, + // quoted words, and a bare `~`. + // language="unit file (systemd)" + val file = """ + [Exec] + RestrictAddressFamilies=unix,inet + RestrictAddressFamilies=unix inet + RestrictAddressFamilies=AF_UNIX AF_INET + RestrictAddressFamilies=~netlink + RestrictAddressFamilies=none + RestrictAddressFamilies=unix,inet,netlink + RestrictAddressFamilies=unix inet AF_NETLINK + RestrictAddressFamilies=~packet + RestrictAddressFamilies=~ + RestrictAddressFamilies=AF_INET, AF_INET6 + RestrictAddressFamilies=af_inet6 + RestrictAddressFamilies=~ AF_UNIX + RestrictAddressFamilies="AF_INET" 'inet6' + RestrictAddressFamilies=AF_DECnet decnet + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Unknown family, `~` only allowed at the start, `none` only allowed on its own. + // language="unit file (systemd)" + val file = """ + [Exec] + RestrictAddressFamilies=AF_BOGUS + RestrictAddressFamilies=inet ~unix + RestrictAddressFamilies=~none + RestrictAddressFamilies=none inet + RestrictAddressFamilies=AF-INET + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(5, highlights) + } + + @Test + fun testRemovedFamiliesAreDeprecatedInEitherSpelling() { + // af_from_name still resolves kernel-removed families, so they are valid but flagged as obsolete, + // whether written with the AF_ prefix or without it and in any case. + val grammar = ConfigParseRestrictAddressFamiliesOptionValue().combinator + val deprecated = grammar.deprecatedTokens("inet,AF_DECnet irda") + assertEquals(2, deprecated.size) + assertTrue(deprecated[0].message.contains("AF_DECnet")) + assertTrue(deprecated[1].message.contains("AF_IRDA")) + assertTrue(grammar.deprecatedTokens("AF_INET inet6 unix").isEmpty()) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTmpfsOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTmpfsOptionValueTest.kt new file mode 100644 index 00000000..2e2fc892 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseTmpfsOptionValueTest.kt @@ -0,0 +1,48 @@ +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 ConfigParseTmpfsOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // From TEST-13-NSPAWN; the option string is passed to the kernel unchecked. + // language="unit file (systemd)" + val file = """ + [Files] + TemporaryFileSystem=/tmp + TemporaryFileSystem=/opt/tmp + TemporaryFileSystem=/var:rw,nosuid,noexec + TemporaryFileSystem=/tmp: + TemporaryFileSystem=/tmp:mode=1777,size=10% + TemporaryFileSystem=/path\:x:mode=0700 + TemporaryFileSystem=/ + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // The path must be absolute. + // language="unit file (systemd)" + val file = """ + [Files] + TemporaryFileSystem=tmp + TemporaryFileSystem=:mode=0755 + TemporaryFileSystem=relative:size=1M + """.trimIndent() + + setupFileInEditor("file.nspawn", 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/ConfigParseVethExtraOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVethExtraOptionValueTest.kt new file mode 100644 index 00000000..09adeaf3 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVethExtraOptionValueTest.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 ConfigParseVethExtraOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // From TEST-13-NSPAWN. An invalid container-side name is silently replaced by the host-side one, so it is accepted. + // language="unit file (systemd)" + val file = """ + [Network] + VirtualEthernetExtra=my-fancy-veth1 + VirtualEthernetExtra=fancy-veth2:my-fancy-veth2 + VirtualEthernetExtra=ve-host: + VirtualEthernetExtra=ve-host:name-that-is-far-too-long + VirtualEthernetExtra=ve-a:all + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Empty/too long/reserved host-side name, anything after a second colon (even empty), whitespace in the name. + // language="unit file (systemd)" + val file = """ + [Network] + VirtualEthernetExtra=:ve-b + VirtualEthernetExtra=a:b:c + VirtualEthernetExtra=a:b: + VirtualEthernetExtra=averyveryverylongname + VirtualEthernetExtra=default + VirtualEthernetExtra=host name:x + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(6, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVolatileModeOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVolatileModeOptionValueTest.kt new file mode 100644 index 00000000..04684493 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVolatileModeOptionValueTest.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 ConfigParseVolatileModeOptionValueTest : AbstractUnitFileTest() { + + @Test + fun testValidValues() { + // Table names plus every parse_boolean spelling (case-insensitive). + // language="unit file (systemd)" + val file = """ + [Files] + Volatile=no + Volatile=yes + Volatile=state + Volatile=overlay + Volatile=true + Volatile=false + Volatile=1 + Volatile=0 + Volatile=on + Volatile=off + Volatile=Yes + Volatile=TRUE + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(0, highlights) + } + + @Test + fun testInvalidValues() { + // Table names are matched case-sensitively. + // language="unit file (systemd)" + val file = """ + [Files] + Volatile=State + Volatile=overlayfs + Volatile=maybe + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(3, highlights) + } +} From f69054b8f37da6cfcc6cbaa3bfbb4183b0101471 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Fri, 23 Oct 2026 18:46:12 -0700 Subject: [PATCH 2/7] fix: address review findings in nspawn validators (nspawn batch) - VirtualEthernetExtra=: the container-side field honours backslash escapes like extract_first_word(), so `a:b\:c` is accepted. - safeAtoNumber: accept a sign after the 0b/0o prefix (`0b-1`), which strtol() in base 2/8 takes after mangle_base() strips the prefix. - Interface names: refuse spellings parse_ifindex() reads as a positive index (`0x10`, `+5`, `0o7`, `0b1`), as ifname_valid() does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ConfigParseInterfacePairOptionValues.kt | 10 +- .../ConfigParseOomScoreAdjustOptionValue.kt | 3 +- .../ai/ConfigParseVethExtraOptionValue.kt | 9 +- .../grammar/BatchNspawnCombinators.kt | 98 ++++++++++++++----- ...ConfigParseInterfacePairOptionValueTest.kt | 9 +- ...onfigParseOomScoreAdjustOptionValueTest.kt | 6 +- .../ai/ConfigParseVethExtraOptionValueTest.kt | 8 +- 7 files changed, 104 insertions(+), 39 deletions(-) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInterfacePairOptionValues.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInterfacePairOptionValues.kt index ef72c63d..23c2a288 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInterfacePairOptionValues.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInterfacePairOptionValues.kt @@ -44,9 +44,13 @@ private val INTERFACE_PAIR_LIST: Combinator = SequenceCombinator( * * The value is a whitespace-separated list (extract_first_word with WHITESPACE). Each word is split once * at the first `:`: the host-side name must pass ifname_valid() (printable ASCII except `:`, `/`, `%`; at - * most 15 bytes; not all digits, `.`, `..`, `all` or `default`), and the container-side name, if present - * and non-empty, must too — so `eth0:` is the same as `eth0`, and a second colon is an invalid name. - * Unlike most nspawn settings an invalid word is a hard error that fails loading the file. + * most 15 bytes; not all digits, `.`, `..`, `all` or `default`, and nothing parse_ifindex() reads as a + * positive index such as `0x10` or `+5`), and the container-side name, if present and non-empty, must + * too — so `eth0:` is the same as `eth0`, and a second colon is an invalid name. Unlike most nspawn + * settings an invalid word is a hard error that fails loading the file. + * + * Deliberately lenient: backslash escapes in the outer whitespace split are not modelled, so + * `eth0\ x` is read as two names here although C sees one invalid name `eth0 x`. * * The prefixed default name (`mv-eth0`) may exceed 15 bytes; nspawn shortens it later, so the * host-side name alone is what is length-checked. diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt index 9a7cce07..a889679b 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt @@ -23,7 +23,8 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * Validator for the .nspawn `[Exec] OOMScoreAdjust=` setting (config_parse_oom_score_adjust, ltype 0). * * parse_oom_score_adjust() reads the value with safe_atoi() — base 0 after mangle_base(), so besides - * plain decimal it takes a sign, `0x` hex, leading-zero octal, `0o` octal and `0b` binary — and then + * plain decimal it takes a sign, `0x` hex, leading-zero octal, `0o` octal and `0b` binary (where a sign goes after the prefix, + * as in `0b-1`) — and then * requires OOM_SCORE_ADJ_MIN…OOM_SCORE_ADJ_MAX, i.e. -1000…1000 inclusive. An empty value resets the * setting and never reaches the grammar. Out-of-range or malformed input is logged and ignored. */ diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVethExtraOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVethExtraOptionValue.kt index 2a42d6b3..c6cfe114 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVethExtraOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVethExtraOptionValue.kt @@ -3,6 +3,7 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON_DELIMITED_INTERFACE_NAME +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.COLON_FIELD_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 @@ -23,10 +24,12 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram /** * The container-side name. veth_extra_parse() does not reject an invalid one: if it is empty or fails - * ifname_valid() it is silently replaced by the host-side name. So anything without a further colon is - * accepted here. + * ifname_valid() it is silently replaced by the host-side name. So any field is accepted here; the + * field ends at the next unescaped colon, because extract_first_word() (no flags) makes the character + * after a backslash literal — `a:b\:c` is host `a`, container `b:c` (invalid, so it falls back to `a`) + * — and refuses a lone trailing backslash. */ -private val CONTAINER_SIDE = RegexTerminal("""[^:]+""", """[^:]+""") +private val CONTAINER_SIDE = RegexTerminal("""$COLON_FIELD_CHAR+""", """$COLON_FIELD_CHAR+""") /** * Validator for the .nspawn `[Network] VirtualEthernetExtra=` setting (config_parse_veth_extra, ltype 0). diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt index 76815865..d663c0ab 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt @@ -19,12 +19,15 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gra // `0o`/`0O` (octal) prefixes, and then strtol()/strtoul() with base 0, which adds C's `0x` hex and // leading-zero octal plus an optional `+`/`-` sign. mangle_base() only looks at the very start of the // string, so a sign in front of `0b`/`0o` defeats it and strtol() then stops at the `b`/`o` — such -// spellings are rejected, and the shape below does not allow them. +// spellings are rejected, and the shape below does not allow them. A sign *after* the prefix is fine, +// though: mangle_base() strips `0b`/`0o` and strtol() in base 2/8 still takes a sign, so `0b-1` is -1 +// and `0o+120` is 80. (strtol() would also skip whitespace after the prefix, as in `0b 1`; that is not +// modelled, as every caller here also splits on whitespace or the spelling is absurd.) /** * An integer in `[minInclusive, maxExclusive)` spelled any way safe_atoi()/safe_atou*() accept: - * decimal, `0x` hex, leading-zero octal, `0o` octal or `0b` binary, with an optional sign on the - * decimal/hex/octal forms. The range check is done on the real value, so it holds however the number + * decimal, `0x` hex, leading-zero octal, `0o` octal or `0b` binary, with an optional sign (before the + * number for decimal/hex/leading-zero octal, after the prefix for `0b`/`0o`). The range check is done on the real value, so it holds however the number * is spelled. * * A negative value is only in range when [minInclusive] allows it. safe_atou() accepts `-0` (it only @@ -37,23 +40,9 @@ class SafeAtoNumberTerminal( private val maxExclusive: Long, ) : TerminalCombinator { - private fun span(value: String, offset: Int): String? = SHAPE.matchAt(value, offset)?.value - - /** The numeric value of [text], or null when it does not fit a Long (and so is out of range anyway). */ - private fun valueOf(text: String): Long? { - if (text.length > 2 && (text.startsWith("0b") || text.startsWith("0B"))) return text.drop(2).toLongOrNull(2) - if (text.length > 2 && (text.startsWith("0o") || text.startsWith("0O"))) return text.drop(2).toLongOrNull(8) - val negative = text.startsWith("-") - val body = text.removePrefix("-").removePrefix("+") - val magnitude = when { - body.length > 2 && (body.startsWith("0x") || body.startsWith("0X")) -> body.drop(2).toLongOrNull(16) - body.length > 1 && body.startsWith("0") -> body.drop(1).toLongOrNull(8) - else -> body.toLongOrNull() - } ?: return null - return if (negative) -magnitude else magnitude - } + private fun span(value: String, offset: Int): String? = SAFE_ATO_SHAPE.matchAt(value, offset)?.value - private fun inRange(text: String): Boolean = valueOf(text)?.let { it >= minInclusive && it < maxExclusive } ?: false + private fun inRange(text: String): Boolean = safeAtoValueOf(text)?.let { it >= minInclusive && it < maxExclusive } ?: false override fun SyntacticMatch(value: String, offset: Int): MatchResult { val text = span(value, offset) ?: return NoMatch @@ -72,13 +61,33 @@ class SafeAtoNumberTerminal( } override fun toString(): String = "SafeAto($minInclusive,$maxExclusive)" +} - private companion object { - // The prefixed forms come first so `0b1`/`0x1f` are not read as a lone octal `0`. - val SHAPE = Regex("""0[bB][01]+|0[oO][0-7]+|[+-]?(?:0[xX][0-9a-fA-F]+|0[0-7]*|[1-9][0-9]*)""") - } +// The prefixed forms come first so `0b1`/`0x1f` are not read as a lone octal `0`. Kotlin's +// toLongOrNull() takes the sign that may follow `0b`/`0o`. +private val SAFE_ATO_SHAPE = Regex("""0[bB][+-]?[01]+|0[oO][+-]?[0-7]+|[+-]?(?:0[xX][0-9a-fA-F]+|0[0-7]*|[1-9][0-9]*)""") + +/** + * The numeric value of [text] read the way safe_atoi()/safe_atou*() read it, assuming [text] is one + * whole [SAFE_ATO_SHAPE] match; null when it does not fit a Long (and so is out of any range anyway). + */ +private fun safeAtoValueOf(text: String): Long? { + if (text.length > 2 && (text.startsWith("0b") || text.startsWith("0B"))) return text.drop(2).toLongOrNull(2) + if (text.length > 2 && (text.startsWith("0o") || text.startsWith("0O"))) return text.drop(2).toLongOrNull(8) + val negative = text.startsWith("-") + val body = text.removePrefix("-").removePrefix("+") + val magnitude = when { + body.length > 2 && (body.startsWith("0x") || body.startsWith("0X")) -> body.drop(2).toLongOrNull(16) + body.length > 1 && body.startsWith("0") -> body.drop(1).toLongOrNull(8) + else -> body.toLongOrNull() + } ?: return null + return if (negative) -magnitude else magnitude } +/** Whether all of [text] is a number safe_atoi() reads as an `int` greater than zero — parse_ifindex() success. */ +private fun isPositiveIfindex(text: String): Boolean = + SAFE_ATO_SHAPE.matchEntire(text) != null && safeAtoValueOf(text)?.let { it in 1..Int.MAX_VALUE.toLong() } == true + /** A [SafeAtoNumberTerminal] coloured as a literal, like the other number terminals. */ fun safeAtoNumber(minInclusive: Long, maxExclusive: Long): Combinator = Labeled(Role.LITERAL, SafeAtoNumberTerminal(minInclusive, maxExclusive)) @@ -104,19 +113,54 @@ fun enumWithBoolean(vararg names: String): Combinator = // --------------------------------------------------------------------------------------------------- // Interface names — ifname_valid() (ifname_valid_full with no flags, src/basic/socket-util.c): // printable ASCII 33…126 except `:`, `/` and `%`, at most IFNAMSIZ-1 = 15 bytes, not `.`/`..`, not -// `all`/`default`, and not all digits. (parse_ifindex() also refuses anything safe_atoi() reads as a -// positive number, e.g. `0x10` or `+5`; those rare spellings are not modelled and are accepted.) +// `all`/`default`, not all digits, and not anything parse_ifindex() accepts — i.e. anything safe_atoi() +// reads as a positive `int`, such as `0x10`, `+5`, `0o7` or `0b1`. (`0x0`, `-5` or an out-of-range +// `0xFFFFFFFFFF` are not ifindexes and so are valid names.) // // Unlike [INTERFACE_NAME] the syntactic form stops at `:` as well as whitespace, because every nspawn // caller splits `NAME:NAME` pairs on the colon. +// +// Not modelled: the outer extract_first_word(WHITESPACE, 0) that splits Interface=/MACVLAN=/IPVLAN= +// lists honours backslash escapes, so `a\ b` is one (invalid) word there but two valid names here, +// and an escaping backslash is counted towards the 15-byte limit here although C drops it. private const val NSPAWN_IFNAME_CHAR = """[\x21-\x7E&&[^:/%]]""" private fun nspawnIfnameSemantic(maxLength: Int) = """(?!(?:[0-9]+|\.\.?|all|default)(?!$NSPAWN_IFNAME_CHAR))$NSPAWN_IFNAME_CHAR{1,$maxLength}""" +/** + * A [RegexTerminal] for interface names that additionally refuses, as ifname_valid() does, a span that + * parse_ifindex() would read as an interface index. + */ +private class NspawnInterfaceNameTerminal : TerminalCombinator { + private val regex = RegexTerminal("""[^\s:]+""", nspawnIfnameSemantic(15)) + + override fun SyntacticMatch(value: String, offset: Int): MatchResult = regex.SyntacticMatch(value, offset).owned() + + override fun SemanticMatch(value: String, offset: Int): MatchResult { + val result = regex.SemanticMatch(value, offset) + if (result.matchResult >= 0 && isPositiveIfindex(value.substring(offset, result.matchResult))) { + return NoMatch.copy(longestMatch = offset) + } + return result.owned() + } + + override fun parse(value: String, offset: Int): Sequence = regex.parse(value, offset).map { step -> + when (step) { + is Parse -> step.copy(tokens = step.tokens.map { it.copy(terminal = this, valid = it.valid && !isPositiveIfindex(it.text)) }) + is Stuck -> Stuck(step.offset, setOf(this)) + } + } + + /** Report this terminal, not the wrapped regex, as the one that matched. */ + private fun MatchResult.owned(): MatchResult = if (matchResult < 0) this else copy(terminals = listOf(this@NspawnInterfaceNameTerminal)) + + override fun toString(): String = "NspawnIfname" +} + /** One interface name in a `NAME[:NAME]` pair. */ -val COLON_DELIMITED_INTERFACE_NAME = RegexTerminal("""[^\s:]+""", nspawnIfnameSemantic(15)) +val COLON_DELIMITED_INTERFACE_NAME: TerminalCombinator = NspawnInterfaceNameTerminal() // --------------------------------------------------------------------------------------------------- @@ -126,7 +170,7 @@ val COLON_DELIMITED_INTERFACE_NAME = RegexTerminal("""[^\s:]+""", nspawnIfnameSe // backslash is an error. Whitespace is NOT a separator here, so a path may contain spaces. /** The characters one colon-delimited field may contain. */ -private const val COLON_FIELD_CHAR = """(?:[^:\\]|\\[\s\S])""" +internal const val COLON_FIELD_CHAR = """(?:[^:\\]|\\[\s\S])""" /** Any non-empty colon-delimited field — the lenient (syntactic) shape shared by the terminals below. */ private const val COLON_FIELD = """$COLON_FIELD_CHAR+""" diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInterfacePairOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInterfacePairOptionValueTest.kt index 3db4b33a..bb81ead8 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInterfacePairOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInterfacePairOptionValueTest.kt @@ -19,6 +19,7 @@ class ConfigParseInterfacePairOptionValueTest : AbstractUnitFileTest() { Interface=eth0 wlan0 MACVLAN=enp0s31f6 IPVLAN=a.b:c-d + Interface=0x0 -5 0b-1 0xFFFFFFFFFF """.trimIndent() setupFileInEditor("file.nspawn", file) @@ -30,7 +31,8 @@ class ConfigParseInterfacePairOptionValueTest : AbstractUnitFileTest() { @Test fun testInvalidValues() { - // Reserved name, all digits, a second colon, too long (either side), `/`, and a reserved name later in the list. + // Reserved name, all digits, a second colon, too long (either side), `/`, a reserved name later in the list, + // and names that parse_ifindex() reads as a positive index (so ifname_valid() refuses them). // language="unit file (systemd)" val file = """ [Network] @@ -41,12 +43,15 @@ class ConfigParseInterfacePairOptionValueTest : AbstractUnitFileTest() { Interface=averyveryverylongname MACVLAN=eth/0 IPVLAN=eth0 default + Interface=0x10 + MACVLAN=+5 + IPVLAN=eth0:0o7 """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(7, highlights) + assertSize(10, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt index 96fb5991..b1da68df 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt @@ -23,6 +23,9 @@ class ConfigParseOomScoreAdjustOptionValueTest : AbstractUnitFileTest() { OOMScoreAdjust=0b101 OOMScoreAdjust=017 OOMScoreAdjust=-0 + OOMScoreAdjust=0b-1 + OOMScoreAdjust=0o+120 + OOMScoreAdjust=0O-1750 """.trimIndent() setupFileInEditor("file.nspawn", file) @@ -44,12 +47,13 @@ class ConfigParseOomScoreAdjustOptionValueTest : AbstractUnitFileTest() { OOMScoreAdjust=0x3e9 OOMScoreAdjust=1.5 OOMScoreAdjust=+0b1 + OOMScoreAdjust=0b-1111101001 """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(6, highlights) + assertSize(7, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVethExtraOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVethExtraOptionValueTest.kt index 09adeaf3..d4f00ec8 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVethExtraOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVethExtraOptionValueTest.kt @@ -17,6 +17,8 @@ class ConfigParseVethExtraOptionValueTest : AbstractUnitFileTest() { VirtualEthernetExtra=ve-host: VirtualEthernetExtra=ve-host:name-that-is-far-too-long VirtualEthernetExtra=ve-a:all + VirtualEthernetExtra=a:b\:c + VirtualEthernetExtra=ve-a:0x10 """.trimIndent() setupFileInEditor("file.nspawn", file) @@ -28,7 +30,8 @@ class ConfigParseVethExtraOptionValueTest : AbstractUnitFileTest() { @Test fun testInvalidValues() { - // Empty/too long/reserved host-side name, anything after a second colon (even empty), whitespace in the name. + // Empty/too long/reserved/ifindex-like host-side name, anything after a second colon (even empty), whitespace in + // the name. // language="unit file (systemd)" val file = """ [Network] @@ -38,12 +41,13 @@ class ConfigParseVethExtraOptionValueTest : AbstractUnitFileTest() { VirtualEthernetExtra=averyveryverylongname VirtualEthernetExtra=default VirtualEthernetExtra=host name:x + VirtualEthernetExtra=0x10:ve-b """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(6, highlights) + assertSize(7, highlights) } } From 9c12afa1c96907ad13d078af08b0c7e3dba7dd4d Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Fri, 23 Oct 2026 19:07:10 -0700 Subject: [PATCH 3/7] refactor: give nspawn batch grammar helpers family-unique names Several batch branches declared public helpers with the same name in the shared grammar package (SafeAtouTerminal, UINT32_LIMIT, safeAtou, SafeAtoNumberTerminal, BOOLEAN_ANY_CASE, COALESCED_COLONS). Each branch compiled alone but any two merged together would not. Prefix them per family so the batch PRs can merge in any order; consolidating them into Combinators.kt is left for a follow-up. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../optionvalues/grammar/BatchNspawnCombinators.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt index d663c0ab..24ec4a83 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt @@ -35,7 +35,7 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gra * * Unlike [UnsignedNumberTerminal] this also understands the `0b`/`0o` prefixes and signs. */ -class SafeAtoNumberTerminal( +class NspawnSafeAtoNumberTerminal( private val minInclusive: Long, private val maxExclusive: Long, ) : TerminalCombinator { @@ -88,9 +88,9 @@ private fun safeAtoValueOf(text: String): Long? { private fun isPositiveIfindex(text: String): Boolean = SAFE_ATO_SHAPE.matchEntire(text) != null && safeAtoValueOf(text)?.let { it in 1..Int.MAX_VALUE.toLong() } == true -/** A [SafeAtoNumberTerminal] coloured as a literal, like the other number terminals. */ +/** A [NspawnSafeAtoNumberTerminal] coloured as a literal, like the other number terminals. */ fun safeAtoNumber(minInclusive: Long, maxExclusive: Long): Combinator = - Labeled(Role.LITERAL, SafeAtoNumberTerminal(minInclusive, maxExclusive)) + Labeled(Role.LITERAL, NspawnSafeAtoNumberTerminal(minInclusive, maxExclusive)) // --------------------------------------------------------------------------------------------------- From d3b949212137123dcb8f26d8428db6d1bbe51d52 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sat, 24 Oct 2026 06:47:01 -0700 Subject: [PATCH 4/7] fix: align nspawn validators with systemd's parsers Checked against fuzz-nspawn-settings built at 0f0682b773: - OOMScoreAdjust= and Port= accept whitespace between a 0b/0o prefix and the digits (strtol skips it), e.g. `0b 1`, `tcp:0o 120`. Capability= keeps the strict form, since the word split comes first. - Interface=/MACVLAN=/IPVLAN= refuse a name that runs into whitespace after a backslash run whose length is not a multiple of 4. Each word is unescaped twice, so `a\ b` is the invalid name "a b" and `a\\ b` leaves a dangling backslash. - Bind= option-list checks are now a separate test that states they fail only at container start (parse_mount_bind_options), not at parse time. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ai/ConfigParseExposePortOptionValue.kt | 7 ++- .../ConfigParseInterfacePairOptionValues.kt | 5 +- .../ConfigParseOomScoreAdjustOptionValue.kt | 7 ++- .../grammar/BatchNspawnCombinators.kt | 52 ++++++++++++++----- .../ai/ConfigParseBindOptionValueTest.kt | 25 +++++++-- .../ConfigParseCapabilityOptionValueTest.kt | 6 ++- .../ConfigParseExposePortOptionValueTest.kt | 12 ++++- ...ConfigParseInterfacePairOptionValueTest.kt | 12 ++++- ...onfigParseOomScoreAdjustOptionValueTest.kt | 10 +++- .../ai/ConfigParseVethExtraOptionValueTest.kt | 3 +- 10 files changed, 106 insertions(+), 33 deletions(-) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExposePortOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExposePortOptionValue.kt index edb1de6f..5a1de039 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExposePortOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExposePortOptionValue.kt @@ -21,8 +21,11 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L315-L326 (accepted and rejected --port= spellings) */ -/** parse_ip_port(): a safe_atou16() number (base 0) that is not zero. */ -private val PORT = safeAtoNumber(1, 65536) +/** + * parse_ip_port(): a safe_atou16() number (base 0) that is not zero. Whitespace between a `0b`/`0o` prefix and + * the digits is skipped by strtoul() (`Port=0o 120` is valid), but not in front of the number (`80: 90` is not). + */ +private val PORT = safeAtoNumber(1, 65536, spaceAfterPrefix = true) /** * Validator for the .nspawn `[Network] Port=` setting (config_parse_expose_port, ltype 0). diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInterfacePairOptionValues.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInterfacePairOptionValues.kt index 23c2a288..49e02e1b 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInterfacePairOptionValues.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInterfacePairOptionValues.kt @@ -49,8 +49,9 @@ private val INTERFACE_PAIR_LIST: Combinator = SequenceCombinator( * too — so `eth0:` is the same as `eth0`, and a second colon is an invalid name. Unlike most nspawn * settings an invalid word is a hard error that fails loading the file. * - * Deliberately lenient: backslash escapes in the outer whitespace split are not modelled, so - * `eth0\ x` is read as two names here although C sees one invalid name `eth0 x`. + * Backslash escapes: each word is unescaped twice (whitespace split, then colon split), so a name that + * runs into whitespace after a backslash run that is not a multiple of 4 long is refused, as C refuses it — + * `eth0\ x` is one invalid name `eth0 x`, and `eth0\\ x` leaves a trailing backslash for the colon split. * * The prefixed default name (`mv-eth0`) may exceed 15 bytes; nspawn shortens it later, so the * host-side name alone is what is length-checked. diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt index a889679b..c50b81f9 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt @@ -23,12 +23,11 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * Validator for the .nspawn `[Exec] OOMScoreAdjust=` setting (config_parse_oom_score_adjust, ltype 0). * * parse_oom_score_adjust() reads the value with safe_atoi() — base 0 after mangle_base(), so besides - * plain decimal it takes a sign, `0x` hex, leading-zero octal, `0o` octal and `0b` binary (where a sign goes after the prefix, - * as in `0b-1`) — and then - * requires OOM_SCORE_ADJ_MIN…OOM_SCORE_ADJ_MAX, i.e. -1000…1000 inclusive. An empty value resets the + * plain decimal it takes a sign, `0x` hex, leading-zero octal, `0o` octal and `0b` binary (where whitespace and a + * sign may follow the prefix, as in `0b-1` or `0b 1`, because strtol() skips them) — and then requires OOM_SCORE_ADJ_MIN…OOM_SCORE_ADJ_MAX, i.e. -1000…1000 inclusive. An empty value resets the * setting and never reaches the grammar. Out-of-range or malformed input is logged and ignored. */ class ConfigParseOomScoreAdjustOptionValue : SimpleGrammarOptionValues( "config_parse_oom_score_adjust", - SequenceCombinator(safeAtoNumber(-1000, 1001), EOF()), + SequenceCombinator(safeAtoNumber(-1000, 1001, spaceAfterPrefix = true), EOF()), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt index 24ec4a83..fd818c56 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt @@ -21,8 +21,10 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gra // string, so a sign in front of `0b`/`0o` defeats it and strtol() then stops at the `b`/`o` — such // spellings are rejected, and the shape below does not allow them. A sign *after* the prefix is fine, // though: mangle_base() strips `0b`/`0o` and strtol() in base 2/8 still takes a sign, so `0b-1` is -1 -// and `0o+120` is 80. (strtol() would also skip whitespace after the prefix, as in `0b 1`; that is not -// modelled, as every caller here also splits on whitespace or the spelling is absurd.) +// and `0o+120` is 80. strtol() also skips whitespace after the prefix, so `OOMScoreAdjust=0b 1` and +// `Port=0o 120` are valid (confirmed with fuzz-nspawn-settings at 0f0682b773). That is only modelled +// for callers that pass `spaceAfterPrefix = true`: in a whitespace-separated list (Capability=) the +// word split happens first, so `0b 1` there is the invalid word `0b` followed by `1`. /** * An integer in `[minInclusive, maxExclusive)` spelled any way safe_atoi()/safe_atou*() accept: @@ -38,9 +40,12 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gra class NspawnSafeAtoNumberTerminal( private val minInclusive: Long, private val maxExclusive: Long, + spaceAfterPrefix: Boolean = false, ) : TerminalCombinator { - private fun span(value: String, offset: Int): String? = SAFE_ATO_SHAPE.matchAt(value, offset)?.value + private val shape = if (spaceAfterPrefix) NSPAWN_SAFE_ATO_SHAPE_SPACED else SAFE_ATO_SHAPE + + private fun span(value: String, offset: Int): String? = shape.matchAt(value, offset)?.value private fun inRange(text: String): Boolean = safeAtoValueOf(text)?.let { it >= minInclusive && it < maxExclusive } ?: false @@ -67,13 +72,17 @@ class NspawnSafeAtoNumberTerminal( // toLongOrNull() takes the sign that may follow `0b`/`0o`. private val SAFE_ATO_SHAPE = Regex("""0[bB][+-]?[01]+|0[oO][+-]?[0-7]+|[+-]?(?:0[xX][0-9a-fA-F]+|0[0-7]*|[1-9][0-9]*)""") +// As [SAFE_ATO_SHAPE], plus the isspace() run strtol() skips between a `0b`/`0o` prefix and the digits. +private val NSPAWN_SAFE_ATO_SHAPE_SPACED = + Regex("""0[bB][ \t\u000B\u000C\r]*[+-]?[01]+|0[oO][ \t\u000B\u000C\r]*[+-]?[0-7]+|[+-]?(?:0[xX][0-9a-fA-F]+|0[0-7]*|[1-9][0-9]*)""") + /** * The numeric value of [text] read the way safe_atoi()/safe_atou*() read it, assuming [text] is one * whole [SAFE_ATO_SHAPE] match; null when it does not fit a Long (and so is out of any range anyway). */ private fun safeAtoValueOf(text: String): Long? { - if (text.length > 2 && (text.startsWith("0b") || text.startsWith("0B"))) return text.drop(2).toLongOrNull(2) - if (text.length > 2 && (text.startsWith("0o") || text.startsWith("0O"))) return text.drop(2).toLongOrNull(8) + if (text.length > 2 && (text.startsWith("0b") || text.startsWith("0B"))) return text.drop(2).trimStart().toLongOrNull(2) + if (text.length > 2 && (text.startsWith("0o") || text.startsWith("0O"))) return text.drop(2).trimStart().toLongOrNull(8) val negative = text.startsWith("-") val body = text.removePrefix("-").removePrefix("+") val magnitude = when { @@ -89,8 +98,8 @@ private fun isPositiveIfindex(text: String): Boolean = SAFE_ATO_SHAPE.matchEntire(text) != null && safeAtoValueOf(text)?.let { it in 1..Int.MAX_VALUE.toLong() } == true /** A [NspawnSafeAtoNumberTerminal] coloured as a literal, like the other number terminals. */ -fun safeAtoNumber(minInclusive: Long, maxExclusive: Long): Combinator = - Labeled(Role.LITERAL, NspawnSafeAtoNumberTerminal(minInclusive, maxExclusive)) +fun safeAtoNumber(minInclusive: Long, maxExclusive: Long, spaceAfterPrefix: Boolean = false): Combinator = + Labeled(Role.LITERAL, NspawnSafeAtoNumberTerminal(minInclusive, maxExclusive, spaceAfterPrefix)) // --------------------------------------------------------------------------------------------------- @@ -120,9 +129,14 @@ fun enumWithBoolean(vararg names: String): Combinator = // Unlike [INTERFACE_NAME] the syntactic form stops at `:` as well as whitespace, because every nspawn // caller splits `NAME:NAME` pairs on the colon. // -// Not modelled: the outer extract_first_word(WHITESPACE, 0) that splits Interface=/MACVLAN=/IPVLAN= -// lists honours backslash escapes, so `a\ b` is one (invalid) word there but two valid names here, -// and an escaping backslash is counted towards the 15-byte limit here although C drops it. +// Backslashes: Interface=/MACVLAN=/IPVLAN= unescape each word twice — once in the outer +// extract_first_word(WHITESPACE, 0) and again in the extract_first_word(":", 0) that splits the pair — +// and VirtualEthernetExtra= once. A name that runs into whitespace after a run of n backslashes is +// therefore invalid unless n is a multiple of 4: an odd n escapes the whitespace into the name (`a\ b`, +// "interface name not valid: a b"), and n = 2 (mod 4) leaves a lone trailing backslash for the second +// pass (`a\\ b`, "Failed to extract first word"). [nspawnIfnameEscapesWhitespace] models exactly that; +// both verdicts were confirmed with fuzz-nspawn-settings at 0f0682b773. Still not modelled: an escaping +// backslash elsewhere is counted towards the 15-byte limit here although C drops it. private const val NSPAWN_IFNAME_CHAR = """[\x21-\x7E&&[^:/%]]""" @@ -140,7 +154,7 @@ private class NspawnInterfaceNameTerminal : TerminalCombinator { override fun SemanticMatch(value: String, offset: Int): MatchResult { val result = regex.SemanticMatch(value, offset) - if (result.matchResult >= 0 && isPositiveIfindex(value.substring(offset, result.matchResult))) { + if (result.matchResult >= 0 && isRefused(value, offset, result.matchResult)) { return NoMatch.copy(longestMatch = offset) } return result.owned() @@ -148,17 +162,31 @@ private class NspawnInterfaceNameTerminal : TerminalCombinator { override fun parse(value: String, offset: Int): Sequence = regex.parse(value, offset).map { step -> when (step) { - is Parse -> step.copy(tokens = step.tokens.map { it.copy(terminal = this, valid = it.valid && !isPositiveIfindex(it.text)) }) + is Parse -> step.copy(tokens = step.tokens.map { it.copy(terminal = this, valid = it.valid && !isRefused(value, it.start, it.end)) }) is Stuck -> Stuck(step.offset, setOf(this)) } } + private fun isRefused(value: String, start: Int, end: Int): Boolean = + isPositiveIfindex(value.substring(start, end)) || nspawnIfnameEscapesWhitespace(value, start, end) + /** Report this terminal, not the wrapped regex, as the one that matched. */ private fun MatchResult.owned(): MatchResult = if (matchResult < 0) this else copy(terminals = listOf(this@NspawnInterfaceNameTerminal)) override fun toString(): String = "NspawnIfname" } +/** + * Whether the name `value[start, end)` ends in a run of backslashes that is not a multiple of 4 long and is + * followed by whitespace — see the comment above [NSPAWN_IFNAME_CHAR] for why C refuses that. + */ +private fun nspawnIfnameEscapesWhitespace(value: String, start: Int, end: Int): Boolean { + if (end >= value.length || !value[end].isWhitespace()) return false + var run = 0 + while (end - run - 1 >= start && value[end - run - 1] == '\\') run++ + return run % 4 != 0 +} + /** One interface name in a `NAME[:NAME]` pair. */ val COLON_DELIMITED_INTERFACE_NAME: TerminalCombinator = NspawnInterfaceNameTerminal() diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindOptionValueTest.kt index 5b9e708a..9cc794dd 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindOptionValueTest.kt @@ -39,8 +39,8 @@ class ConfigParseBindOptionValueTest : AbstractUnitFileTest() { @Test fun testInvalidValues() { - // A lone path must be absolute; empty or relative destination; `+` must be followed by an absolute path; - // unknown bind options. + // A lone path must be absolute; empty or relative destination; `+` must be followed by an absolute path. + // These are rejected by bind_mount_parse() itself. // language="unit file (systemd)" val file = """ [Files] @@ -48,16 +48,33 @@ class ConfigParseBindOptionValueTest : AbstractUnitFileTest() { Bind=/a: Bind=/a:relative Bind=+relative:/b + Bind=+:/b + """.trimIndent() + + setupFileInEditor("file.nspawn", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + assertSize(5, highlights) + } + + @Test + fun testOptionsRejectedOnlyAtContainerStart() { + // Deliberately stricter than the settings parser: bind_mount_parse() stores the option string verbatim, so + // fuzz-nspawn-settings accepts these, but parse_mount_bind_options() fails the container start with + // "Invalid bind mount option" (`ro` is not an option, read-only is BindReadOnly=; `rbind:x` is one word). + // language="unit file (systemd)" + val file = """ + [Files] Bind=/a:/b:bogus Bind=/a:/b:rbind:x BindReadOnly=/a:/b:ro - Bind=+:/b """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(8, highlights) + assertSize(3, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCapabilityOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCapabilityOptionValueTest.kt index 866a38bc..d655f23b 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCapabilityOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCapabilityOptionValueTest.kt @@ -34,7 +34,8 @@ class ConfigParseCapabilityOptionValueTest : AbstractUnitFileTest() { @Test fun testInvalidValues() { - // Unknown name; comma is not a separator; above CAP_LIMIT; `all` is case-sensitive; negative; one bad word in a list. + // Unknown name; comma is not a separator; above CAP_LIMIT; `all` is case-sensitive; negative; one bad word in a list; + // `0b 1` is split into the words `0b` (invalid) and `1` before safe_atoi() sees it. // language="unit file (systemd)" val file = """ [Exec] @@ -44,12 +45,13 @@ class ConfigParseCapabilityOptionValueTest : AbstractUnitFileTest() { Capability=ALL Capability=-1 DropCapability=CAP_NET_ADMIN CAP_NOPE + Capability=0b 1 """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(6, highlights) + assertSize(7, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExposePortOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExposePortOptionValueTest.kt index 7f6a410f..34fa7ed9 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExposePortOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExposePortOptionValueTest.kt @@ -19,6 +19,10 @@ class ConfigParseExposePortOptionValueTest : AbstractUnitFileTest() { Port=65535 Port=0x50 Port=tcp:0x50:010 + Port=0o+120 + Port=0b 1 + Port=80:0b 1 + Port=tcp:0o 120 """.trimIndent() setupFileInEditor("file.nspawn", file) @@ -31,7 +35,8 @@ class ConfigParseExposePortOptionValueTest : AbstractUnitFileTest() { @Test fun testInvalidValues() { // Rejected spellings from TEST-13-NSPAWN (--port=-1, :, icmp:..., tcp::8080, 8080:) plus port 0, overflow, - // an upper-case protocol (startswith is case-sensitive) and a third field. + // an upper-case protocol (startswith is case-sensitive), a third field, and whitespace around a port (parse_ip_port + // refuses leading whitespace; only the whitespace strtoul() skips after a 0b/0o prefix is fine). // language="unit file (systemd)" val file = """ [Network] @@ -44,12 +49,15 @@ class ConfigParseExposePortOptionValueTest : AbstractUnitFileTest() { Port=65536 Port=TCP:80 Port=80:8080:1 + Port=80: 90 + Port=tcp: 80 + Port=80 :90 """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(9, highlights) + assertSize(12, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInterfacePairOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInterfacePairOptionValueTest.kt index bb81ead8..307ca597 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInterfacePairOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInterfacePairOptionValueTest.kt @@ -20,6 +20,9 @@ class ConfigParseInterfacePairOptionValueTest : AbstractUnitFileTest() { MACVLAN=enp0s31f6 IPVLAN=a.b:c-d Interface=0x0 -5 0b-1 0xFFFFFFFFFF + Interface=a\\\\ b + Interface=a\:b c + Interface=a\b """.trimIndent() setupFileInEditor("file.nspawn", file) @@ -32,7 +35,8 @@ class ConfigParseInterfacePairOptionValueTest : AbstractUnitFileTest() { @Test fun testInvalidValues() { // Reserved name, all digits, a second colon, too long (either side), `/`, a reserved name later in the list, - // and names that parse_ifindex() reads as a positive index (so ifname_valid() refuses them). + // names that parse_ifindex() reads as a positive index (so ifname_valid() refuses them), and backslash runs + // before whitespace that C unescapes into the name or leaves dangling (all confirmed with fuzz-nspawn-settings). // language="unit file (systemd)" val file = """ [Network] @@ -46,12 +50,16 @@ class ConfigParseInterfacePairOptionValueTest : AbstractUnitFileTest() { Interface=0x10 MACVLAN=+5 IPVLAN=eth0:0o7 + Interface=a\ b + Interface=a\\ b + MACVLAN=a\\\ b + Interface=x a\:b\ c """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(10, highlights) + assertSize(14, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt index b1da68df..57793536 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt @@ -26,6 +26,8 @@ class ConfigParseOomScoreAdjustOptionValueTest : AbstractUnitFileTest() { OOMScoreAdjust=0b-1 OOMScoreAdjust=0o+120 OOMScoreAdjust=0O-1750 + OOMScoreAdjust=0b 1 + OOMScoreAdjust=0o -7 """.trimIndent() setupFileInEditor("file.nspawn", file) @@ -37,7 +39,8 @@ class ConfigParseOomScoreAdjustOptionValueTest : AbstractUnitFileTest() { @Test fun testInvalidValues() { - // Out of range (also when spelled in hex), not a number, and a sign defeating mangle_base's 0b prefix. + // Out of range (also when spelled in hex), not a number, a sign defeating mangle_base's 0b prefix, and + // whitespace strtol() does not skip (after a sign, after 0x, between a sign and the digits). // language="unit file (systemd)" val file = """ [Exec] @@ -48,12 +51,15 @@ class ConfigParseOomScoreAdjustOptionValueTest : AbstractUnitFileTest() { OOMScoreAdjust=1.5 OOMScoreAdjust=+0b1 OOMScoreAdjust=0b-1111101001 + OOMScoreAdjust=- 5 + OOMScoreAdjust=0x 5 + OOMScoreAdjust=0b - 1 """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(7, highlights) + assertSize(10, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVethExtraOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVethExtraOptionValueTest.kt index d4f00ec8..4b88c25c 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVethExtraOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVethExtraOptionValueTest.kt @@ -42,12 +42,13 @@ class ConfigParseVethExtraOptionValueTest : AbstractUnitFileTest() { VirtualEthernetExtra=default VirtualEthernetExtra=host name:x VirtualEthernetExtra=0x10:ve-b + VirtualEthernetExtra=a\ b:c """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(7, highlights) + assertSize(8, highlights) } } From 5476f9f98a282ce100e96442a08bb62fc9b7e266 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sat, 24 Oct 2026 07:41:08 -0700 Subject: [PATCH 5/7] refactor: use the shared case-insensitive BOOLEAN in nspawn validators 242.x now makes BOOLEAN case-insensitive, so the batch's own CASE_INSENSITIVE_BOOLEAN copy and the enumWithBoolean() helper are gone. LinkJournal=, ResolvConf= and Volatile= now spell out PreferValidAlternativeCombinator(names, BOOLEAN) inline, with the boolean spellings (off; no/yes) dropped from the case-sensitive names. Tests gain mixed-case booleans (Yes, TRUE, oFf) and mixed-case names that must stay invalid, all confirmed with fuzz-nspawn-settings at 0f0682b773. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ai/ConfigParseLinkJournalOptionValue.kt | 17 +++++++++--- .../ai/ConfigParseResolvConfOptionValue.kt | 26 ++++++++++++++----- .../ai/ConfigParseVolatileModeOptionValue.kt | 17 ++++++++++-- .../grammar/BatchNspawnCombinators.kt | 23 +++------------- .../ConfigParseLinkJournalOptionValueTest.kt | 8 +++++- .../ConfigParseResolvConfOptionValueTest.kt | 7 ++++- ...eRestrictAddressFamiliesOptionValueTest.kt | 6 +++-- .../ConfigParseVolatileModeOptionValueTest.kt | 6 ++++- 8 files changed, 73 insertions(+), 37 deletions(-) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLinkJournalOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLinkJournalOptionValue.kt index 49c00a6e..fdfe54eb 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLinkJournalOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseLinkJournalOptionValue.kt @@ -1,7 +1,11 @@ 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.enumWithBoolean +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.PreferValidAlternativeCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator /* * [Exec] LinkJournal= (.nspawn). @@ -19,9 +23,16 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * * parse_link_journal() compares with streq() against `auto`, `guest`, `host`, `try-guest` and * `try-host` (case-sensitive), and otherwise falls back to parse_boolean() (case-insensitive; true is - * treated as `auto`, false as `no`). Invalid input is logged and ignored. + * treated as `auto`, false as `no`). Invalid input is logged and ignored. The names come first so that + * the case-insensitive [BOOLEAN] cannot strand part of one. */ class ConfigParseLinkJournalOptionValue : SimpleGrammarOptionValues( "config_parse_link_journal", - enumWithBoolean("auto", "guest", "host", "try-guest", "try-host"), + SequenceCombinator( + PreferValidAlternativeCombinator( + FlexibleLiteralChoiceTerminal("auto", "guest", "host", "try-guest", "try-host"), + BOOLEAN, + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseResolvConfOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseResolvConfOptionValue.kt index 46a281e2..304a0093 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseResolvConfOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseResolvConfOptionValue.kt @@ -1,7 +1,11 @@ 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.enumWithBoolean +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.PreferValidAlternativeCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator /* * [Exec] ResolvConf= (.nspawn). @@ -23,14 +27,22 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * table names, matched case-sensitively: `off`, `copy-host`, `copy-static`, `copy-uplink`, `copy-stub`, * `replace-host`, `replace-static`, `replace-uplink`, `replace-stub`, `bind-host`, `bind-static`, * `bind-uplink`, `bind-stub`, `delete`, `auto`. + * + * Table entry 0 is `off`, a parse_boolean() spelling, so it is left to [BOOLEAN] (which also takes + * `OFF`) rather than listed with the names. */ class ConfigParseResolvConfOptionValue : SimpleGrammarOptionValues( "config_parse_resolv_conf", - enumWithBoolean( - "off", - "copy-host", "copy-static", "copy-uplink", "copy-stub", - "replace-host", "replace-static", "replace-uplink", "replace-stub", - "bind-host", "bind-static", "bind-uplink", "bind-stub", - "delete", "auto", + SequenceCombinator( + PreferValidAlternativeCombinator( + FlexibleLiteralChoiceTerminal( + "copy-host", "copy-static", "copy-uplink", "copy-stub", + "replace-host", "replace-static", "replace-uplink", "replace-stub", + "bind-host", "bind-static", "bind-uplink", "bind-stub", + "delete", "auto", + ), + BOOLEAN, + ), + EOF(), ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVolatileModeOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVolatileModeOptionValue.kt index 6397c707..a8939f87 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVolatileModeOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseVolatileModeOptionValue.kt @@ -1,7 +1,11 @@ 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.enumWithBoolean +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.PreferValidAlternativeCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator /* * [Files] Volatile= (.nspawn). @@ -21,8 +25,17 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * volatile_mode_from_string() is a DEFINE_STRING_TABLE_LOOKUP_WITH_BOOLEAN lookup: any parse_boolean() * spelling (case-insensitive — false means `no`, true means `yes`), or one of the table names `no`, * `yes`, `state`, `overlay`, matched case-sensitively. + * + * `no` and `yes` are parse_boolean() spellings, so they are left to [BOOLEAN] (which also takes `Yes` + * and `NO`) rather than listed with the names. */ class ConfigParseVolatileModeOptionValue : SimpleGrammarOptionValues( "config_parse_volatile_mode", - enumWithBoolean("no", "yes", "state", "overlay"), + SequenceCombinator( + PreferValidAlternativeCombinator( + FlexibleLiteralChoiceTerminal("state", "overlay"), + BOOLEAN, + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt index fd818c56..8710e5be 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt @@ -7,9 +7,9 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gra * Everything here is pinned to systemd 0f0682b773, the commit recorded in * systemd-build/build/last_commit_hash. Browse it at https://github.com/systemd/systemd/blob/0f0682b773. * - * Several of these are general-purpose (the safe_ato* number terminal, the case-insensitive boolean, - * the colon-delimited interface name, the address-family list) and are candidates for promotion into - * Combinators.kt; they live here only so this batch does not collide with parallel edits there. + * Several of these are general-purpose (the safe_ato* number terminal, the colon-delimited interface + * name, the address-family list) and are candidates for promotion into Combinators.kt; they live here + * only so this batch does not collide with parallel edits there. Booleans use the shared [BOOLEAN]. */ // --------------------------------------------------------------------------------------------------- @@ -102,23 +102,6 @@ fun safeAtoNumber(minInclusive: Long, maxExclusive: Long, spaceAfterPrefix: Bool Labeled(Role.LITERAL, NspawnSafeAtoNumberTerminal(minInclusive, maxExclusive, spaceAfterPrefix)) -// --------------------------------------------------------------------------------------------------- -// Booleans — parse_boolean() (src/basic/parse-util.c) compares with STRCASE_IN_SET, so `Yes`, `TRUE` -// and `Off` are as good as their lower-case spellings. The shared [BOOLEAN] is case-sensitive; this -// one is not. - -/** parse_boolean(), case-insensitively. */ -val CASE_INSENSITIVE_BOOLEAN = FlexibleLiteralChoiceTerminal(*BOOLEAN_SPELLINGS, ignoreCase = true) - -/** - * A DEFINE_STRING_TABLE_LOOKUP_WITH_BOOLEAN enum: string_table_lookup_from_string_with_boolean() - * (src/basic/string-table.c) tries parse_boolean() first and then the table with a case-sensitive - * streq(). The names come first so that [CASE_INSENSITIVE_BOOLEAN] cannot strand part of a name. - */ -fun enumWithBoolean(vararg names: String): Combinator = - SequenceCombinator(AlternativeCombinator(FlexibleLiteralChoiceTerminal(*names), CASE_INSENSITIVE_BOOLEAN), EOF()) - - // --------------------------------------------------------------------------------------------------- // Interface names — ifname_valid() (ifname_valid_full with no flags, src/basic/socket-util.c): // printable ASCII 33…126 except `:`, `/` and `%`, at most IFNAMSIZ-1 = 15 bytes, not `.`/`..`, not diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLinkJournalOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLinkJournalOptionValueTest.kt index 804ac90b..77b88f28 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLinkJournalOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseLinkJournalOptionValueTest.kt @@ -22,6 +22,9 @@ class ConfigParseLinkJournalOptionValueTest : AbstractUnitFileTest() { LinkJournal=false LinkJournal=0 LinkJournal=On + LinkJournal=Yes + LinkJournal=TRUE + LinkJournal=oFf """.trimIndent() setupFileInEditor("file.nspawn", file) @@ -40,12 +43,15 @@ class ConfigParseLinkJournalOptionValueTest : AbstractUnitFileTest() { LinkJournal=try-auto LinkJournal=Guest LinkJournal=persistent + LinkJournal=AUTO + LinkJournal=Try-Host + LinkJournal=nothing """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(3, highlights) + assertSize(6, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseResolvConfOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseResolvConfOptionValueTest.kt index 39fee3dd..879ed538 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseResolvConfOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseResolvConfOptionValueTest.kt @@ -31,6 +31,9 @@ class ConfigParseResolvConfOptionValueTest : AbstractUnitFileTest() { ResolvConf=no ResolvConf=True ResolvConf=OFF + ResolvConf=Yes + ResolvConf=TRUE + ResolvConf=oFf """.trimIndent() setupFileInEditor("file.nspawn", file) @@ -49,12 +52,14 @@ class ConfigParseResolvConfOptionValueTest : AbstractUnitFileTest() { ResolvConf=copy ResolvConf=Copy-Host ResolvConf=bind-hosts + ResolvConf=AUTO + ResolvConf=offline """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(3, highlights) + assertSize(5, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseRestrictAddressFamiliesOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseRestrictAddressFamiliesOptionValueTest.kt index affaf3af..735b2bd3 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseRestrictAddressFamiliesOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseRestrictAddressFamiliesOptionValueTest.kt @@ -29,6 +29,7 @@ class ConfigParseRestrictAddressFamiliesOptionValueTest : AbstractUnitFileTest() RestrictAddressFamilies=~ AF_UNIX RestrictAddressFamilies="AF_INET" 'inet6' RestrictAddressFamilies=AF_DECnet decnet + RestrictAddressFamilies=Unix INET6 Af_Netlink """.trimIndent() setupFileInEditor("file.nspawn", file) @@ -40,7 +41,7 @@ class ConfigParseRestrictAddressFamiliesOptionValueTest : AbstractUnitFileTest() @Test fun testInvalidValues() { - // Unknown family, `~` only allowed at the start, `none` only allowed on its own. + // Unknown family, `~` only allowed at the start, `none` only allowed on its own (and compared with streq()). // language="unit file (systemd)" val file = """ [Exec] @@ -49,13 +50,14 @@ class ConfigParseRestrictAddressFamiliesOptionValueTest : AbstractUnitFileTest() RestrictAddressFamilies=~none RestrictAddressFamilies=none inet RestrictAddressFamilies=AF-INET + RestrictAddressFamilies=None """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(5, highlights) + assertSize(6, highlights) } @Test diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVolatileModeOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVolatileModeOptionValueTest.kt index 04684493..192c522c 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVolatileModeOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseVolatileModeOptionValueTest.kt @@ -24,6 +24,8 @@ class ConfigParseVolatileModeOptionValueTest : AbstractUnitFileTest() { Volatile=off Volatile=Yes Volatile=TRUE + Volatile=oFf + Volatile=NO """.trimIndent() setupFileInEditor("file.nspawn", file) @@ -42,12 +44,14 @@ class ConfigParseVolatileModeOptionValueTest : AbstractUnitFileTest() { Volatile=State Volatile=overlayfs Volatile=maybe + Volatile=OVERLAY + Volatile=stateful """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(3, highlights) + assertSize(5, highlights) } } From af913e437394601de0e24e2e1e83a5a7976fd70f Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sat, 24 Oct 2026 11:31:27 -0700 Subject: [PATCH 6/7] refactor: use the shared SafeAtoTerminal in nspawn validators (#563) Replace the branch-local NspawnSafeAtoNumberTerminal, safeAtoNumber(), SAFE_ATO_SHAPE/NSPAWN_SAFE_ATO_SHAPE_SPACED, safeAtoValueOf() and isPositiveIfindex() with the shared SafeAtoTerminal, written out at each call site: - OOMScoreAdjust=: signed, -1000..1000, whole value. - Port=: parse_ip_port, REFUSE_LEADING_WHITESPACE, 1..65535. - Capability=: signed, 0..62, whitespace-split word. - Interface names: parse_ifindex via SafeAtoTerminal.valueOf(). Now accepted, matching sd-check: OOMScoreAdjust=\v5, \v-5, \f-1000; Port=\v80, tcp:\v80:81, 80:\v81, and the strtoul wrap-around 0b -<2^64-80 in binary> (port 80). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ai/ConfigParseCapabilityOptionValue.kt | 12 +- .../ai/ConfigParseExposePortOptionValue.kt | 18 ++- .../ConfigParseOomScoreAdjustOptionValue.kt | 21 +++- .../grammar/BatchNspawnCombinators.kt | 110 +++--------------- .../ConfigParseExposePortOptionValueTest.kt | 26 ++++- ...onfigParseOomScoreAdjustOptionValueTest.kt | 15 +++ 6 files changed, 97 insertions(+), 105 deletions(-) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCapabilityOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCapabilityOptionValue.kt index e28f0051..287d1665 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCapabilityOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCapabilityOptionValue.kt @@ -5,10 +5,10 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.CAPABILITY_NAME 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.SafeAtoTerminal 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.safeAtoNumber /* * [Exec] Capability= / AmbientCapability= / DropCapability= (.nspawn). @@ -28,7 +28,15 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram /** One capability word: the literal `all`, a capability number, or a capability name. */ private val CAPABILITY_WORD = AlternativeCombinator( LiteralChoiceTerminal("all"), - safeAtoNumber(0, 63), + SafeAtoTerminal( + signed = true, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 62L, + acceptsWhitespace = false, + acceptsMinus = true, + ), CAPABILITY_NAME, ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExposePortOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExposePortOptionValue.kt index 5a1de039..404b54d1 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExposePortOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExposePortOptionValue.kt @@ -4,9 +4,9 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.Simp 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.SafeAtoTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.safeAtoNumber /* * [Network] Port= (.nspawn). @@ -22,10 +22,20 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram */ /** - * parse_ip_port(): a safe_atou16() number (base 0) that is not zero. Whitespace between a `0b`/`0o` prefix and - * the digits is skipped by strtoul() (`Port=0o 120` is valid), but not in front of the number (`80: 90` is not). + * parse_ip_port(): safe_atou16_full(s, SAFE_ATO_REFUSE_LEADING_WHITESPACE) (base 0), then zero is refused. + * Whitespace between a `0b`/`0o` prefix and the digits is skipped by strtoul() (`Port=0o 120` is valid), but a + * leading space or tab is refused (`80: 90` is not). The flag only checks `" \t\n\r"`, so a leading `\v` + * reaches strtoul(), which skips it (`Port=\v80` is valid). */ -private val PORT = safeAtoNumber(1, 65536, spaceAfterPrefix = true) +private val PORT = SafeAtoTerminal( + signed = false, + base = 0, + flags = setOf(SafeAtoTerminal.Flag.REFUSE_LEADING_WHITESPACE), + minInclusive = 1L, + maxInclusive = 65535L, + acceptsWhitespace = true, + acceptsMinus = true, +) /** * Validator for the .nspawn `[Network] Port=` setting (config_parse_expose_port, ltype 0). diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt index c50b81f9..61aedc45 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseOomScoreAdjustOptionValue.kt @@ -2,8 +2,8 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF +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.safeAtoNumber /* * [Exec] OOMScoreAdjust= (.nspawn). @@ -24,10 +24,23 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * * parse_oom_score_adjust() reads the value with safe_atoi() — base 0 after mangle_base(), so besides * plain decimal it takes a sign, `0x` hex, leading-zero octal, `0o` octal and `0b` binary (where whitespace and a - * sign may follow the prefix, as in `0b-1` or `0b 1`, because strtol() skips them) — and then requires OOM_SCORE_ADJ_MIN…OOM_SCORE_ADJ_MAX, i.e. -1000…1000 inclusive. An empty value resets the - * setting and never reaches the grammar. Out-of-range or malformed input is logged and ignored. + * sign may follow the prefix, as in `0b-1` or `0b 1`, because strtol() skips them; a leading `\v` or `\f` is + * skipped by strtol() too) — and then requires OOM_SCORE_ADJ_MIN…OOM_SCORE_ADJ_MAX, i.e. -1000…1000 inclusive. + * The shared [SafeAtoTerminal] models all of that. An empty value resets the setting and never reaches the + * grammar. Out-of-range or malformed input is logged and ignored. */ class ConfigParseOomScoreAdjustOptionValue : SimpleGrammarOptionValues( "config_parse_oom_score_adjust", - SequenceCombinator(safeAtoNumber(-1000, 1001, spaceAfterPrefix = true), EOF()), + SequenceCombinator( + SafeAtoTerminal( + signed = true, + base = 0, + flags = emptySet(), + minInclusive = -1000L, + maxInclusive = 1000L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt index 8710e5be..a68892a1 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchNspawnCombinators.kt @@ -7,101 +7,12 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gra * Everything here is pinned to systemd 0f0682b773, the commit recorded in * systemd-build/build/last_commit_hash. Browse it at https://github.com/systemd/systemd/blob/0f0682b773. * - * Several of these are general-purpose (the safe_ato* number terminal, the colon-delimited interface - * name, the address-family list) and are candidates for promotion into Combinators.kt; they live here - * only so this batch does not collide with parallel edits there. Booleans use the shared [BOOLEAN]. + * Several of these are general-purpose (the colon-delimited interface name, the address-family list) + * and are candidates for promotion into Combinators.kt; they live here only so this batch does not + * collide with parallel edits there. Booleans use the shared [BOOLEAN], and numbers the shared + * [SafeAtoTerminal]. */ -// --------------------------------------------------------------------------------------------------- -// Numbers as systemd's safe_atoi()/safe_atou*() read them (src/basic/parse-util.c). -// -// Both helpers call mangle_base() first, which recognises the Python-style `0b`/`0B` (binary) and -// `0o`/`0O` (octal) prefixes, and then strtol()/strtoul() with base 0, which adds C's `0x` hex and -// leading-zero octal plus an optional `+`/`-` sign. mangle_base() only looks at the very start of the -// string, so a sign in front of `0b`/`0o` defeats it and strtol() then stops at the `b`/`o` — such -// spellings are rejected, and the shape below does not allow them. A sign *after* the prefix is fine, -// though: mangle_base() strips `0b`/`0o` and strtol() in base 2/8 still takes a sign, so `0b-1` is -1 -// and `0o+120` is 80. strtol() also skips whitespace after the prefix, so `OOMScoreAdjust=0b 1` and -// `Port=0o 120` are valid (confirmed with fuzz-nspawn-settings at 0f0682b773). That is only modelled -// for callers that pass `spaceAfterPrefix = true`: in a whitespace-separated list (Capability=) the -// word split happens first, so `0b 1` there is the invalid word `0b` followed by `1`. - -/** - * An integer in `[minInclusive, maxExclusive)` spelled any way safe_atoi()/safe_atou*() accept: - * decimal, `0x` hex, leading-zero octal, `0o` octal or `0b` binary, with an optional sign (before the - * number for decimal/hex/leading-zero octal, after the prefix for `0b`/`0o`). The range check is done on the real value, so it holds however the number - * is spelled. - * - * A negative value is only in range when [minInclusive] allows it. safe_atou() accepts `-0` (it only - * refuses a minus sign on a non-zero result) and so does this terminal, by treating it as zero. - * - * Unlike [UnsignedNumberTerminal] this also understands the `0b`/`0o` prefixes and signs. - */ -class NspawnSafeAtoNumberTerminal( - private val minInclusive: Long, - private val maxExclusive: Long, - spaceAfterPrefix: Boolean = false, -) : TerminalCombinator { - - private val shape = if (spaceAfterPrefix) NSPAWN_SAFE_ATO_SHAPE_SPACED else SAFE_ATO_SHAPE - - private fun span(value: String, offset: Int): String? = shape.matchAt(value, offset)?.value - - private fun inRange(text: String): Boolean = safeAtoValueOf(text)?.let { it >= minInclusive && it < maxExclusive } ?: false - - override fun SyntacticMatch(value: String, offset: Int): MatchResult { - val text = span(value, offset) ?: return NoMatch - return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) - } - - override fun SemanticMatch(value: String, offset: Int): MatchResult { - val text = span(value, offset) ?: return NoMatch - if (!inRange(text)) return NoMatch.copy(longestMatch = offset) - return MatchResult(listOf(text), offset + text.length, listOf(this), offset + text.length) - } - - override fun parse(value: String, offset: Int): Sequence { - val text = span(value, offset) ?: return sequenceOf(Stuck(offset, setOf(this))) - return sequenceOf(Parse(offset + text.length, listOf(ParsedToken(offset, offset + text.length, text, this, inRange(text))))) - } - - override fun toString(): String = "SafeAto($minInclusive,$maxExclusive)" -} - -// The prefixed forms come first so `0b1`/`0x1f` are not read as a lone octal `0`. Kotlin's -// toLongOrNull() takes the sign that may follow `0b`/`0o`. -private val SAFE_ATO_SHAPE = Regex("""0[bB][+-]?[01]+|0[oO][+-]?[0-7]+|[+-]?(?:0[xX][0-9a-fA-F]+|0[0-7]*|[1-9][0-9]*)""") - -// As [SAFE_ATO_SHAPE], plus the isspace() run strtol() skips between a `0b`/`0o` prefix and the digits. -private val NSPAWN_SAFE_ATO_SHAPE_SPACED = - Regex("""0[bB][ \t\u000B\u000C\r]*[+-]?[01]+|0[oO][ \t\u000B\u000C\r]*[+-]?[0-7]+|[+-]?(?:0[xX][0-9a-fA-F]+|0[0-7]*|[1-9][0-9]*)""") - -/** - * The numeric value of [text] read the way safe_atoi()/safe_atou*() read it, assuming [text] is one - * whole [SAFE_ATO_SHAPE] match; null when it does not fit a Long (and so is out of any range anyway). - */ -private fun safeAtoValueOf(text: String): Long? { - if (text.length > 2 && (text.startsWith("0b") || text.startsWith("0B"))) return text.drop(2).trimStart().toLongOrNull(2) - if (text.length > 2 && (text.startsWith("0o") || text.startsWith("0O"))) return text.drop(2).trimStart().toLongOrNull(8) - val negative = text.startsWith("-") - val body = text.removePrefix("-").removePrefix("+") - val magnitude = when { - body.length > 2 && (body.startsWith("0x") || body.startsWith("0X")) -> body.drop(2).toLongOrNull(16) - body.length > 1 && body.startsWith("0") -> body.drop(1).toLongOrNull(8) - else -> body.toLongOrNull() - } ?: return null - return if (negative) -magnitude else magnitude -} - -/** Whether all of [text] is a number safe_atoi() reads as an `int` greater than zero — parse_ifindex() success. */ -private fun isPositiveIfindex(text: String): Boolean = - SAFE_ATO_SHAPE.matchEntire(text) != null && safeAtoValueOf(text)?.let { it in 1..Int.MAX_VALUE.toLong() } == true - -/** A [NspawnSafeAtoNumberTerminal] coloured as a literal, like the other number terminals. */ -fun safeAtoNumber(minInclusive: Long, maxExclusive: Long, spaceAfterPrefix: Boolean = false): Combinator = - Labeled(Role.LITERAL, NspawnSafeAtoNumberTerminal(minInclusive, maxExclusive, spaceAfterPrefix)) - - // --------------------------------------------------------------------------------------------------- // Interface names — ifname_valid() (ifname_valid_full with no flags, src/basic/socket-util.c): // printable ASCII 33…126 except `:`, `/` and `%`, at most IFNAMSIZ-1 = 15 bytes, not `.`/`..`, not @@ -151,7 +62,18 @@ private class NspawnInterfaceNameTerminal : TerminalCombinator { } private fun isRefused(value: String, start: Int, end: Int): Boolean = - isPositiveIfindex(value.substring(start, end)) || nspawnIfnameEscapesWhitespace(value, start, end) + ifindex.valueOf(value.substring(start, end)) != null || nspawnIfnameEscapesWhitespace(value, start, end) + + /** parse_ifindex(): safe_atoi() on the whole name, then refuses anything not greater than zero. */ + private val ifindex = SafeAtoTerminal( + signed = true, + base = 0, + flags = emptySet(), + minInclusive = 1L, + maxInclusive = Int.MAX_VALUE.toLong(), + acceptsWhitespace = true, + acceptsMinus = true, + ) /** Report this terminal, not the wrapped regex, as the one that matched. */ private fun MatchResult.owned(): MatchResult = if (matchResult < 0) this else copy(terminals = listOf(this@NspawnInterfaceNameTerminal)) diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExposePortOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExposePortOptionValueTest.kt index 34fa7ed9..39159d77 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExposePortOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExposePortOptionValueTest.kt @@ -2,13 +2,17 @@ package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai.ConfigParseExposePortOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.GrammarEngineVerdict +import org.junit.Assert.assertEquals import org.junit.Test class ConfigParseExposePortOptionValueTest : AbstractUnitFileTest() { @Test fun testValidValues() { - // From TEST-13-NSPAWN; ports are safe_atou16 (base 0). + // From TEST-13-NSPAWN; ports are safe_atou16 (base 0). A `-` after the 0b prefix escapes safe_atou's minus + // check and strtoul() negates modulo 2^64, so `0b -<2^64-80 in binary>` is port 80 (sd-check accepts it). // language="unit file (systemd)" val file = """ [Network] @@ -23,6 +27,7 @@ class ConfigParseExposePortOptionValueTest : AbstractUnitFileTest() { Port=0b 1 Port=80:0b 1 Port=tcp:0o 120 + Port=0b -$PORT_80_WRAPPED """.trimIndent() setupFileInEditor("file.nspawn", file) @@ -60,4 +65,23 @@ class ConfigParseExposePortOptionValueTest : AbstractUnitFileTest() { assertSize(12, highlights) } + + @Test + fun testLeadingVerticalTab() { + // SAFE_ATO_REFUSE_LEADING_WHITESPACE only checks " \t\n\r", so a \v reaches strtoul(), which skips it. + // sd-check accepts Port=\v80 and Port=tcp:\v80:81 but rejects Port=tcp:80: 81. The editor's lexer does not take + // \v right after `=`, so these are checked on the grammar directly, on both engines. + val grammar = ConfigParseExposePortOptionValue().combinator + for (value in listOf("\u000B80", "tcp:\u000B80:81", "80:\u000B81")) { + assertEquals("classic engine on <$value>", GrammarEngineVerdict.VALID, GrammarEngineVerdict.classic(grammar, value)) + assertEquals("parse engine on <$value>", GrammarEngineVerdict.VALID, GrammarEngineVerdict.parseEngine(grammar, value)) + } + for (value in listOf("tcp:80: 81", "tcp:\t80")) { + assertEquals("classic engine on <$value>", GrammarEngineVerdict.Kind.SYNTAX, GrammarEngineVerdict.classic(grammar, value).kind) + assertEquals("parse engine on <$value>", GrammarEngineVerdict.Kind.SYNTAX, GrammarEngineVerdict.parseEngine(grammar, value).kind) + } + } } + +/** 2^64 - 80 in binary: strtoul() negates it modulo 2^64 to 80. */ +private val PORT_80_WRAPPED = (java.math.BigInteger.ONE.shiftLeft(64) - java.math.BigInteger.valueOf(80)).toString(2) diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt index 57793536..5c4b605c 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseOomScoreAdjustOptionValueTest.kt @@ -2,6 +2,9 @@ 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.ConfigParseOomScoreAdjustOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.GrammarEngineVerdict +import org.junit.Assert.assertEquals import org.junit.Test class ConfigParseOomScoreAdjustOptionValueTest : AbstractUnitFileTest() { @@ -62,4 +65,16 @@ class ConfigParseOomScoreAdjustOptionValueTest : AbstractUnitFileTest() { assertSize(10, highlights) } + + @Test + fun testLeadingVerticalTabAndFormFeed() { + // config_parse strips " \t\n\r" but not \v or \f, and strtol() skips them, so sd-check accepts + // OOMScoreAdjust=\v5, \v-5 and \f-1000. The editor's lexer does not take \v right after `=`, so these are + // checked on the grammar directly, on both engines. + val grammar = ConfigParseOomScoreAdjustOptionValue().combinator + for (value in listOf("\u000B5", "\u000B-5", "\u000C-1000")) { + assertEquals("classic engine on <$value>", GrammarEngineVerdict.VALID, GrammarEngineVerdict.classic(grammar, value)) + assertEquals("parse engine on <$value>", GrammarEngineVerdict.VALID, GrammarEngineVerdict.parseEngine(grammar, value)) + } + } } From d71fa9c54be509c8118ba116019dd808512ab752 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sat, 24 Oct 2026 11:53:17 -0700 Subject: [PATCH 7/7] fix: accept bind options nspawn only rejects at container start (#565) bind_mount_parse() stores everything after the second colon verbatim, so the settings parser accepts any option string; only parse_mount_bind_options() rejects unknown options when the container starts. Keep the known options as the first alternative for completion and highlighting, and accept any other option string. Paths and field counts are still checked as bind_mount_parse() does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ai/ConfigParseBindOptionValue.kt | 37 +++++++++++++++---- .../ai/ConfigParseBindOptionValueTest.kt | 18 ++++++--- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBindOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBindOptionValue.kt index de7943b6..8a377247 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBindOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBindOptionValue.kt @@ -9,6 +9,7 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.FlexibleLiteralChoiceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.OneOrMore +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.PreferValidAlternativeCombinator 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 @@ -24,11 +25,17 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-mount.c#L86-L118 source_path_parse / source_path_parse_nullable * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L240-L281 extract_many_words (":", EXTRACT_DONT_COALESCE_SEPARATORS) * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L10-L188 extract_first_word - * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-mount.c#L759-L800 parse_mount_bind_options (checked when the mount is set up) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-mount.c#L759-L800 parse_mount_bind_options (only at container start, NOT at parse time) + * oracle steve-local/systemd-tools/bin/sd-check (fuzz-nspawn-settings at 0f0682b773): `Bind=/a:/b:bogus`, `Bind=/a:/b:rbind:x`, + * `BindReadOnly=/a:/b:ro`, `Bind=/a:/b:rbind,bogus`, `Bind=/a:/b::`, `Bind=/a:/b:has space` are all accepted + * (no output); `Bind=/a:relative:rbind` → "Invalid bind mount specification ... Invalid argument". * tests https://github.com/systemd/systemd/blob/0f0682b773/test/units/TEST-13-NSPAWN.nspawn.sh#L264-L287 */ -/** The options parse_mount_bind_options() knows; anything else fails the container start. */ +/** + * The options parse_mount_bind_options() knows. Listed first only for completion and highlighting: anything + * else is accepted too (see BIND_OPTIONS_FIELD), because only the container start rejects it. + */ private val BIND_OPTION = FlexibleLiteralChoiceTerminal("rbind", "norbind", "idmap", "noidmap", "rootidmap", "owneridmap") private val COMMA = LiteralChoiceTerminal(",") @@ -43,6 +50,18 @@ private val BIND_OPTIONS = SequenceCombinator( ZeroOrMore(COMMA), ) +/** + * Everything after the second colon. bind_mount_parse() strdup()s it verbatim (no unescaping, no checks), so + * any text is accepted at parse time. The known-option list is tried first so that a valid list is coloured + * and completed as options; otherwise the whole rest is taken as one opaque option string. + * parse_mount_bind_options() rejects unknown options only when the container starts ("Invalid bind mount + * option"); GitHub issue #565 tracks a separate warning for those. + */ +private val BIND_OPTIONS_FIELD = PreferValidAlternativeCombinator( + SequenceCombinator(BIND_OPTIONS, EOF()), + SequenceCombinator(RegexTerminal("""[\s\S]+""", """[\s\S]+"""), EOF()), +) + /** A lone path is both source and destination, so it must be absolute (optionally `+`-prefixed). */ private val SINGLE_PATH = RegexTerminal("""(?:[^:\\]|\\[\s\S])+""", """\+?\\?/(?:[^:\\]|\\[\s\S])*""") @@ -61,9 +80,11 @@ private val SINGLE_PATH = RegexTerminal("""(?:[^:\\]|\\[\s\S])+""", """\+?\\?/(? * other path (a relative one is resolved against the working directory). DEST must be absolute. * `SOURCE:` (an empty DEST) is invalid; an empty OPTIONS after the second colon is ignored. * - * OPTIONS is not inspected at config-parse time, but parse_mount_bind_options() refuses anything other - * than a comma-separated list of `rbind`, `norbind`, `idmap`, `noidmap`, `rootidmap`, `owneridmap` when - * the container starts, so that list is enforced here. Parse errors are logged and the setting ignored. + * OPTIONS is not inspected at config-parse time, so any option string is accepted here. Only when the + * container starts does parse_mount_bind_options() refuse anything other than a comma-separated list of + * `rbind`, `norbind`, `idmap`, `noidmap`, `rootidmap`, `owneridmap` (e.g. `bogus`, `rbind:x`, `ro`); this + * validator follows the parser and accepts them, and GitHub issue #565 tracks a separate warning for + * such runtime-only rejections. Parse errors are logged and the setting ignored. */ class ConfigParseBindOptionValue : SimpleGrammarOptionValues( "config_parse_bind", @@ -72,8 +93,10 @@ class ConfigParseBindOptionValue : SimpleGrammarOptionValues( ZeroOrOne(COLON_FIELD_SOURCE_PATH), COLON, COLON_FIELD_ABSOLUTE_PATH, - ZeroOrOne(SequenceCombinator(COLON, BIND_OPTIONS)), - EOF(), + AlternativeCombinator( + SequenceCombinator(COLON, BIND_OPTIONS_FIELD), + EOF(), + ), ), SequenceCombinator(SINGLE_PATH, EOF()), ) diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindOptionValueTest.kt index 9cc794dd..0d0c09fa 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBindOptionValueTest.kt @@ -40,7 +40,7 @@ class ConfigParseBindOptionValueTest : AbstractUnitFileTest() { @Test fun testInvalidValues() { // A lone path must be absolute; empty or relative destination; `+` must be followed by an absolute path. - // These are rejected by bind_mount_parse() itself. + // These are rejected by bind_mount_parse() itself (sd-check: "Invalid bind mount specification"). // language="unit file (systemd)" val file = """ [Files] @@ -49,32 +49,38 @@ class ConfigParseBindOptionValueTest : AbstractUnitFileTest() { Bind=/a:relative Bind=+relative:/b Bind=+:/b + Bind=/a:relative:rbind """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(5, highlights) + assertSize(6, highlights) } @Test fun testOptionsRejectedOnlyAtContainerStart() { - // Deliberately stricter than the settings parser: bind_mount_parse() stores the option string verbatim, so - // fuzz-nspawn-settings accepts these, but parse_mount_bind_options() fails the container start with - // "Invalid bind mount option" (`ro` is not an option, read-only is BindReadOnly=; `rbind:x` is one word). + // Accepted: bind_mount_parse() stores the option string verbatim, so the settings parser (sd-check / + // fuzz-nspawn-settings at 0f0682b773) accepts all of these. parse_mount_bind_options() fails the container + // start with "Invalid bind mount option" (`ro` is not an option, read-only is BindReadOnly=; `rbind:x` is one + // word), but that is not a parse error. Issue #565 tracks a separate warning for them. // language="unit file (systemd)" val file = """ [Files] Bind=/a:/b:bogus Bind=/a:/b:rbind:x BindReadOnly=/a:/b:ro + Bind=/a:/b:rbind,bogus + Bind=/a:/b:: + Bind=/a:/b:has space + Bind=+/a:/b:x:y:z """.trimIndent() setupFileInEditor("file.nspawn", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(3, highlights) + assertSize(0, highlights) } }