From a059d363b9033e4189df90a3162db2b5ac1b266f Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sat, 24 Oct 2026 15:50:38 -0700 Subject: [PATCH 1/4] fix: align exec and sandboxing validators with systemd's parsers (#568) Fixes values the plugin flagged although systemd accepts them, and values it accepted although systemd rejects them, for ExecStart= and the other command keys, WorkingDirectory=, EnvironmentFile=, Standard{Input,Output,Error}=, IOSchedulingClass=, IO*Max= limits, Memory*= limits, CPUAffinity=, CapabilityBoundingSet=/AmbientCapabilities=, SystemCallErrorNumber=, Nice=, LimitNICE= and other rlimits, signals, ReadOnlyPaths= and friends, and RootImagePolicy= and friends. Unknown %-specifiers are now refused and %% is treated as a literal %. Part of #568. Verdicts were checked against systemd's own parsers built at the pinned commit 0f0682b773 (condition values against src/shared/condition.c, since systemd only evaluates them at unit start). Values systemd accepts at parse time but rejects only later are accepted (see #565). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../optionvalues/EnumOptionValues.kt | 60 ++- .../optionvalues/ExecOptionValue.kt | 387 +++++++++++++++++- .../optionvalues/ExecOutputOptionValue.kt | 65 ++- .../optionvalues/IOLimitOptionValue.kt | 103 ++++- .../optionvalues/ImagePolicyOptionValue.kt | 78 +++- .../optionvalues/MemoryLimitOptionValue.kt | 156 ++++++- .../optionvalues/NamespacePathOptionValue.kt | 74 +++- .../optionvalues/RlimitOptionValue.kt | 284 +++++++++++-- .../optionvalues/SignalOptionValue.kt | 69 +++- .../optionvalues/SimpleGrammarOptionValues.kt | 37 +- .../ConfigParseAddressFamiliesOptionValue.kt | 122 ++---- .../ConfigParseExecCpuAffinityOptionValue.kt | 94 ++++- .../ai/ConfigParseExecInputOptionValue.kt | 48 ++- .../ai/ConfigParseExecNiceOptionValue.kt | 13 +- ...onfigParseExecOomScoreAdjustOptionValue.kt | 13 +- .../ConfigParseExecSecureBitsOptionValue.kt | 22 +- ...onfigParseExecSelinuxContextOptionValue.kt | 6 +- ...igParseExecSmackProcessLabelOptionValue.kt | 6 +- .../ConfigParseHhfPacketLimitOptionValue.kt | 13 +- ...gedOomMemPressureDurationSecOptionValue.kt | 17 +- .../ConfigParseNamespaceFlagsOptionValue.kt | 16 +- .../ConfigParseNetemPacketLimitOptionValue.kt | 13 +- .../ai/ConfigParsePrivateUsersOptionValue.kt | 7 +- .../ai/ConfigParseSyscallErrnoOptionValue.kt | 31 +- .../ai/ConfigParseUnitEnvFileOptionValue.kt | 27 +- .../ai/ConfigParseUnsetEnvironOptionValue.kt | 19 +- .../ConfigParseUserGroupCompatOptionValue.kt | 42 +- .../ConfigParseWorkingDirectoryOptionValue.kt | 28 +- .../completion/ImagePolicyCompletionTest.kt | 4 +- .../Fix568ExecReviewValidatorsTest.kt | 169 ++++++++ .../inspections/Fix568ExecValidatorsTest.kt | 320 +++++++++++++++ .../GrammarParseEngineInspectionTest.kt | 10 +- .../inspections/InvalidValueForRLimitTest.kt | 71 ++-- ...nvalidValueInspectionForExecOptionsTest.kt | 49 ++- ...ValueInspectionForExecOutputOptionsTest.kt | 24 +- .../InvalidValueInspectionForImagePolicy.kt | 109 ++--- ...alueInspectionForMemoryLimitOptionsTest.kt | 8 +- ...ueInspectionForSimpleGrammarOptionValue.kt | 15 +- ...nfigParseAddressFamiliesOptionValueTest.kt | 5 +- ...onfigParseExecSecureBitsOptionValueTest.kt | 6 +- ...gParseExecSelinuxContextOptionValueTest.kt | 6 +- ...rseExecSmackProcessLabelOptionValueTest.kt | 6 +- ...onfigParseHhfPacketLimitOptionValueTest.kt | 4 +- ...omMemPressureDurationSecOptionValueTest.kt | 1 - ...figParseNetemPacketLimitOptionValueTest.kt | 4 +- .../ConfigParseSyscallErrnoOptionValueTest.kt | 1 - ...figParseWorkingDirectoryOptionValueTest.kt | 5 +- 47 files changed, 2232 insertions(+), 435 deletions(-) create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Fix568ExecReviewValidatorsTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Fix568ExecValidatorsTest.kt diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/EnumOptionValues.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/EnumOptionValues.kt index 000f7059..4507918e 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/EnumOptionValues.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/EnumOptionValues.kt @@ -1,7 +1,13 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues import com.google.common.collect.ImmutableSet +import com.intellij.openapi.project.Project import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator +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.FlexibleLiteralChoiceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SafeAtoTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator @@ -74,7 +80,32 @@ class PersonalityOptionValue : AbstractGrammarEnumOptionValue(ImmutableSet.of("a } } -class CpuSchedulingPolicyOptionValue : AbstractGrammarEnumOptionValue(ImmutableSet.of("other", "batch", "idle", "fifo", "rr"), VALIDATOR_NAME) { +/** + * sched_policy_from_string(): a name from sched_policy_table, or (string-table fallback) any safe_atou() number up + * to INT_MAX. + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/process-util.c#L2317-L2326 + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/string-table.c#L65-L80 + */ +class CpuSchedulingPolicyOptionValue : SimpleGrammarOptionValues( + VALIDATOR_NAME, + SequenceCombinator( + AlternativeCombinator( + FlexibleLiteralChoiceTerminal("other", "batch", "idle", "fifo", "ext", "rr", ignoreCase = false), + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = Int.MAX_VALUE.toLong(), + acceptsWhitespace = true, + acceptsMinus = true, + ), + ), + EOF(), + ), +) { + override fun getAutoCompleteOptions(project: Project): Set = ImmutableSet.of("other", "batch", "idle", "fifo", "ext", "rr") + companion object { const val VALIDATOR_NAME = "config_parse_exec_cpu_sched_policy" } @@ -86,7 +117,32 @@ class NumaPolicyOptionValue : AbstractGrammarEnumOptionValue(ImmutableSet.of("de } } -class IOSchedulingClassOptionValue : AbstractGrammarEnumOptionValue(ImmutableSet.of("realtime", "best-effort", "idle"), VALIDATOR_NAME) { +/** + * ioprio_class_from_string(): a name from ioprio_class_table, or (string-table fallback) any safe_atou() number up + * to IOPRIO_NR_CLASSES-1 (7). + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/ioprio-util.c#L24-L31 + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/string-table.c#L65-L80 + */ +class IOSchedulingClassOptionValue : SimpleGrammarOptionValues( + VALIDATOR_NAME, + SequenceCombinator( + AlternativeCombinator( + FlexibleLiteralChoiceTerminal("none", "realtime", "best-effort", "idle", ignoreCase = false), + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 7L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + ), + EOF(), + ), +) { + override fun getAutoCompleteOptions(project: Project): Set = ImmutableSet.of("none", "realtime", "best-effort", "idle") + companion object { const val VALIDATOR_NAME = "config_parse_exec_io_class" } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ExecOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ExecOptionValue.kt index 12dd841e..a6114bc7 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ExecOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ExecOptionValue.kt @@ -6,14 +6,18 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.TextRange import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFilePropertyType import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar import java.util.regex.Pattern /** - * This validator is used for the config_parse_exec validator. + * This validator is used for the config_parse_exec validator (ExecStart=, ExecStop=, ExecReload=, ...). * + * Other inspections (e.g. the shell syntax inspection) also use the presence of this validator to decide whether + * they should run. * - * This validator does nothing because other inspections use it to determine if they should run, - * kind of hacky I know. + * It reports an error for exactly the command lines that systemd's config_parse_exec() refuses to load, and + * otherwise a weak warning recommending an absolute path: + * https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L882-L1094 */ class ExecOptionValue : OptionValueInformation { override fun getAutoCompleteOptions(project: Project): Set { @@ -26,6 +30,15 @@ class ExecOptionValue : OptionValueInformation { * @param holder - A problem holder that we should add to. */ override fun generateProblemDescriptors(property: UnitFilePropertyType, holder: ProblemsHolder) { + val valueText = property.valueText + if (valueText != null) { + val error = getErrorMessage(valueText) + if (error != null) { + holder.registerProblem(property.valueNode.psi, error, ProblemHighlightType.GENERIC_ERROR_OR_WARNING) + return + } + } + val values = property.valueNode.text val absMatcher = ABSOLUTE_PATH_REGEX.matcher(values) if (absMatcher.find()) { @@ -43,13 +56,147 @@ class ExecOptionValue : OptionValueInformation { } } + /** + * Returns why systemd's config_parse_exec() would refuse this command line, or null if it loads it. + * + * An unknown specifier (a '%' followed by a letter or digit that unit_full_printf() does not know) in the path or + * an argument is an error. A path that uses a known specifier is not checked further, because what it expands to + * depends on the unit name and the machine; a path whose only '%'s are literal (`%%`, or '%' before a character + * that is not a letter or digit) is checked after expanding them. Warnings that systemd logs while still loading + * the command (unknown escape sequences, the obsolete `!!` prefix) are not reported. + */ override fun getErrorMessage(value: String): String? { + val rvalue = value.trim(' ', '\t', '\n', '\r') + var p: Int? = 0 + + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L913-L1091 + do { + var semicolon = false + + val first = extractFirstWordAndWarn(rvalue, p) ?: return UNBALANCED_QUOTING + val firstWord = first.word ?: return null + p = first.next + + if (firstWord == ";") { + continue + } + + // Prefix characters, https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L936-L973 + var ignoreFailure = false + var separateArgv0 = false + var noEnvExpand = false + var viaShell = false + var fullyPrivileged = false + var noSetuid = false + var ambientHack = false + var f = 0 + while (f < firstWord.length) { + val c = firstWord[f] + if (c == '-' && !ignoreFailure) { + ignoreFailure = true + } else if (c == '@' && !separateArgv0) { + separateArgv0 = true + } else if (c == ':' && !noEnvExpand) { + noEnvExpand = true + } else if (c == '|' && !viaShell) { + viaShell = true + } else if (c == '+' && !fullyPrivileged && !noSetuid && !ambientHack) { + fullyPrivileged = true + } else if (c == '!' && !fullyPrivileged && !noSetuid && !ambientHack) { + noSetuid = true + } else if (c == '!' && !fullyPrivileged && !ambientHack) { + noSetuid = false + ambientHack = true + } else { + break + } + f++ + } + val rawPath = firstWord.substring(f) + val unknownInPath = firstUnknownSpecifier(rawPath) + // null when the path uses a real specifier + val path = expandLiteralSpecifiers(rawPath) + + var argCount = 0 + if (viaShell) { + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L980-L990 + argCount = if (rawPath.isEmpty()) 1 else 2 + } else if (unknownInPath != null) { + // unit_path_printf() fails with -EBADSLT, + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L992-L998 + return "Unknown specifier %$unknownInPath in executable path: $rawPath" + } else if (path != null) { + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L992-L1028 + if (path.isEmpty()) { + return "Empty path in command line" + } + if (!stringIsSafe(path)) { + return "Executable path contains special characters (control characters, quotes, backslashes or *?[): $path" + } + if (pathImpliesDirectory(path)) { + return "Executable path specifies a directory: $path" + } + if (!filenameOrAbsolutePathIsValid(path)) { + return "Neither a valid executable name nor an absolute path: $path" + } + if (!separateArgv0) { + argCount++ + } + } else { + // The path is only known once the specifiers are expanded, assume it is fine. + if (!separateArgv0) { + argCount++ + } + } + + // Arguments, https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L1031-L1070 + while (p != null && p < rvalue.length) { + val rest = p + if (rvalue[rest] == ';' && (rest + 1 == rvalue.length || rvalue[rest + 1] in WHITESPACE)) { + p = skipWhitespace(rvalue, rest + 1) + semicolon = true + break + } + + if (rvalue[rest] == '\\' && rest + 1 < rvalue.length && rvalue[rest + 1] == ';' + && (rest + 2 == rvalue.length || rvalue[rest + 2] in WHITESPACE)) { + p = skipWhitespace(rvalue, rest + 2) + argCount++ + continue + } + + val arg = extractFirstWordAndWarn(rvalue, p) ?: return UNBALANCED_QUOTING + if (arg.word == null) { + break + } + p = arg.next + // unit_full_printf() fails with -EBADSLT, + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L1060-L1066 + val unknown = firstUnknownSpecifier(arg.word) + if (unknown != null) { + return "Unknown specifier %$unknown in argument: ${arg.word}" + } + argCount++ + } + + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L1072-L1077 + if (argCount == 0) { + return "Empty executable name or zeroeth argument: with the '@' prefix the first argument after the path is argv[0]" + } + } while (semicolon || firstWord == ";") + return null } override val validatorName: String get() = VALIDATOR_NAME + /** + * One extract_first_word() step: the word, and where the rest starts (null when the input is used up, like + * systemd's *p = NULL). + */ + private class Word(val word: String?, val next: Int?) + companion object { // Specifiers that systemd's unit_path_printf() expands to an absolute filesystem path. A command whose first // token begins with one of these (e.g. ExecStart=%h/bin/foo) resolves to an absolute path, so it must not be @@ -64,8 +211,242 @@ class ExecOptionValue : OptionValueInformation { // Used to determine what to highlight (we want to avoid highlighting valid prefixes). private val RELATIVE_PATH_REGEX = Pattern.compile("^\\s*(?:[+@:!-]+)?([^\\/\\s]\\S*)\\s*") + // https://github.com/systemd/systemd/blob/0f0682b773/src/fundamental/string-util.h#L15 + private const val WHITESPACE = " \t\n\r" + + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L226-L228 + private const val UNBALANCED_QUOTING = "Unbalanced quoting" + + // Marks a \xNN or octal escape above 0x7F: systemd copies it as a raw byte, which is not valid UTF-8 on its own. + private const val RAW_HIGH_BYTE = '￿' + + // NAME_MAX and PATH_MAX from linux/limits.h + private const val NAME_MAX = 255 + private const val PATH_MAX = 4096 + const val VALIDATOR_NAME = "config_parse_exec" val validators = mapOf(Validator(VALIDATOR_NAME, "*") to ExecOptionValue()) + + /** + * The first '%' + letter/digit pair in [s] that specifier_printf() does not know (it then fails with -EBADSLT), + * or null. `%%` is a literal '%', and '%' before any other character (or at the end) is kept as it is. + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L33-L105 + */ + private fun firstUnknownSpecifier(s: String): Char? { + var i = 0 + while (i < s.length) { + if (s[i] == '%' && i + 1 < s.length) { + val c = s[i + 1] + if (c != '%' && c.isAsciiLetterOrDigit() && c !in BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS) { + return c + } + i += 2 + } else { + i++ + } + } + return null + } + + /** + * What specifier_printf() makes of [s] if it uses no real specifier (only `%%` and literal '%'s), or null if it + * uses one, in which case the result depends on the unit and the machine. + */ + private fun expandLiteralSpecifiers(s: String): String? { + val out = StringBuilder() + var i = 0 + while (i < s.length) { + if (s[i] == '%' && i + 1 < s.length) { + val c = s[i + 1] + if (c == '%') { + out.append('%') + } else if (c.isAsciiLetterOrDigit()) { + return null + } else { + out.append('%').append(c) + } + i += 2 + } else { + out.append(s[i]) + i++ + } + } + return out.toString() + } + + private fun Char.isAsciiLetterOrDigit(): Boolean = this in 'a'..'z' || this in 'A'..'Z' || this in '0'..'9' + + private fun skipWhitespace(s: String, from: Int): Int? { + var i = from + while (i < s.length && s[i] in WHITESPACE) { + i++ + } + return if (i < s.length) i else null + } + + /** + * extract_first_word_and_warn(…, EXTRACT_UNQUOTE|EXTRACT_CUNESCAPE): an invalid escape sequence is only a + * warning (systemd retries with EXTRACT_UNESCAPE_RELAX and keeps the backslash), so this returns null only for + * unbalanced quoting. + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L190-L233 + */ + private fun extractFirstWordAndWarn(s: String, from: Int?): Word? { + return extractFirstWord(s, from, false) ?: extractFirstWord(s, from, true) + } + + /** + * extract_first_word() with WHITESPACE separators and EXTRACT_UNQUOTE|EXTRACT_CUNESCAPE (plus + * EXTRACT_UNESCAPE_RELAX when [unescapeRelax]). Returns null for -EINVAL. + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/extract-word.c#L10-L188 + */ + private fun extractFirstWord(s: String, from: Int?, unescapeRelax: Boolean): Word? { + var i = from ?: return Word(null, null) + while (i < s.length && s[i] in WHITESPACE) { + i++ + } + if (i >= s.length) { + return Word(null, null) + } + + val out = StringBuilder() + var quote = 0.toChar() + var backslash = false + while (true) { + val atEnd = i >= s.length + val c = if (atEnd) 0.toChar() else s[i] + if (backslash) { + if (atEnd) { + if (unescapeRelax && quote == 0.toChar()) { + out.append('\\') + return Word(out.toString(), null) + } + return null + } + val consumed = cunescapeOne(s, i, out) + if (consumed > 0) { + i += consumed - 1 + } else if (unescapeRelax) { + out.append('\\').append(c) + } else { + return null + } + backslash = false + i++ + } else if (quote != 0.toChar()) { + if (atEnd) { + return null + } else if (c == quote) { + quote = 0.toChar() + } else if (c == '\\') { + backslash = true + } else { + out.append(c) + } + i++ + } else { + if (atEnd) { + return Word(out.toString(), null) + } else if (c == '\'' || c == '"') { + quote = c + } else if (c == '\\') { + backslash = true + } else if (c in WHITESPACE) { + return Word(out.toString(), skipWhitespace(s, i)) + } else { + out.append(c) + } + i++ + } + } + } + + /** + * cunescape_one(…, accept_nul = false) for the escape whose first character (after the backslash) is at [at]. + * Appends the decoded character and returns how many characters were consumed, or -1 for -EINVAL. + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/escape.c#L104-L296 + */ + private fun cunescapeOne(s: String, at: Int, out: StringBuilder): Int { + fun hex(offset: Int, count: Int): Long? { + if (at + offset + count > s.length) return null + var v = 0L + for (k in 0 until count) { + val d = Character.digit(s[at + offset + k], 16) + if (d < 0 || s[at + offset + k].code > 0x7f) return null + v = (v shl 4) or d.toLong() + } + return v + } + + when (val c = s[at]) { + 'a' -> out.append('\u0007') + 'b' -> out.append('\b') + 'f' -> out.append('\u000c') + 'n' -> out.append('\n') + 'r' -> out.append('\r') + 't' -> out.append('\t') + 'v' -> out.append('\u000b') + '\\', '"', '\'' -> out.append(c) + 's' -> out.append(' ') + 'x' -> { + val v = hex(1, 2) ?: return -1 + if (v == 0L) return -1 + out.append(if (v > 0x7f) RAW_HIGH_BYTE else v.toInt().toChar()) + return 3 + } + 'u' -> { + val v = hex(1, 4) ?: return -1 + if (v == 0L || v in 0xD800L..0xDFFFL) return -1 + out.append(v.toInt().toChar()) + return 5 + } + 'U' -> { + val v = hex(1, 8) ?: return -1 + val invalid = v == 0L || v >= 0x110000L || (v and 0xFFFFF800L) == 0xD800L || v in 0xFDD0L..0xFDEFL + || (v and 0xFFFEL) == 0xFFFEL + if (invalid) return -1 + out.appendCodePoint(v.toInt()) + return 9 + } + in '0'..'7' -> { + if (at + 3 > s.length) return -1 + val digits = s.substring(at, at + 3) + if (digits.any { it !in '0'..'7' }) return -1 + val v = digits.toInt(8) + if (v == 0 || v > 255) return -1 + out.append(if (v > 0x7f) RAW_HIGH_BYTE else v.toChar()) + return 3 + } + else -> return -1 + } + return 1 + } + + /** + * string_is_safe(p, 0): no control characters, newlines, backslashes, quotes or glob characters, valid UTF-8. + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/string-util.c#L1154-L1192 + */ + private fun stringIsSafe(p: String): Boolean { + return p.none { it.code < 0x20 || it.code == 0x7f || it == '\\' || it == '"' || it == '\'' || it in "*?[" || it == RAW_HIGH_BYTE } + } + + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/path-util.c#L1340-L1352 + private fun pathImpliesDirectory(path: String): Boolean { + return path == "." || path == ".." || path.endsWith("/") || path.endsWith("/.") || path.endsWith("/..") + } + + /** + * filename_or_absolute_path_is_valid(): an absolute path whose components fit NAME_MAX and whose length fits + * PATH_MAX, or a single file name that is not "." or "..". + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/path-util.h#L156-L161 + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/path-util.c#L1143-L1190 + */ + private fun filenameOrAbsolutePathIsValid(path: String): Boolean { + if (path.startsWith("/")) { + if (path.toByteArray(Charsets.UTF_8).size >= PATH_MAX) return false + return path.split('/').all { it.toByteArray(Charsets.UTF_8).size <= NAME_MAX } + } + return path.isNotEmpty() && path != "." && path != ".." && !path.contains('/') && path.toByteArray(Charsets.UTF_8).size <= NAME_MAX + } } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ExecOutputOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ExecOutputOptionValue.kt index adc0a8f0..b532e214 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ExecOutputOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ExecOutputOptionValue.kt @@ -2,7 +2,12 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues import com.intellij.openapi.project.Project import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar +/** + * StandardOutput= / StandardError=, config_parse_exec_output(): + * https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L1329-L1453 + */ class ExecOutputOptionValue() : OptionValueInformation { override fun getAutoCompleteOptions(project: Project): Set { return emptySet() @@ -10,23 +15,47 @@ class ExecOutputOptionValue() : OptionValueInformation { override fun getErrorMessage(value: String): String? { - if (value.startsWith("file:") || value.startsWith("append:") || value.startsWith("truncate:") || value.startsWith("fd:")) { - val firstArg = value.split(":", limit = 2)[0] - val secondArg = value.split(":", limit = 2)[1] - - if (secondArg.trim().isEmpty()) { - when(firstArg) { - "file", "append", "truncate" -> return "path must be specified after the colon." - "fd" -> return "name must be specified after the colon." - } + if (value.startsWith("fd:")) { + // An empty name is fine; otherwise fdname_is_valid(): printable ASCII except ':', at most 255 characters. + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/fd-util.c#L554-L579 + // The name goes through unit_fd_printf() first, so an unknown specifier (%Q) is refused. + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L1354-L1367 + val name = value.removePrefix("fd:") + if (!KNOWN_SPECIFIERS_ONLY.matches(name)) { + return "fd: name contains an unknown specifier." + } + if (name.length > FDNAME_MAX || name.any { it.code < 0x20 || it.code >= 0x7f || it == ':' }) { + return "fd: name may only contain printable ASCII characters other than ':' and be at most $FDNAME_MAX characters long." } + return null + } - // We could do a better job of validating paths later + if (value.startsWith("file:") || value.startsWith("append:") || value.startsWith("truncate:")) { + // The path goes through path_simplify_and_warn(PATH_CHECK_ABSOLUTE|PATH_CHECK_FATAL): it must be absolute and + // must not contain a ".." component (other than leading ones, which path_simplify() drops). It goes through + // unit_path_printf() first: an unknown specifier (%Q) is refused, "%%" is a literal '%', and a path that starts + // with a known specifier, or uses one later on, is only known once the specifier is expanded. + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L34-L80 + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L33-L105 + val path = value.split(":", limit = 2)[1] + if (path.isEmpty()) { + return "path must be specified after the colon." + } + if (!KNOWN_SPECIFIERS_ONLY.matches(path)) { + return "path after the colon contains an unknown specifier." + } + if (!ABSOLUTE_NORMALIZED_PATH.matches(path) && !SPECIFIED_ABSOLUTE_PATH.matches(path)) { + return "path after the colon must be an absolute, normalized path (no '..' components)." + } return null } + // syslog and syslog+console are obsolete, but systemd still accepts them (as journal and journal+console). + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L1371-L1377 + // The rest is exec_output_table, https://github.com/systemd/systemd/blob/0f0682b773/src/core/execute.c#L3257-L3272 when (value) { - "inherit", "null", "tty", "journal", "kmsg", "journal+console", "kmsg+console", "socket" -> return null + "inherit", "null", "tty", "journal", "kmsg", "journal+console", "kmsg+console", "socket", "fd", "file", "append", "truncate", + "broker", "broker-log", "syslog", "syslog+console" -> return null else -> return "Takes one of inherit, null, tty, journal, kmsg, journal+console, kmsg+console, file:path, append:path, truncate:path, socket or fd:name." } @@ -39,6 +68,20 @@ class ExecOutputOptionValue() : OptionValueInformation { companion object { private val VALIDATOR_NAME = "config_parse_exec_output" + private const val FDNAME_MAX = 255 + + // An absolute path whose components after any leading "", "." and ".." ones (dropped by path_simplify()) are + // never "..". + private val ABSOLUTE_NORMALIZED_PATH = """/(?:\.{0,2}(?:/|$))*(?!(?:.*/)?\.\.(?:/|$))(?:[^%]|%%|%(?![A-Za-z0-9%]))*""".toRegex() + + // Text whose every '%' is "%%", a known specifier, or a '%' that specifier_printf() keeps as it is. + private val KNOWN_SPECIFIERS_ONLY = "${BatchUnitCoreGrammar.UNIT_TEXT_CHAR}*".toRegex() + + // A path that starts with a known specifier, or starts with '/' and uses a known specifier later on. + private val SPECIFIED_ABSOLUTE_PATH = + "(?:%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}]|/(?=(?:[^%]|%%|%(?![A-Za-z0-9%]))*%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}])).*" + .toRegex() + val validators = mapOf(Validator(VALIDATOR_NAME, "0") to ExecOutputOptionValue()) } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/IOLimitOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/IOLimitOptionValue.kt index c5d9531b..e8478c20 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/IOLimitOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/IOLimitOptionValue.kt @@ -2,17 +2,114 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* +import java.math.BigInteger - +/** + * IOReadBandwidthMax=, IOWriteBandwidthMax=, IOReadIOPSMax=, IOWriteIOPSMax=: config_parse_io_limit(), + * https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L4437-L4525 + * + * The first word (extract_first_word with EXTRACT_UNQUOTE, so a backslash escapes any character) is the device path. + * It goes through unit_path_printf() and then path_simplify_and_warn() without PATH_CHECK_ABSOLUTE, so it does not + * have to be absolute, but no ".." component may survive path_simplify() (which only drops leading ones of an + * absolute path). Everything after it must be `infinity` or a single parse_size() value with base 1000 that is + * above 0. + */ class IOLimitOptionValue() : GrammarOptionValue("config_parse_io_limit", GRAMMAR) { companion object { - val GRAMMAR = SequenceCombinator(OneOrMore(SequenceCombinator(DEVICE, BYTES)), EOF()) + val GRAMMAR = SequenceCombinator( + // The device: unquoted characters, backslash escapes, or quoted runs (in which a backslash escapes too). + ValueChecked( + RegexTerminal( + """(?:[^\s"'\\]|\\.|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')+""", + """(?:[^\s"'\\]|\\.|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')+""" + ) + ) { text -> devicePathIsAccepted(text) }, + WhitespaceTerminal(), + PreferValidAlternativeCombinator( + LiteralChoiceTerminal("infinity"), + parseSize(base = 1000, minInclusive = BigInteger.ONE, maxInclusive = SafeAtoTerminal.UINT64_MAX, allowInnerWhitespace = true), + ), + EOF(), + ) + + /** + * Whether systemd keeps the device word [raw] (still quoted and escaped as written). + * + * An unknown specifier makes unit_path_printf() fail; with a known specifier the expanded path depends on the + * unit and the machine, so it is accepted. Otherwise path_simplify_and_warn(…, 0) refuses an empty path, "." and + * any ".." component that path_simplify() keeps. + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L33-L105 + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L34-L80 + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/path-util.c#L355-L413 + */ + private fun devicePathIsAccepted(raw: String): Boolean { + // extract_first_word(…, EXTRACT_UNQUOTE): drop the quotes, a backslash keeps the next character as it is. + val word = StringBuilder() + var quote = 0.toChar() + var i = 0 + while (i < raw.length) { + val c = raw[i] + if (c == '\\' && i + 1 < raw.length) { + word.append(raw[i + 1]) + i += 2 + continue + } + if (quote == 0.toChar() && (c == '"' || c == '\'')) { + quote = c + } else if (c == quote) { + quote = 0.toChar() + } else { + word.append(c) + } + i++ + } + + // specifier_printf(): %% is a literal '%', a known letter is a real specifier, any other letter or digit fails. + val expanded = StringBuilder() + var usesSpecifier = false + var j = 0 + while (j < word.length) { + if (word[j] == '%' && j + 1 < word.length) { + val c = word[j + 1] + if (c == '%') { + expanded.append('%') + } else if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9') { + if (c !in BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS) { + return false + } + usesSpecifier = true + } else { + expanded.append('%').append(c) + } + j += 2 + } else { + expanded.append(word[j]) + j++ + } + } + if (usesSpecifier) { + return true + } + + val path = expanded.toString() + if (path.isEmpty()) { + return false + } + val absolute = path.startsWith("/") + var components = path.split('/').filter { it.isNotEmpty() && it != "." } + if (absolute) { + components = components.dropWhile { it == ".." } + } else if (components.isEmpty()) { + // path_simplify() turns "." or "./" into ".", which path_is_normalized() refuses. + return false + } + return components.none { it == ".." } + } val validators = mapOf( Validator("config_parse_io_limit", "0") to IOLimitOptionValue() ) } } - diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ImagePolicyOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ImagePolicyOptionValue.kt index 956ca5b6..a4ba0154 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ImagePolicyOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ImagePolicyOptionValue.kt @@ -3,6 +3,20 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* +/** + * RootImagePolicy=, MountImagePolicy=, ExtensionImagePolicy=: config_parse_image_policy() → + * image_policy_from_string(…, graceful = true). + * + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/image-policy.c#L776-L810 + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/image-policy.c#L341-L465 + * + * The value is "-", "*", "~", or ':'-separated `designator=flags` rules (an empty designator sets the default + * flags), with the flags '+'-separated. In graceful mode an unknown partition designator or policy flag is only + * logged at debug level and skipped, so systemd's parser accepts any word in either place, including an empty flag + * after a trailing '+' (https://github.com/systemd/systemd/blob/0f0682b773/src/shared/image-policy.c#L237-L269). + * The known names are kept in the grammar for completion. What systemd does refuse is a rule without '=' and a + * designator given twice (architecture-specific designator aliases are not resolved for that check). + */ class ImagePolicyOptionValue : GrammarOptionValue(validatorName, IMAGE_POLICY_COMBINATOR) { companion object { @@ -11,19 +25,71 @@ class ImagePolicyOptionValue : GrammarOptionValue(validatorName, IMAGE_POLICY_CO // Image Polcies // https://www.freedesktop.org/software/systemd/man/latest/systemd.image-policy.html val IMAGE_POLICY_SEPARATOR = LiteralChoiceTerminal(":") + // partition_designator_table, https://github.com/systemd/systemd/blob/0f0682b773/src/shared/gpt.c#L156-L170 val PARTITION_IDENTIFIER = FlexibleLiteralChoiceTerminal( "root", "usr", "home", "srv", "esp", "xbootldr", "swap", "root-verity", "root-verity-sig", "usr-verity", "usr-verity-sig", "tmp", "var") - val PARTITION_POLICY_FLAG = FlexibleLiteralChoiceTerminal("unprotected", "verity", "signed", "encrypted", "unused", "absent", "read-only-off", "read-only-on", "growfs-off", "growfs-on") + // policy_flag_from_string_one() and policy_flag_from_fstype(), + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/image-policy.c#L180-L235 + val PARTITION_POLICY_FLAG = FlexibleLiteralChoiceTerminal( + "unprotected", "verity", "signed", "encrypted", "encryptedwithintegrity", "unused", "absent", "open", "ignore", + "read-only-off", "read-only-on", "growfs-off", "growfs-on", + "btrfs", "erofs", "ext4", "f2fs", "squashfs", "vfat", "xfs", + ) val PARTITION_POLICY_FLAG_SEPARATOR = LiteralChoiceTerminal("+") val PARTITION_IDENTIFIER_FLAG_SEPARATOR = LiteralChoiceTerminal("=") + // Any word is accepted (graceful mode); the regex comes first so that the legacy engine, which takes the first + // alternative that matches, never strands part of an unknown word. + val PARTITION_POLICY_FLAGS = SequenceCombinator( + AlternativeCombinator(RegexTerminal("[^+:]*", "[^+:]*"), PARTITION_POLICY_FLAG), + ZeroOrMore( + SequenceCombinator( + PARTITION_POLICY_FLAG_SEPARATOR, + AlternativeCombinator(RegexTerminal("[^+:]*", "[^+:]*"), PARTITION_POLICY_FLAG), + ) + ), + ) + + val SINGLE_EXPLICIT_IMAGE_POLICY = SequenceCombinator( + AlternativeCombinator(RegexTerminal("[^=:]*", "[^=:]*"), PARTITION_IDENTIFIER), + PARTITION_IDENTIFIER_FLAG_SEPARATOR, + PARTITION_POLICY_FLAGS, + ) - val PARTITION_POLICY_FLAGS = SequenceCombinator(PARTITION_POLICY_FLAG, ZeroOrMore(SequenceCombinator(PARTITION_POLICY_FLAG_SEPARATOR, PARTITION_POLICY_FLAG))) + val IMAGE_POLICY_COMBINATOR = SequenceCombinator( + AlternativeCombinator( + SequenceCombinator(LiteralChoiceTerminal("-", "*", "~"), EOF()), + // A designator given twice, or two default rules (empty designator), is -ENOTUNIQ. + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/image-policy.c#L413-L432 + ValueChecked( + SequenceCombinator(SINGLE_EXPLICIT_IMAGE_POLICY, ZeroOrMore(SequenceCombinator(IMAGE_POLICY_SEPARATOR, SINGLE_EXPLICIT_IMAGE_POLICY))) + ) { text -> noDuplicateDesignators(text) }, + ), + EOF(), + ) + // partition_designator_table, https://github.com/systemd/systemd/blob/0f0682b773/src/shared/gpt.c#L156-L170 + private val KNOWN_DESIGNATORS = setOf( + "root", "usr", "home", "srv", "esp", "xbootldr", "swap", "root-verity", "root-verity-sig", "usr-verity", "usr-verity-sig", "tmp", "var", + ) - val SINGLE_EXPLICIT_IMAGE_POLICY = SequenceCombinator(PARTITION_IDENTIFIER, PARTITION_IDENTIFIER_FLAG_SEPARATOR, PARTITION_POLICY_FLAGS) - val DEFAULT_IMAGE_POLICY = SequenceCombinator(PARTITION_POLICY_FLAG_SEPARATOR, PARTITION_POLICY_FLAGS) - var IMAGE_POLICY= AlternativeCombinator(SINGLE_EXPLICIT_IMAGE_POLICY, DEFAULT_IMAGE_POLICY) - val IMAGE_POLICY_COMBINATOR = SequenceCombinator(IMAGE_POLICY, ZeroOrMore(SequenceCombinator(IMAGE_POLICY_SEPARATOR, IMAGE_POLICY)), EOF()) + /** + * False if a rule repeats a designator from partition_designator_table, or the default rule (empty designator), + * as image_policy_from_string() checks it after strstrip(). Unknown designators are skipped by graceful parsing, + * and architecture-specific aliases (root-x86-64, ...) depend on the build, so neither is counted. + */ + private fun noDuplicateDesignators(text: String): Boolean { + val seen = mutableSetOf() + for (rule in text.split(':')) { + val designator = rule.substringBefore('=').trim(' ', '\t', '\n', '\r') + if (designator.isNotEmpty() && designator !in KNOWN_DESIGNATORS) { + continue + } + if (!seen.add(designator)) { + return false + } + } + return true + } val validators = mapOf( Validator(validatorName, "0") to ImagePolicyOptionValue() diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/MemoryLimitOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/MemoryLimitOptionValue.kt index 814e0bda..6beccc8c 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/MemoryLimitOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/MemoryLimitOptionValue.kt @@ -1,8 +1,22 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues +import com.intellij.codeInspection.ProblemHighlightType +import com.intellij.codeInspection.ProblemsHolder import com.intellij.openapi.project.Project +import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFilePropertyType import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator - +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SafeAtoTerminal +import java.math.BigDecimal +import java.math.BigInteger + +/** + * MemoryMin=, MemoryLow=, MemoryHigh=, MemoryMax=, MemorySwapMax=, MemoryZSwapMax= and their Startup variants: + * config_parse_memory_limit(), https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L3821-L3898 + * + * `infinity`, a parse_permyriad() value (at most 100%), or a parse_size() value with base 1024. The result must be + * below UINT64_MAX, and it may only be 0 (also "0%") for MemorySwapMax=, MemoryZSwapMax=, MemoryLow=, MemoryMin= and + * the Startup variants of the first three. + */ class MemoryLimitOptionValue() : OptionValueInformation { @@ -10,21 +24,43 @@ class MemoryLimitOptionValue() : OptionValueInformation { return emptySet() } + override fun generateProblemDescriptors(property: UnitFilePropertyType, holder: ProblemsHolder) { + val value = property.valueText ?: return + val errorMessage = getErrorMessage(value) ?: zeroErrorMessage(property.key, value) + if (errorMessage != null) { + holder.registerProblem(property.valueNode.psi, errorMessage, ProblemHighlightType.GENERIC_ERROR_OR_WARNING) + } + } + + /** The error for a limit of 0 on a key that does not allow it, or null. Expects a value [getErrorMessage] accepts. */ + private fun zeroErrorMessage(key: String, value: String): String? { + if (key in ZERO_ALLOWED_KEYS || value.trim() == "infinity") { + return null + } + val permyriad = parsePermyriad(value.trim()) + val isZero = if (permyriad != null) permyriad == 0 else parseSize1024(value.trim()) == BigInteger.ZERO + return if (isZero) "Memory limit '$value' out of range: $key= must be above 0." else null + } + override fun getErrorMessage(value: String): String? { + val trimmed = value.trim() - if (BYTES.matches(value)) { + if (trimmed == "infinity") { return null } - if (INFINITY.matches(value)) { + // parse_permyriad() comes first; only when it fails is the value a parse_size() byte count, which must stay + // below UINT64_MAX. + if (parsePermyriad(trimmed) != null) { return null } - if (VALID_PERCENTAGE.matches(value)) { + val bytes = parseSize1024(trimmed) + if (bytes != null && bytes < UINT64_MAX) { return null } - return "Invalid memory limit: '${value}'. Takes a memory size in bytes. Can be suffixed with K, M, G, T to specify Kilo/Mega/Giga/Terabytes respectively. Alternatively a percentage (with up to 2 decimal points) may be specified, or the special value 'infinity'." + return "Invalid memory limit: '${value}'. Takes a memory size in bytes. Can be suffixed with K, M, G, T, P, E to specify Kilo/Mega/Giga/Tera/Peta/Exabytes respectively. Alternatively a percentage (with up to 2 decimal points), permille or permyriad may be specified, or the special value 'infinity'." } override val validatorName: String @@ -36,9 +72,113 @@ class MemoryLimitOptionValue() : OptionValueInformation { const val VALIDATOR_NAME = "config_parse_memory_limit" val validators = mapOf(Validator(VALIDATOR_NAME, "0") to MemoryLimitOptionValue()) - val BYTES = """^\s*[0-9]+(\.[0-9]*)?\s*[KMGT]?\s*$""".toRegex() - val VALID_PERCENTAGE = """^\s*(100|([0-9]?[0-9]))(\.[0-9]{1,2})?\s*%\s*$""".toRegex() - val INFINITY = """^\s*infinity\s*$""".toRegex() + private val UINT64_MAX: BigInteger = BigInteger.ONE.shiftLeft(64) - BigInteger.ONE + + private const val WHITESPACE = " \t\n\r" + + // The base-1024 table of parse_size(), in table order; "" (a bare number) comes last. + private val IEC_FACTORS: List> = listOf( + "E" to BigInteger.valueOf(1024).pow(6), + "P" to BigInteger.valueOf(1024).pow(5), + "T" to BigInteger.valueOf(1024).pow(4), + "G" to BigInteger.valueOf(1024).pow(3), + "M" to BigInteger.valueOf(1024).pow(2), + "K" to BigInteger.valueOf(1024), + "B" to BigInteger.ONE, + "" to BigInteger.ONE, + ) + + /** + * parse_size(t, 1024, …), https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L157-L283: + * one or more `number[.fraction][suffix]` elements whose suffixes strictly descend through the table. Returns + * the byte count, or null for -EINVAL / -ERANGE. + */ + private fun parseSize1024(t: String): BigInteger? { + val n = t.length + var p = 0 + var total = BigInteger.ZERO + var startPos = 0 + do { + while (p < n && t[p] in WHITESPACE) p++ + // strtoull(p, &e, 10): an optional sign, then digits. A '-' is ERANGE. + var q = p + if (q < n && (t[q] == '+' || t[q] == '-')) q++ + val digitsStart = q + while (q < n && t[q] in '0'..'9') q++ + if (q == digitsStart) return null + if (t[p] == '-') return null + val l = BigInteger(t.substring(digitsStart, q)) + if (l > UINT64_MAX) return null + + var e = q + var frac = BigDecimal.ZERO + if (e < n && t[e] == '.') { + e++ + val fracStart = e + while (e < n && t[e] in '0'..'9') e++ + if (e > fracStart) frac = BigDecimal("0." + t.substring(fracStart, e)) + } + while (e < n && t[e] in WHITESPACE) e++ + + var i = startPos + while (i < IEC_FACTORS.size && !t.startsWith(IEC_FACTORS[i].first, e)) i++ + if (i >= IEC_FACTORS.size) return null + val factor = IEC_FACTORS[i].second + + val roundUp = if (frac.signum() > 0) BigInteger.ONE else BigInteger.ZERO + if (l + roundUp > UINT64_MAX / factor) return null + val tmp = l * factor + frac.multiply(BigDecimal(factor)).toBigInteger() + if (tmp > UINT64_MAX - total) return null + total += tmp + + p = e + IEC_FACTORS[i].first.length + startPos = i + 1 + } while (p < n) + return total + } + + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L3853-L3863 + private val ZERO_ALLOWED_KEYS = setOf( + "MemorySwapMax", "StartupMemorySwapMax", "MemoryZSwapMax", "StartupMemoryZSwapMax", "MemoryLow", "StartupMemoryLow", "MemoryMin", + ) + + // safe_atoi(): leading whitespace, an optional sign, base 0 (so 0x10 and 010 are numbers). + private val PERMYRIAD_NUMBER = SafeAtoTerminal( + signed = true, + base = 0, + flags = emptySet(), + minInclusive = SafeAtoTerminal.INT32_MIN, + maxInclusive = SafeAtoTerminal.INT32_MAX, + acceptsWhitespace = true, + acceptsMinus = true, + ) + + /** + * parse_permyriad(): `N‱`, `N[.d]‰` or `N[.d[d]]%`, at most 10000 permyriad. Returns the permyriad value, or + * null for -EINVAL / -ERANGE. + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/percent-util.c#L7-L157 + */ + private fun parsePermyriad(p: String): Int? { + val (body, scale, maxDecimals) = when { + p.endsWith("‱") -> Triple(p.removeSuffix("‱"), 1, 0) + p.endsWith("‰") -> Triple(p.removeSuffix("‰"), 10, 1) + p.endsWith("%") -> Triple(p.removeSuffix("%"), 100, 2) + else -> return null + } + val dot = if (maxDecimals > 0) body.indexOf('.') else -1 + val whole = if (dot >= 0) body.substring(0, dot) else body + val decimals = if (dot >= 0) body.substring(dot + 1) else "" + if (dot >= 0 && (decimals.isEmpty() || decimals.length > maxDecimals || decimals.any { it !in '0'..'9' })) { + return null + } + val v = PERMYRIAD_NUMBER.valueOf(whole) ?: return null + if (v.signum() < 0) { + return null + } + val fraction = if (decimals.isEmpty()) 0 else decimals.padEnd(maxDecimals, '0').toInt() + val result = v * BigInteger.valueOf(scale.toLong()) + BigInteger.valueOf(fraction.toLong()) + return if (result > BigInteger.valueOf(10000)) null else result.toInt() + } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/NamespacePathOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/NamespacePathOptionValue.kt index 1a5bb238..daf31715 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/NamespacePathOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/NamespacePathOptionValue.kt @@ -7,6 +7,7 @@ import com.intellij.openapi.util.TextRange import net.sjrx.intellij.plugins.systemdunitfiles.intentions.SwapPrefixOrderQuickFix import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFilePropertyType import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar class NamespacePathOptionValue : OptionValueInformation { @@ -16,9 +17,42 @@ class NamespacePathOptionValue : OptionValueInformation { val matches = NEXT_WORD_REGEX.findAll(value) for (match in matches) { - val text = match.groups[0]!!.value + val raw = match.groups[0]!!.value val textRange = match.groups[0]!!.toTextRange() + // extract_first_word(…, EXTRACT_UNQUOTE) drops quotes and keeps the character after a backslash. + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L5067-L5100 + val text = unquote(raw) + + // After the optional "-" and "+" prefixes the path goes through unit_path_printf(): an unknown specifier (%Q) + // is refused, a path that starts with a known one (%h, %S, ...) is only known once it is expanded, and %% is a + // literal '%'. Then path_simplify_and_warn(PATH_CHECK_ABSOLUTE) refuses a ".." component path_simplify() + // keeps (https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L33-L105, + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L34-L80). + val prefixLength = if (text.startsWith("-+")) 2 else if (text.startsWith("-") || text.startsWith("+")) 1 else 0 + val path = text.substring(prefixLength) + if (!KNOWN_SPECIFIERS_ONLY.matches(path)) { + holder.registerProblem( + property.valueNode.psi, + "Invalid Path [$path] contains an unknown specifier", + ProblemHighlightType.GENERIC_ERROR_OR_WARNING, + textRange + ) + continue + } + if (STARTS_WITH_SPECIFIER.matches(path)) { + continue + } + if (path.startsWith("/") && !NO_DOT_DOT_AFTER_SIMPLIFY.matches(path)) { + holder.registerProblem( + property.valueNode.psi, + "Invalid Path [$path] is not normalized: a '..' component is not allowed", + ProblemHighlightType.GENERIC_ERROR_OR_WARNING, + textRange + ) + continue + } + if (text.startsWith("/")) { // Absolute Path continue @@ -87,7 +121,43 @@ class NamespacePathOptionValue : OptionValueInformation { companion object { const val VALIDATOR_NAME = "config_parse_namespace_path_strv" - val NEXT_WORD_REGEX = """(?:[^\\ ]|\\\s|(?:\\\\))+""".toRegex() + // A word: anything but whitespace or quotes, a backslash escaping the next character, or a quoted run (which may + // contain whitespace). A backslash at the end of a line is a line continuation, not part of a word. + val NEXT_WORD_REGEX = """(?:[^\\\s"']|\\[^\r\n]|"(?:[^"\\]|\\[^\r\n])*"|'(?:[^'\\]|\\[^\r\n])*')+""".toRegex() + + // Every '%' is "%%", a known specifier, or a '%' that specifier_printf() keeps as it is. + private val KNOWN_SPECIFIERS_ONLY = "${BatchUnitCoreGrammar.UNIT_TEXT_CHAR}*".toRegex() + + private val STARTS_WITH_SPECIFIER = "%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}].*".toRegex() + + // An absolute path without a ".." component after the leading ones path_simplify() drops, or one that uses a known + // specifier (the expansion decides). + private val NO_DOT_DOT_AFTER_SIMPLIFY = + ("/(?:\\.{0,2}(?:/|$))*(?!(?:.*/)?\\.\\.(?:/|$)).*" + + "|/(?=(?:[^%]|%%|%(?![A-Za-z0-9%]))*%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}]).*").toRegex() + + private fun unquote(raw: String): String { + val word = StringBuilder() + var quote = 0.toChar() + var i = 0 + while (i < raw.length) { + val c = raw[i] + if (c == '\\' && i + 1 < raw.length) { + word.append(raw[i + 1]) + i += 2 + continue + } + if (quote == 0.toChar() && (c == '"' || c == '\'')) { + quote = c + } else if (c == quote) { + quote = 0.toChar() + } else { + word.append(c) + } + i++ + } + return word.toString() + } val validators = mapOf(Validator(VALIDATOR_NAME, "0") to NamespacePathOptionValue()) } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/RlimitOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/RlimitOptionValue.kt index 269f885e..34e01e15 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/RlimitOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/RlimitOptionValue.kt @@ -2,47 +2,279 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* -import java.util.Optional +import java.math.BigInteger +/** + * Limit*= settings: config_parse_rlimit() → rlimit_parse(). + * + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L1768-L1803 + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/rlimit-util.c#L79-L292 + * + * The value is `soft` or `soft:hard`, cut at the first ':' without trimming, so whitespace around a field reaches + * the field's parser: parse_size() and parse_time() skip it, safe_atou64() only skips leading whitespace, and the + * literal `infinity` of the size and number fields must match exactly. The soft limit must not be above the hard + * limit (-EILSEQ). + */ class RlimitOptionValue(grammar : Combinator) : GrammarOptionValue("config_parse_rlimit", grammar) { companion object { - val GENERIC_SEQ = - AlternativeCombinator( - OptionalWhitespacePrefix(FlexibleLiteralChoiceTerminal("infinity")), - OptionalWhitespacePrefix(IntegerTerminal(0, Int.MAX_VALUE))) + /** RLIM_INFINITY; every finite field must stay below it. */ + private val RLIM_INFINITY: BigInteger = SafeAtoTerminal.UINT64_MAX - val BYTE_SEQ = AlternativeCombinator( - FlexibleLiteralChoiceTerminal("infinity"), - SequenceCombinator( - OptionalWhitespacePrefix(IntegerTerminal(0, Int.MAX_VALUE)), - OptionalWhitespacePrefix(LiteralChoiceTerminal("K", "M", "G", "T", "P", "E")) - ), - OptionalWhitespacePrefix(IntegerTerminal(0, Int.MAX_VALUE))) + private val USEC_PER_SEC: BigInteger = BigInteger.valueOf(1_000_000L) + + // rlimit_parse_u64(): `infinity` or safe_atou64() below RLIM_INFINITY. + private val U64_NUMBER = SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = BigInteger.ZERO, + maxInclusive = RLIM_INFINITY - BigInteger.ONE, + acceptsWhitespace = true, + acceptsMinus = true, + ) + + val GENERIC_SEQ = PreferValidAlternativeCombinator( + LiteralChoiceTerminal("infinity"), + U64_NUMBER, + ) - val TIME_SEQ = AlternativeCombinator( - FlexibleLiteralChoiceTerminal("infinity"), - OneOrMore( + // rlimit_parse_size(): `infinity` or parse_size(…, 1024) below RLIM_INFINITY. parse_size() skips whitespace before + // a number but not after the last element, so a soft limit such as "12 G " (before the ':') is refused. + val BYTE_SEQ = PreferValidAlternativeCombinator( + LiteralChoiceTerminal("infinity"), + ValueChecked( SequenceCombinator( - OptionalWhitespacePrefix(IntegerTerminal(0, Int.MAX_VALUE)), - OptionalWhitespacePrefix(FlexibleLiteralChoiceTerminal("usec", "us", "μs", "msec", "ms", "seconds", "second", "sec", "s", "minutes", "minute", "min", "m", "hours", "hour", "hr", "h", "days", "day", "d", "weeks", "week", "w", "months", "month", "M", "years", "year", "y")) + ZeroOrOne(WhitespaceTerminal()), + parseSize(base = 1024, minInclusive = BigInteger.ZERO, maxInclusive = RLIM_INFINITY - BigInteger.ONE, allowInnerWhitespace = true), + ) + ) { text -> text.last() !in WHITESPACE }, + ) + + // rlimit_parse_sec() / rlimit_parse_usec(): parse_time(). The structure locates the parts; the ValueChecked + // around it runs the exact parse_time() rules (time-util.c#L1198-L1287). + private val TIME_ELEMENT = SequenceCombinator( + RegexTerminal("""\+?[0-9]+(?:\.[0-9]+)?|\.[0-9]+""", """\+?[0-9]+(?:\.[0-9]+)?|\.[0-9]+"""), + ZeroOrOne(WhitespaceTerminal()), + ZeroOrOne( + LiteralChoiceTerminal( + "seconds", "second", "sec", "s", "minutes", "minute", "min", "months", "month", "M", "msec", "ms", "m", + "hours", "hour", "hr", "h", "days", "day", "d", "weeks", "week", "w", "years", "year", "y", "usec", "us", "μs", "µs", ) ), - OptionalWhitespacePrefix(IntegerTerminal(0, Int.MAX_VALUE)) + ZeroOrOne(WhitespaceTerminal()), ) - val NICE_SEQ = AlternativeCombinator( - SequenceCombinator(LiteralChoiceTerminal("+", "-"), IntegerTerminal(0, 21)), - OptionalWhitespacePrefix(SequenceCombinator(IntegerTerminal(0, 41))), + val TIME_SEQ = ValueChecked( + SequenceCombinator( + ZeroOrOne(WhitespaceTerminal()), + PreferValidAlternativeCombinator( + SequenceCombinator(LiteralChoiceTerminal("infinity"), ZeroOrOne(WhitespaceTerminal())), + OneOrMore(TIME_ELEMENT), + ), + ) + ) { text -> parseTime(text, USEC_PER_SEC) != null } + + // The same for LimitRTTIME=, whose parse_time() default unit is the microsecond. + val USEC_SEQ = ValueChecked( + SequenceCombinator( + ZeroOrOne(WhitespaceTerminal()), + PreferValidAlternativeCombinator( + SequenceCombinator(LiteralChoiceTerminal("infinity"), ZeroOrOne(WhitespaceTerminal())), + OneOrMore(TIME_ELEMENT), + ), + ) + ) { text -> parseTime(text, BigInteger.ONE) != null } + + // rlimit_parse_nice(): "+n" is the nice level n (0…19), "-n" the nice level -n (0…20), and an unprefixed + // value is the raw limit (0…40). Only the very first character selects the form, so a field that starts with + // whitespace (" +5" after the ':') is the raw form, and safe_atou64() then skips the whitespace and takes the + // sign: " +5" is the raw limit 5 and " -0" is 0. + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/rlimit-util.c#L169-L218 + val NICE_SEQ = PreferValidAlternativeCombinator( + SequenceCombinator( + LiteralChoiceTerminal("+"), + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 19L, acceptsWhitespace = true, acceptsMinus = true), + ), + SequenceCombinator( + LiteralChoiceTerminal("-"), + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 20L, acceptsWhitespace = true, acceptsMinus = true), + ), + SafeAtoTerminal( + signed = false, + base = 0, + flags = setOf(SafeAtoTerminal.Flag.REFUSE_PLUS_MINUS), + minInclusive = 0L, + maxInclusive = 40L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + SequenceCombinator( + WhitespaceTerminal(), + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 40L, acceptsWhitespace = true, acceptsMinus = true), + ), ) val COLON = LiteralChoiceTerminal(":") - val BYTE_RLIMIT = SequenceCombinator(AlternativeCombinator(SequenceCombinator(BYTE_SEQ, COLON, BYTE_SEQ), BYTE_SEQ), EOF()) - val TIME_RLIMIT = SequenceCombinator(AlternativeCombinator(SequenceCombinator(TIME_SEQ, COLON, TIME_SEQ), TIME_SEQ), EOF()) - val GENERIC_RLIMIT = SequenceCombinator(AlternativeCombinator(SequenceCombinator(GENERIC_SEQ, COLON, GENERIC_SEQ), GENERIC_SEQ), EOF()) - val NICE_RLIMIT = SequenceCombinator(AlternativeCombinator(SequenceCombinator(NICE_SEQ, COLON, NICE_SEQ), NICE_SEQ), EOF()) + val BYTE_RLIMIT = SequenceCombinator( + ValueChecked(SequenceCombinator(BYTE_SEQ, ZeroOrOne(SequenceCombinator(COLON, BYTE_SEQ)))) { text -> + softNotAboveHard(text) { field -> if (field == "infinity") RLIM_INFINITY else evaluateParseSize(field, 1024) } + }, + EOF(), + ) + + val TIME_RLIMIT = SequenceCombinator( + ValueChecked(SequenceCombinator(TIME_SEQ, ZeroOrOne(SequenceCombinator(COLON, TIME_SEQ)))) { text -> + // rlimit_parse_sec() rounds up to whole seconds before the comparison. + softNotAboveHard(text) { field -> + parseTime(field, USEC_PER_SEC)?.let { usec -> + if (usec == RLIM_INFINITY) RLIM_INFINITY else (usec + USEC_PER_SEC - BigInteger.ONE) / USEC_PER_SEC + } + } + }, + EOF(), + ) + + // LimitRTTIME= uses parse_time() with microseconds as the default unit. + val USEC_RLIMIT = SequenceCombinator( + ValueChecked(SequenceCombinator(USEC_SEQ, ZeroOrOne(SequenceCombinator(COLON, USEC_SEQ)))) { text -> + softNotAboveHard(text) { field -> parseTime(field, BigInteger.ONE) } + }, + EOF(), + ) + + val GENERIC_RLIMIT = SequenceCombinator( + ValueChecked(SequenceCombinator(GENERIC_SEQ, ZeroOrOne(SequenceCombinator(COLON, GENERIC_SEQ)))) { text -> + softNotAboveHard(text) { field -> if (field == "infinity") RLIM_INFINITY else U64_NUMBER.valueOf(field) } + }, + EOF(), + ) + + // What rlimit_parse_nice() stores for a field; the unprefixed form is plain safe_atou64() (no flags). + private val NICE_RAW_NUMBER = SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 40L, + acceptsWhitespace = true, + acceptsMinus = true, + ) + + val NICE_RLIMIT = SequenceCombinator( + ValueChecked(SequenceCombinator(NICE_SEQ, ZeroOrOne(SequenceCombinator(COLON, NICE_SEQ)))) { text -> + softNotAboveHard(text) { field -> + val twenty = BigInteger.valueOf(20) + when { + field.startsWith("+") -> NICE_RAW_NUMBER.valueOf(field.substring(1))?.let { twenty - it } + field.startsWith("-") -> NICE_RAW_NUMBER.valueOf(field.substring(1))?.let { twenty + it } + else -> NICE_RAW_NUMBER.valueOf(field) + } + } + }, + EOF(), + ) + + /** + * False if [text] is `soft:hard` and the soft limit is above the hard one (rlimit_parse()'s -EILSEQ); null when a + * field cannot be evaluated, which leaves the verdict to the field grammars. + */ + private fun softNotAboveHard(text: String, evaluate: (String) -> BigInteger?): Boolean? { + val colon = text.indexOf(':') + if (colon < 0) return true + val soft = evaluate(text.substring(0, colon)) ?: return null + val hard = evaluate(text.substring(colon + 1)) ?: return null + return soft <= hard + } + + private const val WHITESPACE = " \t\n\r" + + // extract_multiplier()'s table, in table order (time-util.c#L1147-L1196). + private val TIME_MULTIPLIERS: List> = listOf( + "seconds" to 1_000_000L, "second" to 1_000_000L, "sec" to 1_000_000L, "s" to 1_000_000L, + "minutes" to 60_000_000L, "minute" to 60_000_000L, "min" to 60_000_000L, + "months" to 2_629_800_000_000L, "month" to 2_629_800_000_000L, "M" to 2_629_800_000_000L, + "msec" to 1_000L, "ms" to 1_000L, "m" to 60_000_000L, + "hours" to 3_600_000_000L, "hour" to 3_600_000_000L, "hr" to 3_600_000_000L, "h" to 3_600_000_000L, + "days" to 86_400_000_000L, "day" to 86_400_000_000L, "d" to 86_400_000_000L, + "weeks" to 604_800_000_000L, "week" to 604_800_000_000L, "w" to 604_800_000_000L, + "years" to 31_557_600_000_000L, "year" to 31_557_600_000_000L, "y" to 31_557_600_000_000L, + "usec" to 1L, "us" to 1L, "μs" to 1L, "µs" to 1L, + ) + + /** + * parse_time(t, &usec, defaultUnit), https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1198-L1287. + * Returns the value in microseconds (USEC_INFINITY for `infinity`), or null for -EINVAL / -ERANGE. + */ + private fun parseTime(t: String, defaultUnit: BigInteger): BigInteger? { + val usecInfinity = RLIM_INFINITY + val llongMax = BigInteger.valueOf(Long.MAX_VALUE) + val n = t.length + var p = 0 + while (p < n && t[p] in WHITESPACE) p++ + if (t.startsWith("infinity", p)) { + return if (t.substring(p + "infinity".length).all { it in WHITESPACE }) usecInfinity else null + } + + var usec = BigInteger.ZERO + var something = false + while (true) { + var multiplier = defaultUnit + while (p < n && t[p] in WHITESPACE) p++ + if (p >= n) { + return if (something) usec else null + } + if (t[p] == '-') return null + + // strtoll(p, &e, 10); a leading '-' was refused above. + var q = p + if (q < n && t[q] == '+') q++ + val digitsStart = q + while (q < n && t[q] in '0'..'9') q++ + val e = if (q == digitsStart) p else q + val l = if (q == digitsStart) BigInteger.ZERO else BigInteger(t.substring(digitsStart, q)) + if (l > llongMax) return null + + if (e < n && t[e] == '.') { + p = e + 1 + while (p < n && t[p] in '0'..'9') p++ + } else if (e == p) { + return null + } else { + p = e + } + + var s = p + while (s < n && t[s] in WHITESPACE) s++ + val suffix = TIME_MULTIPLIERS.firstOrNull { t.startsWith(it.first, s) } + if (suffix != null) { + multiplier = BigInteger.valueOf(suffix.second) + s += suffix.first.length + } + if (s == p && s < n) return null + p = s + + if (l >= usecInfinity / multiplier) return null + var k = l * multiplier + if (k >= usecInfinity - usec) return null + usec += k + something = true + if (e < n && t[e] == '.') { + var m = multiplier / BigInteger.TEN + var b = e + 1 + while (b < n && t[b] in '0'..'9') { + k = BigInteger.valueOf((t[b] - '0').toLong()) * m + if (k >= usecInfinity - usec) return null + usec += k + b++ + m /= BigInteger.TEN + } + if (b == e + 1) return null + } + } + } val validators = mapOf( // Exec.LimitCPU, config_parse_rlimit, RLIMIT_CPU, offsetof(Settings, rlimit) @@ -76,7 +308,7 @@ class RlimitOptionValue(grammar : Combinator) : GrammarOptionValue("config_parse // Exec.LimitRTPRIO, config_parse_rlimit, RLIMIT_RTPRIO, offsetof(Settings, rlimit) Validator("config_parse_rlimit", "RLIMIT_RTPRIO") to RlimitOptionValue(GENERIC_RLIMIT), // Exec.LimitRTTIME, config_parse_rlimit, RLIMIT_RTTIME, offsetof(Settings, rlimit) - Validator("config_parse_rlimit", "RLIMIT_RTTIME") to RlimitOptionValue(TIME_RLIMIT), + Validator("config_parse_rlimit", "RLIMIT_RTTIME") to RlimitOptionValue(USEC_RLIMIT), ) } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/SignalOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/SignalOptionValue.kt index a94f3e48..5dbdec9d 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/SignalOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/SignalOptionValue.kt @@ -2,7 +2,15 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues import com.intellij.openapi.project.Project import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SafeAtoTerminal +import java.math.BigInteger +/** + * config_parse_signal(): signal_from_string() must return a signal number above 0. + * + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L1556-L1581 + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/signal-util.c#L164-L224 + */ class SignalOptionValue : OptionValueInformation { override fun getAutoCompleteOptions(project: Project): Set { @@ -10,22 +18,38 @@ class SignalOptionValue : OptionValueInformation { } override fun getErrorMessage(value: String): String? { - if (value.trim() in SIGNALS) { - return null + val trimmed = value.trim() + + // A number (safe_atoi) must satisfy SIGNAL_VALID: 1 ≤ n < _NSIG (65), + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/signal-util.h#L56-L58 + val number = SIGNAL_NUMBER.valueOf(trimmed) + if (number != null) { + if (number >= BigInteger.ONE && number <= BigInteger.valueOf(SIGRTMAX.toLong())) { + return null + } + return "Illegal signal value of ${value}, if using an integer the value must be between 1 and $SIGRTMAX." } - val valInt = value.toIntOrNull() + val name = trimmed.removePrefix("SIG") + if ("SIG$name" in SIGNALS) { + return null + } - if (valInt is Int) { - if (valInt in 0..63) { - return null; - } else { - return "Illegal signal value of ${value}, if using an integer the value must be between 0 and 63." + // RTMIN, RTMIN+n, RTMAX, RTMAX-n with 0 ≤ n ≤ SIGRTMAX-SIGRTMIN; the sign is part of what safe_atoi() parses. + if (name == "RTMIN" || name == "RTMAX") { + return null + } + if (name.startsWith("RTMIN+")) { + val offset = SIGNAL_NUMBER.valueOf(name.removePrefix("RTMIN")) + if (offset != null && offset >= BigInteger.ZERO && offset <= BigInteger.valueOf((SIGRTMAX - SIGRTMIN).toLong())) { + return null } } - - if ("SIG${value}" in SIGNALS) { - return null; + if (name.startsWith("RTMAX-")) { + val offset = SIGNAL_NUMBER.valueOf(name.removePrefix("RTMAX")) + if (offset != null && offset <= BigInteger.ZERO && offset >= BigInteger.valueOf((SIGRTMIN - SIGRTMAX).toLong())) { + return null + } } return """Illegal signal value of ${value}, please consult the signal(7) man page for a full list.""" @@ -36,9 +60,28 @@ class SignalOptionValue : OptionValueInformation { companion object { const val VALIDATOR_NAME = "config_parse_signal" - private val SIGNALS = setOf("SIGABRT","SIGALRM","SIGBUS","SIGCHLD","SIGCLD","SIGCONT","SIGEMT","SIGFPE","SIGHUP","SIGILL","SIGINFO","SIGINT","SIGIO","SIGIOT","SIGKILL","SIGLOST","SIGPIPE","SIGPOLL","SIGPROF","SIGPWR","SIGQUIT","SIGSEGV","SIGSTKFLT","SIGSTOP","SIGSYS","SIGTERM","SIGTRAP","SIGTSTP","SIGTTIN","SIGTTOU","SIGURG","SIGUSR1","SIGUSR2","SIGVTALRM","SIGWINCH","SIGXCPU","SIGXFSZ") + // glibc on Linux: SIGRTMIN is 34 (the first two real-time signals are reserved by glibc), SIGRTMAX is 64. + private const val SIGRTMIN = 34 + private const val SIGRTMAX = 64 + + // safe_atoi(): signed, base 0, so "+5" and "0x9" are numbers too. + private val SIGNAL_NUMBER = SafeAtoTerminal( + signed = true, + base = 0, + flags = emptySet(), + minInclusive = SafeAtoTerminal.INT32_MIN, + maxInclusive = SafeAtoTerminal.INT32_MAX, + acceptsWhitespace = true, + acceptsMinus = true, + ) + + // static_signal_table, https://github.com/systemd/systemd/blob/0f0682b773/src/basic/signal-util.c#L110-L144 + // (SIGCLD, SIGEMT, SIGINFO, SIGIOT, SIGLOST and SIGPOLL are not in it, so systemd rejects them). + private val SIGNALS = setOf("SIGABRT", "SIGALRM", "SIGBUS", "SIGCHLD", "SIGCONT", "SIGFPE", "SIGHUP", "SIGILL", "SIGINT", "SIGIO", + "SIGKILL", "SIGPIPE", "SIGPROF", "SIGPWR", "SIGQUIT", "SIGSEGV", "SIGSTKFLT", "SIGSTOP", "SIGSYS", + "SIGTERM", "SIGTRAP", "SIGTSTP", "SIGTTIN", "SIGTTOU", "SIGURG", "SIGUSR1", "SIGUSR2", "SIGVTALRM", + "SIGWINCH", "SIGXCPU", "SIGXFSZ") val validators = mapOf(Validator(VALIDATOR_NAME, "0") to SignalOptionValue()) } } - diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/SimpleGrammarOptionValues.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/SimpleGrammarOptionValues.kt index d756044b..e400331e 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/SimpleGrammarOptionValues.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/SimpleGrammarOptionValues.kt @@ -2,6 +2,7 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* +import java.math.BigInteger @@ -66,24 +67,38 @@ open class SimpleGrammarOptionValues(validatorName: String, grammar: Combinator) ), EOF())), + // config_parse_capability_set() → parse_capability_set(): an optional leading '~', then words + // (extract_first_word with EXTRACT_UNQUOTE|EXTRACT_RELAX). capability_set_from_string() only logs unknown + // names at debug level and skips them, so systemd's parser accepts any word; the known names are offered for + // completion. https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L1866-L1897 + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/capability-list.c#L160-L186 Validator("config_parse_capability_set", "0") to SimpleGrammarOptionValues("config_parse_capability_set", - SequenceCombinator( - AlternativeCombinator( - SequenceCombinator(ZeroOrOne(FlexibleLiteralChoiceTerminal("~")), Capabilities, ZeroOrMore(SequenceCombinator(WhitespaceTerminal(), Capabilities))), - FlexibleLiteralChoiceTerminal("~"), + ZeroOrOne(LiteralChoiceTerminal("~")), + ZeroOrOne(WhitespaceTerminal()), + ZeroOrOne( + SequenceCombinator( + AlternativeCombinator(RegexTerminal("\\S+", "\\S+"), Capabilities), + ZeroOrMore( + SequenceCombinator( + WhitespaceTerminal(), + AlternativeCombinator(RegexTerminal("\\S+", "\\S+"), Capabilities), + ) + ), + ZeroOrOne(WhitespaceTerminal()), + ) ), EOF())), + // config_parse_exec_quota(): "off", a parse_permyriad() percentage (at most 100%), or a parse_size() byte count + // (base 1024, the full 64-bit range). + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L4668-L4708 Validator("config_parse_exec_quota", "0") to SimpleGrammarOptionValues("config_parse_exec_quota", SequenceCombinator( - AlternativeCombinator( - OptionalWhitespacePrefix( - SequenceCombinator(IntegerTerminal(0, 4_294_967_296), OptionalWhitespacePrefix(FlexibleLiteralChoiceTerminal("K","M","G", "T")) - )), - SequenceCombinator(IntegerTerminal(0, 101), FlexibleLiteralChoiceTerminal("%")), - OptionalWhitespacePrefix(IntegerTerminal(0, 4_294_967_296)), - FlexibleLiteralChoiceTerminal("off"), + PreferValidAlternativeCombinator( + LiteralChoiceTerminal("off"), + TC_PERCENT, + parseSize(base = 1024, minInclusive = BigInteger.ZERO, maxInclusive = SafeAtoTerminal.UINT64_MAX, allowInnerWhitespace = true), ), EOF() )) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAddressFamiliesOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAddressFamiliesOptionValue.kt index b0134cdc..2163ce93 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAddressFamiliesOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAddressFamiliesOptionValue.kt @@ -6,25 +6,18 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram /** * Validator for RestrictAddressFamilies=. * - * C function: config_parse_address_families in src/core/load-fragment.c → parse_address_families - * in src/shared/parse-helpers.c:90. Accepts: + * C function: config_parse_address_families → parse_address_families, + * https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L3543-L3576 + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L90-L145. Accepts: * - "none" (clears the set, sets allowlist) - * - optional leading "~" (invert / denylist mode), followed by a whitespace-separated list - * of address family names resolved by af_from_name + * - an optional leading "~" (denylist), then words separated by whitespace and/or commas + * (extract_first_word with WHITESPACE "," and EXTRACT_UNQUOTE; leading, trailing and repeated separators are + * fine), each resolved by af_from_name(), which ignores case and retries with "AF_" prepended + * (https://github.com/systemd/systemd/blob/0f0682b773/src/basic/af-list.c#L39-L51). So `AF_INET`, `inet` and + * `AF_DECNET` are all fine. A word may be quoted. * - * The name set is the list of AF_* macros systemd's af_from_name knows about, which is - * generated (src/basic/generate-af-list.sh) from the AF_* #defines in , minus - * AF_UNSPEC and AF_MAX. It can be reproduced by preprocessing with - * `cpp -dM`, keeping the `#define AF_*` lines (dropping AF_UNSPEC/AF_MAX), and taking the macro - * names. - * - * Enumerating the names exactly (rather than the old loose RegexTerminal("AF_[A-Z0-9_]+")) - * makes validation correct (AF_BOGUS is now rejected) and sets up grammar-based completion. - * - * Note: some of these names still resolve via af_from_name (the macro exists in libc headers) - * even though the kernel removed the protocol — AF_DECnet (Linux 6.1), AF_IRDA (4.17), - * AF_ECONET (3.5), AF_WANPIPE (2.6.21). These are valid-but-removed and are intended targets - * of a future deprecation annotator (see GitHub #467 / address_families(7)). + * The name terminal is the shared [ADDRESS_FAMILY_NAME], which also marks the families the kernel removed as + * deprecated. */ class ConfigParseAddressFamiliesOptionValue : SimpleGrammarOptionValues( "config_parse_address_families", @@ -33,80 +26,29 @@ class ConfigParseAddressFamiliesOptionValue : SimpleGrammarOptionValues( LiteralChoiceTerminal("none"), SequenceCombinator( ZeroOrOne(LiteralChoiceTerminal("~")), - ADDRESS_FAMILY, - ZeroOrMore(SequenceCombinator( - WhitespaceTerminal(), - ADDRESS_FAMILY - )) + ZeroOrOne(RegexTerminal("[\\s,]+", "[\\s,]+")), + ZeroOrOne( + SequenceCombinator( + AlternativeCombinator( + ADDRESS_FAMILY_NAME, + SequenceCombinator(LiteralChoiceTerminal("\""), ADDRESS_FAMILY_NAME, LiteralChoiceTerminal("\"")), + SequenceCombinator(LiteralChoiceTerminal("'"), ADDRESS_FAMILY_NAME, LiteralChoiceTerminal("'")), + ), + ZeroOrMore( + SequenceCombinator( + RegexTerminal("[\\s,]+", "[\\s,]+"), + AlternativeCombinator( + ADDRESS_FAMILY_NAME, + SequenceCombinator(LiteralChoiceTerminal("\""), ADDRESS_FAMILY_NAME, LiteralChoiceTerminal("\"")), + SequenceCombinator(LiteralChoiceTerminal("'"), ADDRESS_FAMILY_NAME, LiteralChoiceTerminal("'")), + ), + ) + ), + ZeroOrOne(RegexTerminal("[\\s,]+", "[\\s,]+")), + ) + ), ) ), EOF() ) -) { - companion object { - /** - * The AF_* names systemd's af_from_name accepts. FlexibleLiteralChoiceTerminal matches - * loosely for syntax (so coloring / error localization still work) but requires an exact - * choice to be semantically valid. - */ - private val ADDRESS_FAMILY = FlexibleLiteralChoiceTerminal( - "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" - ).deprecating( - // Still resolved by af_from_name (the libc macro exists) but the kernel removed the - // protocol, so configuring them has no effect. Reasons per address_families(7). - 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.", - ) - ) - } -} +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecCpuAffinityOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecCpuAffinityOptionValue.kt index 118f3bd7..dae2c85c 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecCpuAffinityOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecCpuAffinityOptionValue.kt @@ -6,14 +6,22 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram /** * Validator for CPUAffinity=. * - * C function: config_parse_exec_cpu_affinity in src/core/load-fragment.c. Either the literal - * "numa" (use NUMA-derived CPU set) or a CPU set list parsed by the same code path as - * NUMAMask= / AllowedCPUs=: whitespace- or comma-separated integers and `N-M` ranges. + * C function: config_parse_exec_cpu_affinity, + * https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L1834-L1864. Either the literal + * "numa" or a CPU set: config_parse_unit_cpu_set() expands specifiers in the whole value, then + * config_parse_cpu_set() splits it with extract_first_word(WHITESPACE ",") — leading, trailing and repeated + * separators are fine — and reads each word as `N` or `N-M` + * (https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L3790-L3819, + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/cpu-set-util.c#L220-L300). * - * The "numa" literal goes first explicitly; otherwise the CPU set grammar matches integers - * and ranges. Note the existing AllowedCpuSetOptionValue handles the same syntax for - * config_parse_unit_cpu_set — duplicated here as a grammar so it composes with the "numa" - * alternative. + * A word that uses a known specifier (e.g. %I) is only known once it is expanded, which depends on the unit name, so + * it is accepted; an unknown specifier (e.g. %Q) makes unit_full_printf() refuse the whole value + * (https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L33-L105). Every other word goes + * through parse_range() (safe_atou(), base 0, so "0x3" and "+1" are numbers) and must give lower <= upper below + * CPU_SET_MAX_NCPU (8192): + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L305-L340 + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/cpu-set-util.c#L186-L206 + * A list of separators only ("," or " , ") is an empty set, which is fine. */ class ConfigParseExecCpuAffinityOptionValue : SimpleGrammarOptionValues( "config_parse_exec_cpu_affinity", @@ -21,18 +29,76 @@ class ConfigParseExecCpuAffinityOptionValue : SimpleGrammarOptionValues( AlternativeCombinator( LiteralChoiceTerminal("numa"), SequenceCombinator( - CPU_RANGE, - ZeroOrMore(SequenceCombinator( - AlternativeCombinator(WhitespaceTerminal(), LiteralChoiceTerminal(",")), - CPU_RANGE - )) + ZeroOrOne(RegexTerminal("[\\s,]+", "[\\s,]+")), + ZeroOrOne( + SequenceCombinator( + AlternativeCombinator(SPECIFIER_WORD, CPU_RANGE), + ZeroOrMore(SequenceCombinator( + RegexTerminal("[\\s,]+", "[\\s,]+"), + AlternativeCombinator(SPECIFIER_WORD, CPU_RANGE) + )), + ZeroOrOne(RegexTerminal("[\\s,]+", "[\\s,]+")), + ) + ), ) ), EOF() ) ) { companion object { - // `N` or `N-M` (each side a non-negative integer) - private val CPU_RANGE = RegexTerminal("[0-9]+(-[0-9]+)?", "[0-9]+(-[0-9]+)?") + // A word with a known specifier and no unknown one. + private val SPECIFIER_WORD = RegexTerminal( + "[^\\s,]*%[^\\s,]*", + "(?=(?:[^\\s,%]|%%|%(?![A-Za-z0-9%]))*%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}])" + + "(?:[^\\s,%]|%%|%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}]|%(?![A-Za-z0-9%]))+" + ) + + // `N` or `N-M`, checked like parse_range() and cpu_set_add_range(). + private val CPU_RANGE = ValueChecked(RegexTerminal("[^\\s,%]+", "[^\\s,%]+")) { text -> cpuRangeIsAccepted(text) } + + // safe_atou(): base 0, an optional '+', and "-0" is 0. + private val CPU_NUMBER = SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 4_294_967_295L, + acceptsWhitespace = true, + acceptsMinus = true, + ) + + private const val CPU_SET_MAX_NCPU = 8192 + + private fun cpuRangeIsAccepted(raw: String): Boolean { + // extract_first_word(…, EXTRACT_UNQUOTE): drop quotes, a backslash keeps the next character. + val word = StringBuilder() + var quote = 0.toChar() + var i = 0 + while (i < raw.length) { + val c = raw[i] + if (c == '\\' && i + 1 < raw.length) { + word.append(raw[i + 1]) + i += 2 + continue + } + if (quote == 0.toChar() && (c == '"' || c == '\'')) { + quote = c + } else if (c == quote) { + quote = 0.toChar() + } else { + word.append(c) + } + i++ + } + if (quote != 0.toChar()) { + return false + } + + val text = word.toString() + val dash = text.indexOf('-') + val lower = CPU_NUMBER.valueOf(if (dash < 0) text else text.substring(0, dash)) ?: return false + val upper = if (dash < 0) lower else CPU_NUMBER.valueOf(text.substring(dash + 1)) ?: return false + return lower <= upper && upper < CPU_SET_MAX_NCPU.toBigInteger() + } } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecInputOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecInputOptionValue.kt index 6be9a610..a2d723da 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecInputOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecInputOptionValue.kt @@ -6,26 +6,42 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram /** * Validator for StandardInput=. * - * C function: config_parse_exec_input in src/core/load-fragment.c. Accepts: - * - "fd:NAME" — fdname_is_valid name (alphanumerics-ish, specifier-aware) - * - "file:/PATH" — absolute path (specifier-aware) - * - one of the bare enum names from exec_input_table in src/core/execute.c: - * null, tty, tty-force, tty-fail, socket, data - * (fd / file appear as prefixes above; their bare form isn't accepted) - * - * The structured branches (fd:/file:) come before the enum so that a literal "fd"/"file" - * (which aren't in the enum table anyway) wouldn't accidentally match. Order also matters - * because LiteralChoiceTerminal does first-match-by-length. + * C function: config_parse_exec_input, + * https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L1136-L1206. Accepts: + * - "fd:NAME" — NAME may be empty, otherwise fdname_is_valid(): printable ASCII except ':', at most 255 + * characters (https://github.com/systemd/systemd/blob/0f0682b773/src/basic/fd-util.c#L554-L579). Spaces + * are allowed. + * - "file:PATH" — PATH goes through path_simplify_and_warn(PATH_CHECK_ABSOLUTE|PATH_CHECK_FATAL): absolute + * (spaces allowed) with no ".." component after the leading ones path_simplify() drops + * (https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L34-L80). A path that + * starts with a known specifier is only known once it is expanded, so it is accepted; an unknown specifier + * (https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L33-L105) is refused. + * - any name from exec_input_table, including the bare "fd" and "file" + * (https://github.com/systemd/systemd/blob/0f0682b773/src/core/execute.c#L3242-L3253). */ class ConfigParseExecInputOptionValue : SimpleGrammarOptionValues( "config_parse_exec_input", SequenceCombinator( - AlternativeCombinator( - // fd:NAME (NAME is allowed to be empty per the C code's isempty check) - SequenceCombinator(LiteralChoiceTerminal("fd:"), RegexTerminal("\\S*", "\\S*")), - // file:/PATH - SequenceCombinator(LiteralChoiceTerminal("file:"), RegexTerminal("/\\S*", "/\\S*")), - LiteralChoiceTerminal("null", "tty", "tty-force", "tty-fail", "socket", "data") + // PreferValid: under the legacy engine the bare "file" choice would otherwise be matched out of an invalid + // "file:rel" and mislocate the error. + PreferValidAlternativeCombinator( + // The name goes through unit_fd_printf() first, so an unknown specifier (%Q) is refused. + SequenceCombinator( + LiteralChoiceTerminal("fd:"), + RegexTerminal(".*", "(?=${BatchUnitCoreGrammar.UNIT_TEXT_CHAR}*\$)[\\x20-\\x39\\x3b-\\x7e]{0,255}") + ), + // unit_path_printf(), then the path checks: a known leading specifier is accepted, an unknown one anywhere + // is refused, and "%%/a" is relative. Without a specifier, no ".." component may survive path_simplify(). + SequenceCombinator( + LiteralChoiceTerminal("file:"), + RegexTerminal( + ".+", + "%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}]${BatchUnitCoreGrammar.UNIT_TEXT_CHAR}*\$" + + "|/(?=(?:[^%]|%%|%(?![A-Za-z0-9%]))*%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}])${BatchUnitCoreGrammar.UNIT_TEXT_CHAR}*\$" + + "|/(?:\\.{0,2}(?:/|$))*(?!(?:.*/)?\\.\\.(?:/|$))(?:[^%]|%%|%(?![A-Za-z0-9%]))*\$" + ) + ), + FlexibleLiteralChoiceTerminal("null", "tty", "tty-force", "tty-fail", "socket", "fd", "data", "file", "broker", "broker-log") ), EOF() ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecNiceOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecNiceOptionValue.kt index b7070ce8..2e0b04c0 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecNiceOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecNiceOptionValue.kt @@ -15,7 +15,18 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.Simp class ConfigParseExecNiceOptionValue : SimpleGrammarOptionValues( "config_parse_exec_nice", SequenceCombinator( - IntegerTerminal(-20, 20), // -20 to 19 inclusive (max is exclusive) + // parse_nice() is safe_atoi() plus a range check, so "+12", "0x10" and "\v5" are fine. + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L724-L761 + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L702-L716 + SafeAtoTerminal( + signed = true, + base = 0, + flags = emptySet(), + minInclusive = -20L, + maxInclusive = 19L, + acceptsWhitespace = true, + acceptsMinus = true, + ), EOF() ) ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecOomScoreAdjustOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecOomScoreAdjustOptionValue.kt index 711b43f6..feefe74e 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecOomScoreAdjustOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecOomScoreAdjustOptionValue.kt @@ -14,7 +14,18 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.Simp class ConfigParseExecOomScoreAdjustOptionValue : SimpleGrammarOptionValues( "config_parse_exec_oom_score_adjust", SequenceCombinator( - IntegerTerminal(-1000, 1001), // -1000 to 1000 inclusive (max is exclusive) + // parse_oom_score_adjust() is safe_atoi() plus a range check, so "+12", "0x10" and "\v5" are fine. + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L763-L800 + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L762-L777 + 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/ai/ConfigParseExecSecureBitsOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecSecureBitsOptionValue.kt index 67353acb..257ec2b9 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecSecureBitsOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecSecureBitsOptionValue.kt @@ -1,8 +1,10 @@ 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.FlexibleLiteralChoiceTerminal +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.WhitespaceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrMore @@ -13,23 +15,29 @@ private val SECURE_BIT = FlexibleLiteralChoiceTerminal( "no-setuid-fixup", "no-setuid-fixup-locked", "noroot", - "noroot-locked" + "noroot-locked", + "no-cap-ambient-raise", + "no-cap-ambient-raise-locked", + "exec-restrict-file", + "exec-restrict-file-locked", + "exec-deny-interactive", + "exec-deny-interactive-locked" ) /** * Validator for Service.SecureBits, Socket.SecureBits, Mount.SecureBits, Swap.SecureBits. * - * C Function: config_parse_exec_secure_bits(0) + * C Function: config_parse_exec_secure_bits(0) → secure_bits_from_string(), + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/securebits-util.c#L93-L134 * - * Accepts a whitespace-separated list of secure bits flags. Valid values are: - * keep-caps, keep-caps-locked, no-setuid-fixup, no-setuid-fixup-locked, - * noroot, noroot-locked. + * A whitespace-separated list of secure bits flags. secure_bits_from_string() skips words it does not know without + * an error, so systemd's parser accepts any word; the known flags are offered for completion. */ class ConfigParseExecSecureBitsOptionValue : SimpleGrammarOptionValues( "config_parse_exec_secure_bits", SequenceCombinator( - SECURE_BIT, - ZeroOrMore(SequenceCombinator(WhitespaceTerminal(), SECURE_BIT)), + AlternativeCombinator(RegexTerminal("\\S+", "\\S+"), SECURE_BIT), + ZeroOrMore(SequenceCombinator(WhitespaceTerminal(), AlternativeCombinator(RegexTerminal("\\S+", "\\S+"), SECURE_BIT))), EOF() ) ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecSelinuxContextOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecSelinuxContextOptionValue.kt index cb8dde61..1a08c818 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecSelinuxContextOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecSelinuxContextOptionValue.kt @@ -15,7 +15,11 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram class ConfigParseExecSelinuxContextOptionValue : SimpleGrammarOptionValues( "config_parse_exec_selinux_context", SequenceCombinator( - RegexTerminal(".*", "-?(?:[^%]|%%|%[iIjJnNpPfyYcrRCdDELSthsaAbBHlqmMovwWgGuUTV])*"), + // specifier_printf() only refuses an unknown ALPHANUMERIC specifier (-EBADSLT); a '%' followed by any other + // character, or at the very end, is kept as it is. + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L1899-L1945 + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L31-L105 + RegexTerminal(".*", "-?(?:[^%]|%%|%[iIjJnNpPfyYcrRCdDELSthsaAbBHlqmMovwWgGuUTV]|%(?![A-Za-z0-9%]))*"), EOF() ) ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecSmackProcessLabelOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecSmackProcessLabelOptionValue.kt index 44446ffc..10e21cb0 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecSmackProcessLabelOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseExecSmackProcessLabelOptionValue.kt @@ -15,7 +15,11 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram class ConfigParseExecSmackProcessLabelOptionValue : SimpleGrammarOptionValues( "config_parse_exec_smack_process_label", SequenceCombinator( - RegexTerminal(".*", "-?(?:[^%]|%%|%[iIjJnNpPfyYcrRCdDELSthsaAbBHlqmMovwWgGuUTV])*"), + // specifier_printf() only refuses an unknown ALPHANUMERIC specifier (-EBADSLT); a '%' followed by any other + // character, or at the very end, is kept as it is. + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L1995-L2041 + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L31-L105 + RegexTerminal(".*", "-?(?:[^%]|%%|%[iIjJnNpPfyYcrRCdDELSthsaAbBHlqmMovwWgGuUTV]|%(?![A-Za-z0-9%]))*"), EOF() ) ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseHhfPacketLimitOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseHhfPacketLimitOptionValue.kt index 704eb320..2f4aac98 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseHhfPacketLimitOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseHhfPacketLimitOptionValue.kt @@ -9,13 +9,22 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.Simp * C Function: config_parse_hhf_packet_limit(QDISC_KIND_HHF) * Used by Options: HeavyHitterFilter.PacketLimit * - * Validates that the value is an unsigned integer in the range 0 to 4294967294. + * Validates that the value is a safe_atou32() unsigned integer in the range 0 to 4294967295. * This specifies the hard limit on the queue size in number of packets. + * https://github.com/systemd/systemd/blob/0f0682b773/src/network/tc/hhf.c#L40-L90 */ class ConfigParseHhfPacketLimitOptionValue : SimpleGrammarOptionValues( "config_parse_hhf_packet_limit", SequenceCombinator( - IntegerTerminal(0, 4294967295), // Range is 0 to 4294967294 inclusive (max is exclusive) + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 4_294_967_295L, + acceptsWhitespace = true, + acceptsMinus = true, + ), EOF() ) ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseManagedOomMemPressureDurationSecOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseManagedOomMemPressureDurationSecOptionValue.kt index 4b338096..12b34733 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseManagedOomMemPressureDurationSecOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseManagedOomMemPressureDurationSecOptionValue.kt @@ -2,23 +2,22 @@ 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.* +import java.math.BigInteger /** * Validator for ManagedOOMMemoryPressureDurationSec=. * - * C function: config_parse_managed_oom_mem_pressure_duration_sec in src/core/load-fragment.c. - * Empty resets to NETWORK_ROUTE_ADDR_USEC_INFINITY. Otherwise calls parse_sec(rvalue) and additionally requires - * the result to be at least 1s and strictly less than infinity. - * - * The grammar matches parse_sec syntax (which is the existing TIME_VALUE: "infinity" or a - * compound time expression). The semantic "must be >= 1s and != infinity" can't be enforced - * by a grammar without arithmetic — leaving that as a runtime check (the systemd parser will - * still log a warning at unit load). + * C function: config_parse_managed_oom_mem_pressure_duration_sec in src/core/load-fragment.c. The empty value + * resets it. Otherwise parse_sec(rvalue) must succeed and the result must be at least 1s and not infinity. */ class ConfigParseManagedOomMemPressureDurationSecOptionValue : SimpleGrammarOptionValues( "config_parse_managed_oom_mem_pressure_duration_sec", SequenceCombinator( - OptionalWhitespacePrefix(TIME_VALUE), + // parse_sec(), then at least 1s and not infinity. + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L4134-L4170 + ValueChecked(parseSec(maxUsec = SafeAtoTerminal.UINT64_MAX - BigInteger.ONE)) { text -> + evaluateParseSec(text)?.let { it >= BigInteger.valueOf(1_000_000L) } + }, EOF() ) ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNamespaceFlagsOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNamespaceFlagsOptionValue.kt index 9ae10b52..309e53ae 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNamespaceFlagsOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNamespaceFlagsOptionValue.kt @@ -1,6 +1,7 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.AlternativeCombinator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BOOLEAN import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.FlexibleLiteralChoiceTerminal @@ -22,10 +23,17 @@ private val NAMESPACE_TYPE = FlexibleLiteralChoiceTerminal( "time" ) -private val NAMESPACE_LIST = SequenceCombinator( - ZeroOrOne(LiteralChoiceTerminal("~")), - NAMESPACE_TYPE, - ZeroOrMore(SequenceCombinator(WhitespaceTerminal(), NAMESPACE_TYPE)) +// namespace_flags_from_string() splits on whitespace; a leading '~' is cut off first, so whitespace may follow +// it, and "~" alone is an empty (inverted) list. +// https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L3579-L3643 +// https://github.com/systemd/systemd/blob/0f0682b773/src/shared/nsflags.c#L10-L41 +private val NAMESPACE_LIST = AlternativeCombinator( + SequenceCombinator( + LiteralChoiceTerminal("~"), + ZeroOrOne(WhitespaceTerminal()), + ZeroOrOne(SequenceCombinator(NAMESPACE_TYPE, ZeroOrMore(SequenceCombinator(WhitespaceTerminal(), NAMESPACE_TYPE)))), + ), + SequenceCombinator(NAMESPACE_TYPE, ZeroOrMore(SequenceCombinator(WhitespaceTerminal(), NAMESPACE_TYPE))), ) /** diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNetemPacketLimitOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNetemPacketLimitOptionValue.kt index 57f3189e..1ea4ded0 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNetemPacketLimitOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNetemPacketLimitOptionValue.kt @@ -9,13 +9,22 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.Simp * C Function: config_parse_netem_packet_limit(QDISC_KIND_NETEM) * Used by Options: NetworkEmulator.PacketLimit * - * Validates an unsigned integer in the range 0…4294967294 representing + * Validates a safe_atou() unsigned integer in the range 0…4294967295 representing * the maximum number of packets the qdisc may hold queued at a time. + * https://github.com/systemd/systemd/blob/0f0682b773/src/network/tc/netem.c#L172-L221 */ class ConfigParseNetemPacketLimitOptionValue : SimpleGrammarOptionValues( "config_parse_netem_packet_limit", SequenceCombinator( - IntegerTerminal(0, 4294967295), + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 4_294_967_295L, + acceptsWhitespace = true, + acceptsMinus = true, + ), EOF() ) ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePrivateUsersOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePrivateUsersOptionValue.kt index 867d0b45..e9f66126 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePrivateUsersOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePrivateUsersOptionValue.kt @@ -31,8 +31,11 @@ class ConfigParsePrivateUsersOptionValue : SimpleGrammarOptionValues( ), // bare uid IntegerTerminal(0, 4_294_967_296L), - // string aliases - LiteralChoiceTerminal("pick", "identity"), + // string aliases. The unit-file PrivateUsers= (load-fragment.c, private_users_table: self, identity, + // full, managed, or a boolean) shares this validator name with nspawn's, so both sets are accepted here. + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/namespace.c#L4263-L4271 + // https://github.com/systemd/systemd/blob/0f0682b773/src/nspawn/nspawn-settings.c#L662-L745 + LiteralChoiceTerminal("pick", "identity", "self", "full", "managed"), BOOLEAN ), EOF() diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSyscallErrnoOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSyscallErrnoOptionValue.kt index ebde9b32..09db317c 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSyscallErrnoOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseSyscallErrnoOptionValue.kt @@ -6,23 +6,36 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram /** * Validator for SystemCallErrorNumber=. * - * C function: config_parse_syscall_errno in src/core/load-fragment.c → parse_errno in - * src/basic/parse-util.c. Accepts: + * C function: config_parse_syscall_errno → parse_errno, + * https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L3504-L3541 + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L342-L360. Accepts: * - "kill" (special, resets to SECCOMP_ERROR_NUMBER_KILL) - * - an errno name (uppercase "E"-prefixed, e.g. EPERM, ENOENT) via errno_from_name - * - an integer that maps to a valid errno via errno_is_valid (or 0) + * - an errno name (E-prefixed, any case, e.g. EPERM, eperm, E2BIG) via errno_from_name + * - a safe_atoi() number from 1 to ERRNO_MAX (4095). parse_errno() lets 0 through, but + * config_parse_syscall_errno() then refuses it ("Invalid error number"). * - * The grammar matches the errno-name pattern loosely (any uppercase E-prefixed token); - * unknown names like "ENOIENT" would slip past the grammar but fail at runtime. Numeric - * range here is 0..4095 — wider than typical valid errnos but enough to catch obvious typos. + * The grammar matches the errno-name pattern loosely (any E-prefixed alphanumeric token); + * unknown names like "ENOIENT" would slip past the grammar. */ class ConfigParseSyscallErrnoOptionValue : SimpleGrammarOptionValues( "config_parse_syscall_errno", SequenceCombinator( AlternativeCombinator( LiteralChoiceTerminal("kill"), - RegexTerminal("E[A-Z][A-Z0-9]*", "E[A-Z][A-Z0-9]*"), - IntegerTerminal(0, 4096) + // errno_from_name() is a gperf lookup built with --ignore-case, so "eperm" is fine, and names such as + // E2BIG have a digit second. + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/errno-list.c#L14-L25 + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/meson.build#L164-L173 + RegexTerminal("[Ee][A-Za-z0-9]+", "[Ee][A-Za-z0-9]+"), + SafeAtoTerminal( + signed = true, + base = 0, + flags = emptySet(), + minInclusive = 1L, + maxInclusive = 4095L, + acceptsWhitespace = true, + acceptsMinus = true, + ), ), EOF() ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitEnvFileOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitEnvFileOptionValue.kt index 0a13f714..7db2651d 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitEnvFileOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitEnvFileOptionValue.kt @@ -6,16 +6,33 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram /** * Validator for EnvironmentFile=. * - * C function: config_parse_unit_env_file in src/core/load-fragment.c. Each line is a single - * environment file path (after unit_path_printf specifier expansion); an optional leading "-" - * marks the file as missing-OK. The resolved path must be absolute (path_simplify_and_warn - * with PATH_CHECK_ABSOLUTE). + * C function: config_parse_unit_env_file, + * https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L2717-L2761. The whole value is one + * path (whitespace included) after unit_path_printf() specifier expansion; an optional leading "-" marks the file + * as missing-OK. The rest goes through path_simplify_and_warn(PATH_CHECK_ABSOLUTE): it must be absolute and, + * after path_simplify() drops leading "..", "." and empty components, contain no ".." component + * (https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L34-L80). + * + * A path that starts with a known specifier (%h, %E, ...) is only known once it is expanded, which depends on the + * unit and the machine, so it is accepted; an unknown specifier anywhere is refused. */ class ConfigParseUnitEnvFileOptionValue : SimpleGrammarOptionValues( "config_parse_unit_env_file", SequenceCombinator( ZeroOrOne(LiteralChoiceTerminal("-")), - RegexTerminal("/\\S*", "/\\S*"), + AlternativeCombinator( + // A path that starts with a known specifier (%h, %S, ...) is only known once it is expanded; an unknown + // specifier (%Q) makes unit_path_printf() fail, and %% is a literal '%' (so "%%/a" is relative). + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L33-L105 + RegexTerminal("%.*", "%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}]${BatchUnitCoreGrammar.UNIT_TEXT_CHAR}*"), + // An absolute path: with a known specifier further on, only the specifiers are checked; without one, + // also no ".." component after the leading ones path_simplify() drops. + RegexTerminal( + "/.*", + "/(?=(?:[^%]|%%|%(?![A-Za-z0-9%]))*%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}])${BatchUnitCoreGrammar.UNIT_TEXT_CHAR}*\$" + + "|/(?:\\.{0,2}(?:/|$))*(?!(?:.*/)?\\.\\.(?:/|$))(?:[^%]|%%|%(?![A-Za-z0-9%]))*\$" + ) + ), EOF() ) ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnsetEnvironOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnsetEnvironOptionValue.kt index 8bffd48b..839d0ed8 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnsetEnvironOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnsetEnvironOptionValue.kt @@ -15,8 +15,23 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * an invalid entry localizes to that entry rather than invalidating the whole value. */ private val UNSET_ENVIRON_ENTRY = SequenceCombinator( - RegexTerminal("[^=\\s]+", "[A-Za-z_][A-Za-z0-9_]*"), - ZeroOrOne(SequenceCombinator(LiteralChoiceTerminal("="), RegexTerminal("[^\\s]*", "[^\\s]*"))) + // A name with a known specifier (e.g. X%b) is only known once unit_env_printf() expands it, which depends on the + // machine, so it is accepted. An unknown specifier (e.g. %Q), in the name or the value, makes unit_env_printf() + // fail, so the entry is ignored. + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L2905-L2972 + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L33-L105 + RegexTerminal( + "[^=\\s]+", + "(?=(?:[^=\\s%]|%%|%(?![A-Za-z0-9%]))*%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}])" + + "(?:[^=\\s%]|%%|%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}]|%(?![A-Za-z0-9%]))+" + + "|[A-Za-z_][A-Za-z0-9_]*" + ), + ZeroOrOne( + SequenceCombinator( + LiteralChoiceTerminal("="), + RegexTerminal("[^\\s]*", "(?:[^\\s%]|%%|%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}]|%(?![A-Za-z0-9%]))*") + ) + ) ) class ConfigParseUnsetEnvironOptionValue : SimpleGrammarOptionValues( diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUserGroupCompatOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUserGroupCompatOptionValue.kt index 0b74730b..cf17d6c8 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUserGroupCompatOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUserGroupCompatOptionValue.kt @@ -6,21 +6,41 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram /** * Validator for User= / Group= (and similar) keys parsed by config_parse_user_group_compat. * - * C function: config_parse_user_group_compat in src/core/load-fragment.c. It runs unit_full_printf() - * (specifier expansion) and then valid_user_group_name() with - * VALID_USER_ALLOW_NUMERIC | VALID_USER_RELAX | VALID_USER_WARN. - * - * In RELAX mode (see src/basic/user-util.c) the name is checked only superficially: it must be - * non-empty, valid UTF-8, contain no control characters, contain no ':' or '/', and have no - * leading/trailing whitespace (interior whitespace is allowed). Numeric IDs are allowed. We model - * exactly those rules and leave the rarer RELAX checks (purely-numeric-but-not-a-uid, "-N", "."/ - * "..") unenforced rather than risk false positives. '%' is allowed (specifier syntax). The empty - * value resets the field. + * C function: config_parse_user_group_compat, + * https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L2550-L2597. It runs + * unit_full_printf() (specifier expansion) and then valid_user_group_name() with + * VALID_USER_ALLOW_NUMERIC | VALID_USER_RELAX | VALID_USER_WARN + * (https://github.com/systemd/systemd/blob/0f0682b773/src/basic/user-util.c#L718-L791): + * - something parse_uid() accepts is fine: decimal, no sign, no leading zero, below 2^32-1, and not 65535; + * - otherwise the RELAX checks apply: no leading/trailing space, no control characters, no ':' or '/', not + * all digits, not '-' followed by only digits (so not "-1000" and not "-"), and not "." or "..". + * '%' is allowed (specifier syntax), but an unknown specifier (e.g. %Q) makes unit_full_printf() fail + * (https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L33-L105). The empty value resets the + * field. */ class ConfigParseUserGroupCompatOptionValue : SimpleGrammarOptionValues( "config_parse_user_group_compat", SequenceCombinator( - RegexTerminal(".+", "[^\\s:/\\x00-\\x1F](?:[^:/\\x00-\\x1F]*[^\\s:/\\x00-\\x1F])?"), + PreferValidAlternativeCombinator( + RegexTerminal( + ".+", + "(?=${BatchUnitCoreGrammar.UNIT_TEXT_CHAR}*\$)(?!-?[0-9]*$)(?!\\.\\.?$)[^\\s:/\\x00-\\x1F\\x7F](?:[^:/\\x00-\\x1F\\x7F]*[^\\s:/\\x00-\\x1F\\x7F])?" + ), + // uid_is_valid() also refuses 65535, the 16-bit -1. + ValueChecked(SafeAtoTerminal( + signed = false, + base = 10, + flags = setOf( + SafeAtoTerminal.Flag.REFUSE_PLUS_MINUS, + SafeAtoTerminal.Flag.REFUSE_LEADING_ZERO, + SafeAtoTerminal.Flag.REFUSE_LEADING_WHITESPACE + ), + minInclusive = 0L, + maxInclusive = 4_294_967_294L, + acceptsWhitespace = false, + acceptsMinus = true, + )) { text -> text != "65535" }, + ), EOF() ) ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseWorkingDirectoryOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseWorkingDirectoryOptionValue.kt index 13b725b1..9e8bebc8 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseWorkingDirectoryOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseWorkingDirectoryOptionValue.kt @@ -6,13 +6,17 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram /** * Validator for WorkingDirectory=. * - * C function: config_parse_working_directory in src/core/load-fragment.c. Accepts: - * - optional leading "-" (missing path is non-fatal) - * - then either "~" alone (use the user's home) OR an absolute path + * C function: config_parse_working_directory, + * https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L2656-L2715. Accepts: + * - optional leading "-" (missing directory is non-fatal) + * - then either "~" alone (the user's home) or a path, which goes through unit_path_printf() and + * path_simplify_and_warn(PATH_CHECK_ABSOLUTE): it must be absolute and, after path_simplify() drops leading + * "..", "." and empty components, contain no ".." component + * (https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L34-L80). Whitespace inside + * the path is fine. * - * Path values go through unit_path_printf so "%X" specifiers may appear inline. The grammar - * allows any non-whitespace characters in the path; tighter shell-metacharacter checks happen - * in path_simplify_and_warn at runtime. + * A path that starts with a known specifier (%h, %S, ...) is only known once it is expanded, which depends on the + * unit and the machine, so it is accepted; an unknown specifier anywhere is refused. */ class ConfigParseWorkingDirectoryOptionValue : SimpleGrammarOptionValues( "config_parse_working_directory", @@ -20,7 +24,17 @@ class ConfigParseWorkingDirectoryOptionValue : SimpleGrammarOptionValues( ZeroOrOne(LiteralChoiceTerminal("-")), AlternativeCombinator( LiteralChoiceTerminal("~"), - RegexTerminal("/\\S*", "/\\S*") + // A path that starts with a known specifier (%h, %S, ...) is only known once it is expanded; an unknown + // specifier (%Q) makes unit_path_printf() fail, and %% is a literal '%' (so "%%/a" is relative). + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L33-L105 + RegexTerminal("%.*", "%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}]${BatchUnitCoreGrammar.UNIT_TEXT_CHAR}*"), + // An absolute path: with a known specifier further on, only the specifiers are checked; without one, + // also no ".." component after the leading ones path_simplify() drops. + RegexTerminal( + "/.*", + "/(?=(?:[^%]|%%|%(?![A-Za-z0-9%]))*%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}])${BatchUnitCoreGrammar.UNIT_TEXT_CHAR}*\$" + + "|/(?:\\.{0,2}(?:/|$))*(?!(?:.*/)?\\.\\.(?:/|$))(?:[^%]|%%|%(?![A-Za-z0-9%]))*\$" + ) ), EOF() ) diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/ImagePolicyCompletionTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/ImagePolicyCompletionTest.kt index b42b9d29..dcee31fe 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/ImagePolicyCompletionTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/ImagePolicyCompletionTest.kt @@ -27,7 +27,9 @@ class ImagePolicyCompletionTest : AbstractUnitFileTest() { fun testEmptyValueOffersPartitionsAndDefault() { enableNewEngine() setupFileInEditor("file.service", "[Service]\nRootImagePolicy=${COMPLETION_POSITION}") - assertContainsElements(basicCompletionResultStrings, "root", "usr", "swap", "+") + // The default policy is written "=flags" (an empty designator); "+flags" is refused by systemd ("Missing '='"), + // so "+" is no longer offered here (#568). + assertContainsElements(basicCompletionResultStrings, "root", "usr", "swap", "=") } @Test diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Fix568ExecReviewValidatorsTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Fix568ExecReviewValidatorsTest.kt new file mode 100644 index 00000000..bd273473 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Fix568ExecReviewValidatorsTest.kt @@ -0,0 +1,169 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections + +import com.intellij.lang.annotation.HighlightSeverity +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest + +/** + * Follow-up to [Fix568ExecValidatorsTest] from the review of GitHub #568: unknown specifiers, IO limit device paths, + * memory limit percentages and zero, case-insensitive errno names, CPU ranges, quoted namespace paths and duplicate + * image policy rules. Every verdict was confirmed with steve-local/systemd-tools/bin/sd-check at systemd 0f0682b773, + * one value per file. + */ +class Fix568ExecReviewValidatorsTest : AbstractUnitFileTest() { + + /** + * Puts every value on its own line under [section] in [fileName] and checks that exactly the [invalid] ones are + * flagged (at WARNING or above) by the InvalidValue inspection. + */ + private fun assertVerdicts(fileName: String, section: String, key: String, valid: List, invalid: List) { + val values = valid + invalid + val text = "[$section]\n" + values.joinToString("") { "$key=$it\n" } + setupFileInEditor(fileName, text) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting().filter { it.severity >= HighlightSeverity.WARNING } + val document = myFixture.editor.document + val flaggedLines = highlights.map { document.getLineNumber(it.startOffset) }.toSet() + + val wronglyFlagged = valid.filterIndexed { i, _ -> (i + 1) in flaggedLines } + val wronglyAccepted = invalid.filterIndexed { i, _ -> (valid.size + i + 1) !in flaggedLines } + assertTrue( + "$key: flagged although systemd accepts them: $wronglyFlagged; accepted although systemd rejects them: $wronglyAccepted", + wronglyFlagged.isEmpty() && wronglyAccepted.isEmpty() + ) + } + + fun testExecSpecifiers() { + // unit_path_printf() / unit_full_printf() refuse an unknown specifier in the path or any argument; "%%" is a + // literal '%', so "%%/bin/x" is neither a file name nor an absolute path (while "%%" is the file name "%"). + assertVerdicts( + "file.service", "Service", "ExecStart", + valid = listOf( + "/bin/x %%Q", "/bin/x %-", "/bin/x 100%", "/bin/x %", "/bin/x%%y", "/bin/x%", "%%x", "%-", "%h", "/bin/%h", "/bin/x %h", "%n", + "|echo %%Q", "/bin/x ; /bin/y %h", "%%", + ), + invalid = listOf( + "/bin/x %Q", "/bin/x %%%Q", "%Q", "/bin/%Q", "-%Q", "/bin/x \"%Q\"", "/bin/x \\%Q", "/bin/x a%Qb", "@/bin/x %Q", "|echo hi %Q", + "/bin/x ; /bin/y %Q", "%%/bin/x", "/bin/x%%/", "/bin/x%%*", + ), + ) + } + + fun testPathSpecifiers() { + assertVerdicts( + "file.service", "Service", "WorkingDirectory", + valid = listOf("%h", "-%h", "%h/a", "/a/%n/b", "/a%-", "/a%", "/a%%", "~"), + invalid = listOf("%Q", "/a/%Q", "%h%Q", "%%h", "%%", "-%%", "/a/%%/../b"), + ) + assertVerdicts( + "file.service", "Service", "EnvironmentFile", + valid = listOf("%h", "-%h", "/a/%n/b", "/a%%"), + invalid = listOf("%Q", "/a/%Q", "%%h", "-%%"), + ) + assertVerdicts( + "file.service", "Service", "StandardOutput", + valid = listOf("fd:%n", "fd:a%%b", "fd:%", "fd:%-", "file:/a%%b", "file:%h/a", "truncate:/a%-"), + invalid = listOf("fd:%Q", "file:%Q", "file:/a/%Q", "append:%%", "file:/%%/.."), + ) + assertVerdicts( + "file.service", "Service", "StandardInput", + valid = listOf("fd:%n", "fd:a%%b", "file:%h/a", "file:/a/%h"), + invalid = listOf("fd:%Q", "file:%%", "file:%Q", "file:/a/%Q"), + ) + } + + fun testUserAndUnsetEnvironmentSpecifiers() { + assertVerdicts("file.service", "Service", "User", listOf("%u", "%%"), listOf("%Q")) + assertVerdicts("file.service", "Service", "UnsetEnvironment", listOf("X%b", "FOO=%h", "FOO"), listOf("%Q", "X%Q", "FOO=%Q")) + } + + fun testIOLimitDevicePaths() { + // The device word is unquoted (a backslash keeps the next character) and must survive path_simplify() without + // a ".." component; it does not have to be absolute. + assertVerdicts( + "file.service", "Service", "IOReadBandwidthMax", + valid = listOf( + "/dev/sda\\ x 1M", "\"/dev/a\\\"b\" 1M", "'/dev/a b' 1M", "/../a 1M", "/./a 1M", "a 1M", "a/./b 1M", "/ 1M", "// 1M", + "/dev/sd\\a 1M", "/dev/%n 1M", + ), + invalid = listOf( + "/dev/../x 1M", "/a/../b 1M", "a/../b 1M", ".. 1M", ". 1M", "./ 1M", "\"\" 1M", "a/.. 1M", "../a 1M", "%Q 1M", "/dev/%Q 1M", + "\"/dev/sda 1M", + ), + ) + } + + fun testMemoryLimitPercentagesAndZero() { + // parse_permyriad() takes "N‱", "N[.d]‰" or "N[.d[d]]%" (safe_atoi(), so "+50%", " 50%" and "0x10%" are fine) + // up to 100%; 0 is only allowed for MemorySwapMax=, MemoryZSwapMax=, MemoryLow=, MemoryMin= and StartupMemoryLow=. + assertVerdicts( + "file.service", "Service", "MemoryMax", + valid = listOf( + "1‱", "10000‱", "1.5‰", "1000‰", "50%", "50.5%", "50.55%", "100%", "100.0%", " 50%", "+50%", "0x10%", "010%", "0.01%", "1G", + "infinity", + ), + invalid = listOf( + "0", "0%", "0.0%", "0‱", "0‰", "0K", "0B", "10001‱", "1.55‰", "1000.1‰", "50.%", "50.555%", "100.5%", "101%", "1 %", "-0%", + "-1%", ".5%", + ), + ) + assertVerdicts("file.service", "Service", "MemoryHigh", listOf("1%"), listOf("0", "0%")) + assertVerdicts("file.service", "Service", "StartupMemoryMax", emptyList(), listOf("0", "0%")) + assertVerdicts("file.service", "Service", "MemoryLow", listOf("0", "0%", "1G"), listOf("50 %")) + assertVerdicts("file.service", "Service", "MemoryMin", listOf("0", "0%"), emptyList()) + assertVerdicts("file.service", "Service", "MemorySwapMax", listOf("0", "0%"), emptyList()) + assertVerdicts("file.service", "Service", "MemoryZSwapMax", listOf("0"), emptyList()) + assertVerdicts("file.service", "Service", "StartupMemoryLow", listOf("0"), emptyList()) + } + + fun testSystemCallErrorNumberNamesIgnoreCase() { + // errno_from_name() is a case-insensitive gperf lookup, and E2BIG has a digit second. + assertVerdicts( + "file.service", "Service", "SystemCallErrorNumber", + valid = listOf("EPERM", "eperm", "Eperm", "eDOM", "E2BIG", "e2big", "kill"), + invalid = listOf("KILL", "0"), + ) + } + + fun testCPUAffinityRanges() { + // parse_range(): safe_atou() on each side (base 0, '+' allowed, "-0" is 0), lower <= upper, below 8192. + assertVerdicts( + "file.service", "Service", "CPUAffinity", + valid = listOf( + "\"0-3\"", "'0'", "0x0-0x3", "010", "1-0x2", "+1", ",", " , ", "0x1", "8191", "0-8191", "%i", "0--0", "0 , 1", ", 0 ,", + ), + invalid = listOf( + "0-1-2", "-1-3", "1-", "1,-", "4294967295", "4294967296", "0-4294967295", "3-0", "8192", "0-8192", "%Q", "0 %Q", "%%", "1%%", + "1--0", "\"0", "\"0 1\"", + ), + ) + } + + fun testNamespacePathsQuotesSpecifiersAndDotDot() { + assertVerdicts( + "file.service", "Service", "ReadOnlyPaths", + valid = listOf( + "\"/a b\"", "%h", "-%h", "+%h", "-+%h", "\"-/a b\"", "'/a' /b", "-+/a", "/a\\ b", "/../a", "/a/./b", "/a//b", + ), + invalid = listOf("%Q", "/a/%Q", "/a/../b", "/a /b/../c", "%%/a", "+-/a", "a", "-a"), + ) + } + + fun testLimitNiceRawFormAfterWhitespace() { + // rlimit_parse_nice() only looks at the first character of a field, so " +5" after the ':' is the raw limit 5 + // (safe_atou64() skips the whitespace and takes the sign). The first review pass listed "+0: +5" as a strictness + // corner, but systemd refuses that one too (soft 20 > hard 5); the others below were wrongly flagged. + assertVerdicts( + "file.service", "Service", "LimitNICE", + valid = listOf("0: +5", "0: -0", "0: +0x5", "1: +40", "0: +5", "+5: 30", "-5: 30"), + invalid = listOf("+0: +5", "0: +41", "0: + 5", "0: -5", "+5 :10", "5: +0"), + ) + } + + fun testRootImagePolicyDuplicateRules() { + assertVerdicts( + "file.service", "Service", "RootImagePolicy", + valid = listOf("foo=verity:foo=signed", "root=verity:usr=signed", "root=verity:root-verity=signed"), + invalid = listOf("root=verity:root=signed", "root=verity: root =signed", "=verity:=signed", "=verity: =signed"), + ) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Fix568ExecValidatorsTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Fix568ExecValidatorsTest.kt new file mode 100644 index 00000000..5b731935 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Fix568ExecValidatorsTest.kt @@ -0,0 +1,320 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections + +import com.intellij.lang.annotation.HighlightSeverity +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest + +/** + * Exec / sandboxing validators checked against systemd's own parsers (GitHub #568). Every verdict below was + * confirmed with steve-local/systemd-tools/bin/sd-check at systemd 0f0682b773, one value per file. + * + * Weak warnings (e.g. the "use an absolute path" hint on Exec*= lines, or a deprecated address family) do not count + * as flagging a value. + */ +class Fix568ExecValidatorsTest : AbstractUnitFileTest() { + + /** + * Puts every value on its own line under [section] in [fileName] and checks that exactly the [invalid] ones are + * flagged (at WARNING or above) by the InvalidValue inspection. + */ + private fun assertVerdicts(fileName: String, section: String, key: String, valid: List, invalid: List) { + val values = valid + invalid + val text = "[$section]\n" + values.joinToString("") { "$key=$it\n" } + setupFileInEditor(fileName, text) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting().filter { it.severity >= HighlightSeverity.WARNING } + val document = myFixture.editor.document + val flaggedLines = highlights.map { document.getLineNumber(it.startOffset) }.toSet() + + val wronglyFlagged = valid.filterIndexed { i, _ -> (i + 1) in flaggedLines } + val wronglyAccepted = invalid.filterIndexed { i, _ -> (valid.size + i + 1) !in flaggedLines } + assertTrue( + "$key: flagged although systemd accepts them: $wronglyFlagged; accepted although systemd rejects them: $wronglyAccepted", + wronglyFlagged.isEmpty() && wronglyAccepted.isEmpty() + ) + } + + fun testExecStart() { + assertVerdicts( + "file.service", "Service", "ExecStart", + valid = listOf( + "/bin/x arg", + "!!/sbin/x", + "@/bin/x x", + "-/bin/x", + "/bin/x ; /bin/y", + "/bin/x \\; a", + "|", + "%h/bin/x", + "/a/../b", + "foo", + // An unknown escape sequence is only a warning; systemd keeps the backslash. + "bash -x -c '! capsh --print | sed -re \"s/[^ ]+?\\-[epi]+//g\" -e '/IAB/d' | grep cap_mknod'", + "cmd \\' \\\" \\\\ \\a \\b \\f \\n \\r \\s \\t \\v \\x0A \\012 \\u1234 \\U12abcdef", + ), + invalid = listOf( + "@ /some/cmd arg", + "@ -/some/cmd arg", + "@/sbin/docker-compose", + "@docker-compose", + "@-:!!/sbin/docker-compose", + "@service_path@", + "+!/some/cmd arg", + "@!!+/some/cmd arg", + "@+@/some/cmd arg", + "@SBIN_DIR@/haveged -w 1024 -v 1 --Foreground -v 64", + "-@SBIN_DIR@/haveged -c root=/sysroot", + "scripts/run-client-core", + "\${CMAKE_INSTALL_FULL_BINDIR}/gammu-smsd --pid=/run/gammu-smsd.pid --daemon", + "__BINDIR__/purgeLocalCache", + "MISCDIR/mdcheck --restart", + "ExecReload=/bin/sh -c 'case \"\$START_\" in (NO|no) ;; (*) /usr/sbin/gapd ;; esac'", + "/usr/bin/fo*", + "/usr/bin/", + ".", + "/bin/x \"unbal", + "/bin/x ; @/bin/y", + ), + ) + } + + fun testExecStopAndFriendsShareTheExecParser() { + assertVerdicts("file.service", "Service", "ExecStop", listOf("/bin/stop"), listOf("@DIR@/softether/vpnbridge/vpnbridge stop")) + assertVerdicts("file.service", "Service", "ExecStopPost", listOf("-/bin/stop"), listOf("@docker-compose", "@-:!!docker-compose")) + assertVerdicts("file.service", "Service", "ExecStartPre", listOf("/bin/pre"), listOf("TARGET/systemd/incus-agent-setup")) + assertVerdicts("file.service", "Service", "ExecReload", listOf("/bin/kill -HUP \$MAINPID"), listOf("__ASTERISK_SBIN_DIR__/asterisk -rx 'core reload'")) + } + + fun testWorkingDirectory() { + assertVerdicts( + "file.service", "Service", "WorkingDirectory", + valid = listOf("%S/osmocom", "%h", "%E/wireguard", "/some/absolute/path /some/other/path", "/some/absolute/path ~", "-~", "~", "/..", "/a/./b", "//a"), + invalid = listOf("/a/../b", "/a/..", "~/x", "- /a", "relative/path", "--/path"), + ) + } + + fun testEnvironmentFile() { + assertVerdicts( + "file.service", "Service", "EnvironmentFile", + valid = listOf("%E/wireguard/%i.env", "-%E/yubikey-touch-detector/service.conf", "-%h/.config/gpu-screen-recorder.env", "/etc/a b.env"), + invalid = listOf("relative.env", "-relative", "-/a/../b"), + ) + } + + fun testIOSchedulingClass() { + assertVerdicts( + "file.service", "Service", "IOSchedulingClass", + valid = listOf("0", "1", "2", "3", "4", "7", "0x7", "none", "realtime", "best-effort", "idle"), + invalid = listOf("8", "-1", "Idle", "foo"), + ) + } + + fun testCPUSchedulingPolicy() { + // 2147483647 only draws "Unsupported CPU scheduling policy", which does not reject it. + assertVerdicts( + "file.service", "Service", "CPUSchedulingPolicy", + valid = listOf("ext", "other", "rr", "5", "2147483647"), + invalid = listOf("2147483648", "-1", "foo"), + ) + } + + fun testIOLimits() { + assertVerdicts( + "file.service", "Service", "IOReadIOPSMax", + valid = listOf( + "/dev/disk/by-path/pci-0000:00:1f.2-scsi-0:0:0:0 123", + "/dev/disk/by-path/pci-0000:00:1f.2-scsi-0:0:0:0 12 G", + "/dev/disk/by-path/pci-0000:00:1f.2-scsi-0:0:0:0 10M", + "/dev/disk/by-path/pci-0000:00:1f.2-scsi-0:0:0:0 12E", + "/dev/disk/by-path/pci-0000:00:1f.2-scsi-0:0:0:0 1K", + "/dev/sda 1.5K", + "/dev/sda 1G 5M", + "/dev/sda infinity", + "/dev/sda +5", + "foo 10M", + "\"/dev/a b\" 5M", + "%t/x 5", + ), + invalid = listOf("/dev/sda 0", "/dev/sda 10M 20M", "/dev/sda 5k", "/dev/sda"), + ) + assertVerdicts("file.service", "Service", "IOWriteBandwidthMax", listOf("/dev/disk/by-path/pci-0000:00:1f.2-scsi-0:0:0:0 5M", "/dev/sda 1E"), listOf("/dev/sda 0")) + } + + fun testKillSignal() { + assertVerdicts( + "file.service", "Service", "KillSignal", + valid = listOf("SIGRTMIN", "RTMIN", "RTMIN+3", "SIGRTMAX-2", "RTMAX", "RTMIN+30", "RTMAX-30", "RTMIN+0x2", "RTMAX-0", "64", "+5", "0x9", "HUP", "SIGTERM"), + invalid = listOf( + "SIGCLD", "SIGEMT", "SIGINFO", "SIGIOT", "SIGLOST", "SIGPOLL", "0", "65", "-1", "RTMIN+31", "RTMAX-31", "RTMIN+-0", "RTMIN+ 3", + "RTMIN3", "hup", "SIGhup", "SIGSIGHUP", + ), + ) + } + + fun testCapabilitySetsAcceptUnknownNames() { + // capability_set_from_string() skips names it does not know, so systemd never rejects a capability set. + assertVerdicts( + "file.service", "Service", "CapabilityBoundingSet", + valid = listOf( + "CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_SETGID CAP_SETUID CAP_CHOWN CAP_DAC_OVERRIDE LimitNPROC=64", + "~ CAP_SYS_ADMIN CAP_DAC_OVERRIDE CAP_SYS_PTRACE CAP_NET_ADMIN CAP_SYS_BOOT", + "CAP_AUDIT_CONTROLCAP_SETGID", + "CAP_CHOWNER", + "CAP_NET_ADMIN CAP_CHOW", + "CAP_SYS_BOOT ~CAP_SYS_TIME CAP_SYSLOG", + "HELLO", + "~CAP_BLOCK_SUSPEND CAP_SYS_BOOT CAP_SYS_RAWIO CAP_AUDIT_* CAP_SYS_PTRACE", + "~CAP_SYS_BPF CAP_NET_ADMIN", + "~", + "cap_chown", + "63", + ), + invalid = emptyList(), + ) + assertVerdicts("file.service", "Service", "AmbientCapabilities", listOf("~ CAP_NET_ADMIN CAP_NET_RAW CAP_WAKE_ALARM"), emptyList()) + } + + fun testStandardOutputAndError() { + assertVerdicts( + "file.service", "Service", "StandardOutput", + valid = listOf( + "syslog", "syslog+console", "fd", "fd:", "fd:a b", "file", "append", "broker", "broker-log", "file:%t/x", "file:/a/./b", + "append:/var/log/x", "truncate:/a", + ), + invalid = listOf( + "append: /some/relative/path", "append:some/relative/path", "file: /some/relative/path", "file:some/relative/path", "file:", + "fd:a:b", "file:/a/../b", "foo", + ), + ) + assertVerdicts("file.service", "Service", "StandardError", listOf("syslog", "syslog+console"), listOf("foo")) + } + + fun testStandardInput() { + assertVerdicts( + "file.service", "Service", "StandardInput", + valid = listOf("fd", "file", "broker", "data", "tty-force", "fd:", "file:/some/absolute/path socket"), + invalid = listOf("file:", "file:rel", "fd:a:b", "foo"), + ) + } + + fun testNiceAndOOMScoreAdjust() { + assertVerdicts("file.service", "Service", "Nice", listOf("+12", "+1", "+19", "-20", "0x5"), listOf("20", "-21", "+20", "abc")) + assertVerdicts("file.service", "Service", "OOMScoreAdjust", listOf("+1000", "+321", "-1000"), listOf("1001", "+1001")) + } + + fun testRestrictAddressFamilies() { + assertVerdicts( + "file.service", "Service", "RestrictAddressFamilies", + valid = listOf( + "~ AF_INET AF_VSOCK", "~ AF_XDP AF_LOCAL AF_SECURITY", "AF_DECNET", "AF_INET, AF_INET6", "AF_INET,AF_INET6", ",AF_INET,", "inet", + "INET", "af_inet", "~", "none", "\"AF_INET\"", + ), + invalid = listOf("none AF_INET", "AF_UNSPEC", "AF_BOGUS", "AF_INET \"AF_UNIX"), + ) + } + + fun testRlimits() { + assertVerdicts( + "file.service", "Service", "LimitCPU", + valid = listOf("10s 15ms : infinity", "15s 10us : 20s 5ms", "infinity : infinity", "1.9s:1.5s", "6m"), + invalid = listOf("2s:1s", "infinity:5", "5x"), + ) + assertVerdicts( + "file.service", "Service", "LimitNICE", + valid = listOf("+19", "-20", "40", "+0", "-0", "0", "+19:+0", "++5"), + invalid = listOf("+20", "-10:+10", "-21", "41", "+0:+19"), + ) + assertVerdicts( + "file.service", "Service", "LimitNOFILE", + valid = listOf("1: 2", "0x10", "+5", "infinity:infinity", "18446744073709551614"), + invalid = listOf("1: infinity", "1 :2", "5:3", "18446744073709551615"), + ) + assertVerdicts("file.service", "Service", "LimitRTTIME", listOf("12us : 15", "20000000000000"), listOf("15 : 12us")) + assertVerdicts( + "file.service", "Service", "LimitFSIZE", + valid = listOf("4294967296", "1 K: 2 G", "12 G: 24 G", "1G 512M", "18446744073709551614", "1K:infinity"), + invalid = listOf("18446744073709551615", "16E", "2K:1K", "infinity:1K", "1:", "1:2:3", "12 G : 24 G", "12 G :24 G"), + ) + } + + fun testRootImagePolicy() { + // Unknown designators and flags are skipped in graceful mode, so they are fine. + assertVerdicts( + "file.service", "Service", "RootImagePolicy", + valid = listOf( + "home=encrypted+absent+", "home=encrypted+used", "home=encrypted:root=encrypted+used", "opt=unprotected", "root=unsigned", + "=verity", "root=verity:=signed", "-", "*", "~", "root=encryptedwithintegrity+ext4", + ), + invalid = listOf("+verity", "root", ":", "root=verity:"), + ) + } + + fun testMemoryLimits() { + assertVerdicts("file.service", "Service", "MemoryMin", listOf("12 E", "12P", "1G 512M", "1.5K", "10%"), listOf("16E", "5k", "abc")) + assertVerdicts("file.service", "Service", "MemorySwapMax", listOf("12 E", "12P"), emptyList()) + } + + fun testSecureBitsAcceptUnknownWords() { + // secure_bits_from_string() skips words it does not know. + assertVerdicts("file.service", "Service", "SecureBits", listOf("invalid", "invalid_value", "keep-caps invalid_value", "noroot", "exec-restrict-file"), emptyList()) + } + + fun testSystemCallErrorNumber() { + assertVerdicts("file.service", "Service", "SystemCallErrorNumber", listOf("1", "4095", "0x10", "EPERM", "kill"), listOf("0", "4096", "-1")) + } + + fun testStateDirectoryQuota() { + assertVerdicts("file.service", "Service", "StateDirectoryQuota", listOf("1P", "4294967296", "5.52%", "off", "100%", "10G"), listOf("101%", "5.123%", "abc")) + } + + fun testUser() { + assertVerdicts( + "file.service", "Service", "User", + valid = listOf("65534", "0", "4294967294", "a b", "-a", "1a", "+5", "root", "%i"), + invalid = listOf("-1000", "-", ".", "..", "65535", "0123", "4294967295"), + ) + } + + fun testUnsetEnvironment() { + assertVerdicts("file.service", "Service", "UnsetEnvironment", listOf("QUUX=waldo VAR3=somethingelse VAR4 X%b=%U"), listOf("1ABC")) + } + + fun testPrivateUsersInAService() { + assertVerdicts("file.service", "Service", "PrivateUsers", listOf("full", "self", "managed", "identity", "yes"), emptyList()) + } + + fun testRestrictNamespaces() { + assertVerdicts("file.service", "Service", "RestrictNamespaces", listOf("~ cgroup pid uts", "~", "yes", "net"), listOf("~ foo", "foo")) + } + + fun testSELinuxContextAndSmackProcessLabel() { + // A '%' that is not followed by an alphanumeric character is kept as it is. "%e" is an unknown specifier, which + // systemd refuses when it is built with SELinux support. + assertVerdicts("file.service", "Service", "SELinuxContext", listOf("ends with %", "a%-b"), listOf("bad %e specifier")) + assertVerdicts("file.service", "Service", "SmackProcessLabel", listOf("ends with %", "a%-b"), listOf("%Z")) + } + + fun testManagedOOMMemoryPressureDurationSec() { + assertVerdicts("file.service", "Service", "ManagedOOMMemoryPressureDurationSec", listOf("1s", "2min"), listOf("infinity", "500ms")) + } + + fun testCPUAffinity() { + assertVerdicts("file.service", "Service", "CPUAffinity", listOf("%I", "0 1, 2 , 3, 4-8", "numa"), listOf("a")) + } + + fun testPacketLimitsTakeTheFullUnsignedRange() { + assertVerdicts("file.network", "NetworkEmulator", "PacketLimit", listOf("4294967295", "0"), listOf("4294967296")) + assertVerdicts("file.network", "HeavyHitterFilter", "PacketLimit", listOf("4294967295", "0"), listOf("4294967296")) + } + + fun testReadWritePathsAcrossALineContinuation() { + // language="unit file (systemd)" + val file = """ + [Service] + ReadWritePaths=+/some/path-+/ -/some/other/path \ + -+/one/more/path + """.trimIndent() + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + assertEmpty(myFixture.doHighlighting().filter { it.severity >= HighlightSeverity.WARNING }) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/GrammarParseEngineInspectionTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/GrammarParseEngineInspectionTest.kt index 09fbf6a6..a42556c8 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/GrammarParseEngineInspectionTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/GrammarParseEngineInspectionTest.kt @@ -50,14 +50,16 @@ class GrammarParseEngineInspectionTest : AbstractUnitFileTest() { fun testInvalidAddressFamiliesUnderNewEngine() { enableNewEngine() // One highlight each, exercising both ParseOutcome failure kinds now that AF names are - // enumerated: AF_BOGUS is well-formed but unknown (SemanticError), while the comma and the - // bare non-AF token are ill-formed against the grammar shape (SyntaxError). + // enumerated: AF_BOGUS is well-formed but unknown (SemanticError), while the semicolon and the + // unbalanced quote are ill-formed against the grammar shape (SyntaxError). (This used to use + // "AF_INET, AF_INET6" and "inet", which systemd accepts: commas separate too, and af_from_name() + // ignores case and adds a missing AF_ prefix (#568).) // language="unit file (systemd)" val file = """ [Service] RestrictAddressFamilies=AF_BOGUS - RestrictAddressFamilies=AF_INET, AF_INET6 - RestrictAddressFamilies=inet + RestrictAddressFamilies=AF_INET; AF_INET6 + RestrictAddressFamilies=AF_INET "AF_UNIX """.trimIndent() setupFileInEditor("file.service", file) diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueForRLimitTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueForRLimitTest.kt index 2d02d438..5e52503d 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueForRLimitTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueForRLimitTest.kt @@ -57,8 +57,8 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("LimitCPU's value is correctly formatted but seems invalid.", info!!.description) - TestCase.assertEquals("-11", info.text) + // The time and size fields no longer read a leading \'-\' as a number, so this is now a format error (#568). + assertStringContains("LimitCPU\'s value does not match the expected format", info!!.description) } fun testNoWarningWhenCPULimitSpecifiedInNSpawnFile() { @@ -95,8 +95,8 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("LimitCPU's value is correctly formatted but seems invalid.", info!!.description) - TestCase.assertEquals("-11", info.text) + // The time and size fields no longer read a leading \'-\' as a number, so this is now a format error (#568). + assertStringContains("LimitCPU\'s value does not match the expected format", info!!.description) } fun testNoWarningWhenCPULimitSpecifiedInComplexFormat() { @@ -166,8 +166,8 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("LimitCPU's value is correctly formatted but seems invalid.", info!!.description) - TestCase.assertEquals(" n", info.text) + // The time and size fields no longer read a leading \'-\' as a number, so this is now a format error (#568). + assertStringContains("LimitCPU\'s value does not match the expected format", info!!.description) } @@ -187,8 +187,8 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("LimitCPU's value is correctly formatted but seems invalid.", info!!.description) - TestCase.assertEquals("-11", info.text) + // The time and size fields no longer read a leading \'-\' as a number, so this is now a format error (#568). + assertStringContains("LimitCPU\'s value does not match the expected format", info!!.description) } fun testNoWarningWhenFSIZELimitSpecified() { @@ -258,8 +258,8 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("LimitFSIZE's value is correctly formatted but seems invalid.", info!!.description) - TestCase.assertEquals("-11", info.text) + // The time and size fields no longer read a leading \'-\' as a number, so this is now a format error (#568). + assertStringContains("LimitFSIZE\'s value does not match the expected format", info!!.description) } fun testNoWarningWhenDATALimitSpecified() { @@ -295,8 +295,8 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("LimitDATA's value is correctly formatted but seems invalid.", info!!.description) - TestCase.assertEquals("-11", info.text) + // The time and size fields no longer read a leading \'-\' as a number, so this is now a format error (#568). + assertStringContains("LimitDATA\'s value does not match the expected format", info!!.description) } fun testNoWarningWhenSTACKLimitSpecified() { @@ -332,8 +332,8 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("LimitSTACK's value is correctly formatted but seems invalid.", info!!.description) - TestCase.assertEquals("-11", info.text) + // The time and size fields no longer read a leading \'-\' as a number, so this is now a format error (#568). + assertStringContains("LimitSTACK\'s value does not match the expected format", info!!.description) } fun testNoWarningWhenCORELimitSpecified() { @@ -369,8 +369,8 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("LimitCORE's value is correctly formatted but seems invalid.", info!!.description) - TestCase.assertEquals("-11", info.text) + // The time and size fields no longer read a leading \'-\' as a number, so this is now a format error (#568). + assertStringContains("LimitCORE\'s value does not match the expected format", info!!.description) } fun testNoWarningWhenRSSLIMITSpecified() { @@ -406,8 +406,8 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("LimitRSS's value is correctly formatted but seems invalid.", info!!.description) - TestCase.assertEquals("-11", info.text) + // The time and size fields no longer read a leading \'-\' as a number, so this is now a format error (#568). + assertStringContains("LimitRSS\'s value does not match the expected format", info!!.description) } fun testNoWarningWhenNOFILELimitSpecified() { @@ -427,7 +427,10 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { assertSize(0, highlights) } - fun testNoWarningWhenNOFILELimitWithWhitespaceSoftAndHardAndInfinityIsSpecified() { + fun testWarningWhenNOFILELimitWithWhitespaceSoftAndHardAndInfinityIsSpecified() { + // rlimit_parse_u64() compares the field with "infinity" exactly, and the field after ':' is not trimmed, so + // " infinity" is refused ("Failed to parse LimitNOFILE=1: infinity"). This test used to assert the opposite (#568). + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/rlimit-util.c#L79-L102 // Fixture Setup // language="unit file (systemd)" val file = """ @@ -440,6 +443,24 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() + // Verification + assertSize(1, highlights) + } + + fun testNoWarningWhenNOFILELimitWithWhitespaceSoftAndHardIsSpecified() { + // safe_atou64() skips leading whitespace, so " 2" is fine. + // Fixture Setup + // language="unit file (systemd)" + val file = """ + [Service] + LimitNOFILE=1: 2 + """.trimIndent() + + // Execute SUT + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + // Verification assertSize(0, highlights) } @@ -551,8 +572,8 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("LimitMEMLOCK's value is correctly formatted but seems invalid.", info!!.description) - TestCase.assertEquals("-11", info.text) + // The time and size fields no longer read a leading \'-\' as a number, so this is now a format error (#568). + assertStringContains("LimitMEMLOCK\'s value does not match the expected format", info!!.description) } fun testNoWarningWhenLOCKSLimitSpecified() { @@ -663,8 +684,8 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("LimitMSGQUEUE's value is correctly formatted but seems invalid.", info!!.description) - TestCase.assertEquals("-11", info.text) + // The time and size fields no longer read a leading \'-\' as a number, so this is now a format error (#568). + assertStringContains("LimitMSGQUEUE\'s value does not match the expected format", info!!.description) } fun testNoWarningWhenNICELimitSpecified() { @@ -774,7 +795,7 @@ class InvalidValueForRLimitTest : AbstractUnitFileTest() { // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("LimitRTTIME's value is correctly formatted but seems invalid.", info!!.description) - TestCase.assertEquals("-11", info.text) + // The time and size fields no longer read a leading \'-\' as a number, so this is now a format error (#568). + assertStringContains("LimitRTTIME\'s value does not match the expected format", info!!.description) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForExecOptionsTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForExecOptionsTest.kt index 8aac1f1e..bbca9bf9 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForExecOptionsTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForExecOptionsTest.kt @@ -67,10 +67,12 @@ class InvalidValueInspectionForExecOptionsTest : AbstractUnitFileTest() { } fun testNoWarningWithAbsolutePathWithAtPrefix() { + // With '@' the first argument after the path is argv[0]; without one systemd refuses the line ("Empty executable + // name or zeroeth argument"), so the old value "@/sbin/docker-compose" is now an error (#568). // Fixture Setup val file = """ [Service] - ExecStart=@/sbin/docker-compose + ExecStart=@/sbin/docker-compose docker-compose """.trimIndent() @@ -178,7 +180,7 @@ class InvalidValueInspectionForExecOptionsTest : AbstractUnitFileTest() { // Fixture Setup val file = """ [Service] - ExecStart=@-:!!/sbin/docker-compose + ExecStart=@-:!!/sbin/docker-compose docker-compose """.trimIndent() @@ -196,7 +198,7 @@ class InvalidValueInspectionForExecOptionsTest : AbstractUnitFileTest() { // Fixture Setup val file = """ [Service] - ExecStopPost= @docker-compose + ExecStopPost= @docker-compose docker-compose """.trimIndent() @@ -322,7 +324,7 @@ class InvalidValueInspectionForExecOptionsTest : AbstractUnitFileTest() { // Fixture Setup val file = """ [Service] - ExecStopPost= @-:!!docker-compose + ExecStopPost= @-:!!docker-compose docker-compose """.trimIndent() @@ -488,4 +490,43 @@ class InvalidValueInspectionForExecOptionsTest : AbstractUnitFileTest() { // Verification assertSize(15, highlights) } + + fun testErrorWhenAtPrefixHasNoArgv0() { + // systemd: "Empty executable name or zeroeth argument: @/sbin/docker-compose" (#568) + // https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L1072-L1077 + // Fixture Setup + val file = """ + [Service] + ExecStart=@/sbin/docker-compose + + """.trimIndent() + + // Execute SUT + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + // Verification + assertSize(1, highlights) + assertStringContains("Empty executable name or zeroeth argument", highlights[0]!!.description) + } + + fun testErrorWhenPathIsRelativeWithSlash() { + // systemd: "Neither a valid executable name nor an absolute path: BINDIR/mdadm" (#568) + // Fixture Setup + val file = """ + [Service] + ExecStart=BINDIR/mdadm --monitor --scan + + """.trimIndent() + + // Execute SUT + setupFileInEditor("file.service", file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + + // Verification + assertSize(1, highlights) + assertStringContains("Neither a valid executable name nor an absolute path: BINDIR/mdadm", highlights[0]!!.description) + } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForExecOutputOptionsTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForExecOutputOptionsTest.kt index 60295b4d..4d2225d1 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForExecOutputOptionsTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForExecOutputOptionsTest.kt @@ -63,26 +63,10 @@ class InvalidValueInspectionForExecOutputOptionsTest : AbstractUnitFileTest() { TestCase.assertEquals("foo", info.text) } - fun testWeakWarningWhenMissingSecondArgumentForFd() { - // Fixture Setup - // language="unit file (systemd)" - val file = """ - [Service] - StandardOutput=fd: - - """.trimIndent() - - - // Execute SUT - setupFileInEditor("file.service", file) - enableInspection(InvalidValueInspection::class.java) - val highlights = myFixture.doHighlighting() - - // Verification - assertSize(1, highlights) - val info = highlights[0] - assertStringContains("name must be specified after the colon", info!!.description) - TestCase.assertEquals("fd:", info.text) + fun testNoWarningWhenSecondArgumentForFdIsEmpty() { + // config_parse_exec_output() accepts an empty fd: name (it then uses the default name); the test used to assert + // the opposite. https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L1354-L1369 + runValidValueTest("fd:") } fun testWeakWarningWhenMissingSecondArgumentForFile() { diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForImagePolicy.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForImagePolicy.kt index 07ad152d..3f926d3e 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForImagePolicy.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForImagePolicy.kt @@ -1,6 +1,5 @@ package net.sjrx.intellij.plugins.systemdunitfiles.inspections -import junit.framework.TestCase import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest class InvalidValueInspectionForImagePolicy : AbstractUnitFileTest() { @@ -24,113 +23,51 @@ class InvalidValueInspectionForImagePolicy : AbstractUnitFileTest() { } - fun testWeakWarningWhenInvalidPartitionTypeIsSpecified() { - // Fixture Setup - // language="unit file (systemd)" - val file = """ - [Service] - RootImagePolicy=opt=unprotected - """.trimIndent() - + // image_policy_from_string() runs in graceful mode for unit files, so an unknown partition designator or policy + // flag (and the empty flag after a trailing '+') is skipped rather than refused. These values used to be asserted + // invalid; systemd's parser accepts every one of them (#568). + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/image-policy.c#L341-L465 - // Execute SUT - setupFileInEditor("file.service", file) - enableInspection(InvalidValueInspection::class.java) - val highlights = myFixture.doHighlighting() - - // Verification - assertSize(1, highlights) - val info = highlights[0] - assertStringContains("RootImagePolicy's value is correctly formatted but seems invalid", info!!.description) - TestCase.assertEquals("opt", info.text) + fun testNoWarningWhenUnknownPartitionTypeIsSpecified() { + assertNoHighlights("opt=unprotected") } - fun testWeakWarningWhenInvalidPolicyFlagIsSpecified() { - // Fixture Setup - // language="unit file (systemd)" - val file = """ - [Service] - RootImagePolicy=root=unsigned - """.trimIndent() - - - // Execute SUT - setupFileInEditor("file.service", file) - enableInspection(InvalidValueInspection::class.java) - val highlights = myFixture.doHighlighting() - - // Verification - assertSize(1, highlights) - val info = highlights[0] - assertStringContains("RootImagePolicy's value is correctly formatted but seems invalid", info!!.description) - TestCase.assertEquals("unsigned", info.text) + fun testNoWarningWhenUnknownPolicyFlagIsSpecified() { + assertNoHighlights("root=unsigned") } - fun testWeakWarningWhenInvalidPolicyFlagIsSpecifiedInSecondaryPolicy() { - // Fixture Setup - // language="unit file (systemd)" - val file = """ - [Service] - RootImagePolicy=home=encrypted+used - """.trimIndent() - - - // Execute SUT - setupFileInEditor("file.service", file) - enableInspection(InvalidValueInspection::class.java) - val highlights = myFixture.doHighlighting() - + fun testNoWarningWhenUnknownPolicyFlagIsSpecifiedInSecondaryPolicy() { + assertNoHighlights("home=encrypted+used") + } - // Verification - assertSize(1, highlights) - val info = highlights[0] + fun testNoWarningWhenPolicyFlagsEndWithPlus() { + assertNoHighlights("home=encrypted+absent+") + } - assertStringContains("RootImagePolicy's value is correctly formatted but seems invalid", info!!.description) - TestCase.assertEquals("used", info.text) - assertContainsQuickfix(info, "Replace 'used' with 'absent'") + fun testNoWarningWhenUnknownPolicyFlagIsSetInSecondPolicy() { + assertNoHighlights("home=encrypted:root=encrypted+used") } - fun testWeakWarningWhenIncompletePolicyFlagIsSpecifiedInSecondaryPolicy() { - // Fixture Setup + fun testWarningWhenEqualsIsMissing() { + // "Missing '=' in policy expression" is a hard error even in graceful mode. // language="unit file (systemd)" val file = """ [Service] - RootImagePolicy=home=encrypted+absent+ + RootImagePolicy=root """.trimIndent() - - // Execute SUT setupFileInEditor("file.service", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - // Verification assertSize(1, highlights) - val info = highlights[0] - assertStringContains("RootImagePolicy's value does not match the expected format. Possible reasons include unrecognized characters or premature end of input", info!!.description) - TestCase.assertEquals("home=encrypted+absent+", info.text) + assertStringContains("RootImagePolicy's value does not match the expected format", highlights[0]!!.description) } - fun testWeakWarningWhenInvalidPolicySetInSecondPolicy() { - // Fixture Setup - // language="unit file (systemd)" - val file = """ - [Service] - RootImagePolicy=home=encrypted:root=encrypted+used - """.trimIndent() - - - // Execute SUT - setupFileInEditor("file.service", file) + private fun assertNoHighlights(value: String) { + setupFileInEditor("file.service", "[Service]\nRootImagePolicy=$value\n") enableInspection(InvalidValueInspection::class.java) - val highlights = myFixture.doHighlighting() - - // Verification - assertSize(1, highlights) - val info = highlights[0] - assertStringContains("RootImagePolicy's value is correctly formatted but seems invalid", info!!.description) - TestCase.assertEquals("used", info.text) - assertContainsQuickfix(info, "Replace 'used' with 'absent'") + assertSize(0, myFixture.doHighlighting()) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForMemoryLimitOptionsTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForMemoryLimitOptionsTest.kt index bbf4596e..dfdf0c38 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForMemoryLimitOptionsTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForMemoryLimitOptionsTest.kt @@ -30,13 +30,17 @@ class InvalidValueInspectionForMemoryLimitOptionsTest : AbstractUnitFileTest() { fun testNoWarningWithVariousPercentageValues() { // Fixture Setup // language="unit file (systemd)" + // MemoryHigh=0% (a limit of 0, refused for MemoryHigh=) and MemoryMin=50 % (no whitespace allowed before the + // '%') used to be listed here, but systemd rejects both; MemoryLow=0% and MemorySwapMax=0% are fine. val file = """ [Service] - MemoryHigh=0% + MemoryHigh=0.5% MemoryLow=1% - MemoryMin=50 % + MemoryMin=50% MemoryMax=50.0% MemorySwapMax=100% + MemoryLow=0% + StartupMemorySwapMax=0% """.trimIndent() diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForSimpleGrammarOptionValue.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForSimpleGrammarOptionValue.kt index 5ede3dc0..b31f507f 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForSimpleGrammarOptionValue.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForSimpleGrammarOptionValue.kt @@ -109,7 +109,10 @@ class InvalidValueInspectionForSimpleGrammarOptionValue : AbstractUnitFileTest() assertSize(0, highlights) } - fun testWarningsWhenInValidCapabilitiesAreSet() { + fun testNoWarningsWhenUnknownCapabilitiesAreSet() { + // capability_set_from_string() only logs unknown names at debug level and skips them, so systemd accepts all of + // these. The test used to assert four warnings (#568). + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/capability-list.c#L160-L186 // Fixture Setup // language="unit file (systemd)" val file=""" @@ -126,7 +129,7 @@ class InvalidValueInspectionForSimpleGrammarOptionValue : AbstractUnitFileTest() val highlights = myFixture.doHighlighting() // Verification - assertSize(4, highlights) + assertSize(0, highlights) } @@ -161,13 +164,11 @@ class InvalidValueInspectionForSimpleGrammarOptionValue : AbstractUnitFileTest() // language="unit file (systemd)" val file=""" [Service] - StateDirectoryQuota=1P StateDirectoryQuota=-1 StateDirectoryQuota=on StateDirectoryQuota=allo - StateDirectoryQuota=4294967296 StateDirectoryQuota=500% - StateDirectoryQuota=5.52% + StateDirectoryQuota=5.123% """.trimIndent() // Execute SUT @@ -176,7 +177,9 @@ class InvalidValueInspectionForSimpleGrammarOptionValue : AbstractUnitFileTest() val highlights = myFixture.doHighlighting() // Verification - assertSize(7, highlights) + // 1P, 4294967296 and 5.52% used to be listed here, but config_parse_exec_quota() takes any parse_size() value and + // parse_permyriad() allows two decimals (#568). + assertSize(5, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseAddressFamiliesOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseAddressFamiliesOptionValueTest.kt index 4bc8d5fb..0f6a3c34 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseAddressFamiliesOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseAddressFamiliesOptionValueTest.kt @@ -73,14 +73,15 @@ class ConfigParseAddressFamiliesOptionValueTest : AbstractUnitFileTest() { // any AF_-prefixed token, so these passed validation incorrectly. With the enumerated // family set they must be flagged (syntactically well-formed, semantically invalid). // Raw values (no markup) so the guard does not depend on markup stripping; each - // invalid property contributes exactly one highlight. + // invalid property contributes exactly one highlight. (AF_DECNET used to be the fourth value, but af_from_name() + // ignores case, so systemd accepts it (#568); AF_UNSPEC is not in the table at all.) // language="unit file (systemd)" val file = """ [Service] RestrictAddressFamilies=AF_BOGUS RestrictAddressFamilies=AF_INETZ RestrictAddressFamilies=AF_INET AF_MADEUP - RestrictAddressFamilies=AF_DECNET + RestrictAddressFamilies=AF_UNSPEC """.trimIndent() setupFileInEditor("file.service", file) diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExecSecureBitsOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExecSecureBitsOptionValueTest.kt index fecf3ff6..097d7ea2 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExecSecureBitsOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExecSecureBitsOptionValueTest.kt @@ -32,7 +32,9 @@ class ConfigParseExecSecureBitsOptionValueTest : AbstractUnitFileTest() { } @Test - fun testInvalidValues() { + fun testUnknownWordsAreAccepted() { + // secure_bits_from_string() skips words it does not know, so systemd accepts these; the test used to assert + // they were invalid (#568). // Fixture Setup // language="unit file (systemd)" val file = """ @@ -47,6 +49,6 @@ class ConfigParseExecSecureBitsOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(2, highlights) + assertSize(0, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExecSelinuxContextOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExecSelinuxContextOptionValueTest.kt index 9e05ab1a..0f54513f 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExecSelinuxContextOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExecSelinuxContextOptionValueTest.kt @@ -25,18 +25,18 @@ class ConfigParseExecSelinuxContextOptionValueTest : AbstractUnitFileTest() { @Test fun testInvalidValues() { - // Unknown specifier (%e) and a trailing bare '%'. + // Unknown specifier (%e). A trailing bare '%' used to be listed here too, but specifier_printf() keeps it as it + // is, so it is valid (#568). // language=unit file (systemd) val file = """ [Service] SELinuxContext=bad %e specifier - SELinuxContext=ends with % """.trimIndent() setupFileInEditor("file.service", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(2, highlights) + assertSize(1, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExecSmackProcessLabelOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExecSmackProcessLabelOptionValueTest.kt index e519f3ba..56528fcb 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExecSmackProcessLabelOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseExecSmackProcessLabelOptionValueTest.kt @@ -25,18 +25,18 @@ class ConfigParseExecSmackProcessLabelOptionValueTest : AbstractUnitFileTest() { @Test fun testInvalidValues() { - // Unknown specifier (%e) and a trailing bare '%'. + // Unknown specifier (%e). A trailing bare '%' used to be listed here too, but specifier_printf() keeps it as it + // is, so it is valid (#568). // language=unit file (systemd) val file = """ [Service] SmackProcessLabel=bad %e label - SmackProcessLabel=ends with % """.trimIndent() setupFileInEditor("file.service", file) enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(2, highlights) + assertSize(1, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseHhfPacketLimitOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseHhfPacketLimitOptionValueTest.kt index ab08efb1..f737d4f3 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseHhfPacketLimitOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseHhfPacketLimitOptionValueTest.kt @@ -34,7 +34,6 @@ class ConfigParseHhfPacketLimitOptionValueTest : AbstractUnitFileTest() { val file = """ [HeavyHitterFilter] PacketLimit=-1 - PacketLimit=4294967295 PacketLimit=5000000000 PacketLimit=abc PacketLimit=12.34 @@ -46,7 +45,8 @@ class ConfigParseHhfPacketLimitOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(5, highlights) + // 4294967295 used to be listed here, but safe_atou32() takes the full unsigned range (#568). + assertSize(4, highlights) } @Test diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseManagedOomMemPressureDurationSecOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseManagedOomMemPressureDurationSecOptionValueTest.kt index 156cf16c..70f14df0 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseManagedOomMemPressureDurationSecOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseManagedOomMemPressureDurationSecOptionValueTest.kt @@ -11,7 +11,6 @@ class ConfigParseManagedOomMemPressureDurationSecOptionValueTest : AbstractUnitF // language="unit file (systemd)" val file = """ [Service] - ManagedOOMMemoryPressureDurationSec=infinity ManagedOOMMemoryPressureDurationSec=10 ManagedOOMMemoryPressureDurationSec=30s ManagedOOMMemoryPressureDurationSec=2min diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseNetemPacketLimitOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseNetemPacketLimitOptionValueTest.kt index 9de67e8b..c99c963a 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseNetemPacketLimitOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseNetemPacketLimitOptionValueTest.kt @@ -35,7 +35,6 @@ class ConfigParseNetemPacketLimitOptionValueTest : AbstractUnitFileTest() { val file = """ [NetworkEmulator] PacketLimit=-1 - PacketLimit=4294967295 PacketLimit=4294967296 PacketLimit=abc PacketLimit=1.5 @@ -47,6 +46,7 @@ class ConfigParseNetemPacketLimitOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(5, highlights) + // 4294967295 used to be listed here, but safe_atou() takes the full unsigned range (#568). + assertSize(4, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSyscallErrnoOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSyscallErrnoOptionValueTest.kt index dd93c81c..20ddb555 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSyscallErrnoOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseSyscallErrnoOptionValueTest.kt @@ -18,7 +18,6 @@ class ConfigParseSyscallErrnoOptionValueTest : AbstractUnitFileTest() { SystemCallErrorNumber=EAGAIN SystemCallErrorNumber=1 SystemCallErrorNumber=13 - SystemCallErrorNumber=0 """.trimIndent() setupFileInEditor("file.service", file) diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseWorkingDirectoryOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseWorkingDirectoryOptionValueTest.kt index 8e39b9b5..68c025e4 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseWorkingDirectoryOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseWorkingDirectoryOptionValueTest.kt @@ -18,6 +18,8 @@ class ConfigParseWorkingDirectoryOptionValueTest : AbstractUnitFileTest() { WorkingDirectory=-/var/cache/myapp WorkingDirectory=-~ WorkingDirectory=/var/lib/%n + WorkingDirectory=/path with space + WorkingDirectory=%S/osmocom """.trimIndent() setupFileInEditor("file.service", file) @@ -34,7 +36,6 @@ class ConfigParseWorkingDirectoryOptionValueTest : AbstractUnitFileTest() { [Service] WorkingDirectory=relative/path WorkingDirectory=somepath - WorkingDirectory=/path with space WorkingDirectory=--/path """.trimIndent() @@ -42,6 +43,6 @@ class ConfigParseWorkingDirectoryOptionValueTest : AbstractUnitFileTest() { enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - assertSize(4, highlights) + assertSize(3, highlights) } } From a313a11ffc876f45ebd045a05bb57931bb13a218 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sat, 24 Oct 2026 15:50:52 -0700 Subject: [PATCH 2/4] fix: align unit, service and cgroup validators with systemd's parsers (#568) Fixes dependency keys (OnFailure=, Wants=, After=, ...) with specifiers, FailureAction=/SuccessAction=/JobTimeoutAction= values such as halt-force, trigger units, timeouts and cgroup settings, so they match what systemd accepts. Part of #568. Verdicts were checked against systemd's own parsers built at the pinned commit 0f0682b773 (condition values against src/shared/condition.c, since systemd only evaluates them at unit start). Values systemd accepts at parse time but rejects only later are accepted (see #565). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CgroupSocketBindOptionValue.kt | 149 ++++++++-- .../optionvalues/EnumOptionValues.kt | 69 ++++- .../optionvalues/ModeStringOptionValue.kt | 13 +- .../optionvalues/PathOptionValue.kt | 40 +-- .../semanticdata/optionvalues/TimeHelper.kt | 68 +++-- .../optionvalues/UnitDependencyOptionValue.kt | 149 ++++++++-- .../ai/ConfigParseCoalesceSecOptionValue.kt | 62 ++++- ...nfigParseColonSeparatedPathsOptionValue.kt | 31 ++- ...onfigParseDhcpSocketPriorityOptionValue.kt | 12 +- ...onfigParseDisableControllersOptionValue.kt | 56 ++-- .../ai/ConfigParseTriggerUnitOptionValue.kt | 29 +- .../ai/ConfigParseUnitMountsForOptionValue.kt | 29 +- .../ai/ConfigParseUnitSliceOptionValue.kt | 28 +- .../ConfigParseUnitStringPrintfOptionValue.kt | 7 +- .../grammar/BatchUnitCoreCombinators.kt | 8 +- .../inspections/Fix568UnitInspectionTest.kt | 256 ++++++++++++++++++ ...validValueInspectionForCGroupSocketBind.kt | 22 +- .../inspections/InvalidValueInspectionTest.kt | 6 +- .../ConfigParseCoalesceSecOptionValueTest.kt | 8 +- ...gParseDhcpSocketPriorityOptionValueTest.kt | 12 +- ...gParseDisableControllersOptionValueTest.kt | 7 +- ...figParseUnitStringPrintfOptionValueTest.kt | 7 +- .../ai/Fix568UnitGrammarVerdictsTest.kt | 148 ++++++++++ 23 files changed, 1029 insertions(+), 187 deletions(-) create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Fix568UnitInspectionTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/Fix568UnitGrammarVerdictsTest.kt diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/CgroupSocketBindOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/CgroupSocketBindOptionValue.kt index 82da0da1..78d0ad37 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/CgroupSocketBindOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/CgroupSocketBindOptionValue.kt @@ -1,39 +1,137 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues -import kotlinx.html.ADDRESS +import com.intellij.codeInspection.ProblemHighlightType +import com.intellij.codeInspection.ProblemsHolder +import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFilePropertyType import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* +/** + * SocketBindAllow= / SocketBindDeny=. + * + * [config_parse_cgroup_socket_bind](https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L5940-L5983) + * hands the value to + * [parse_socket_bind_item](https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L222-L290), + * which splits it on `:` (an empty field is an error) and tries, in this order and each at most once, + * [parse_af_token](https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L147-L164) + * (`ipv4`/`ipv6`, case-sensitive), parse_ip_protocol_token (`tcp`/`udp` in any case) and + * [parse_ip_ports_token](https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L187-L213): + * `any`, or a port or port range read by + * [parse_ip_port_range](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L736-L760) + * with port 0 allowed. Each bound goes through safe_atou + * ([parse_range](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L305-L340)), so leading + * whitespace, `0x50` and `+80` are all fine, and `ipv4:tcp: 12345` is accepted. Anything left over is an error. + * + * parse_ip_port_range also refuses a range whose upper bound is below the lower one (`5-1`); that is checked on + * the parsed numbers once the grammar has accepted the value. Not modelled: backslash escapes inside a field. + */ +class CgroupSocketBindOptionValue() : GrammarOptionValue("config_parse_cgroup_socket_bind", GRAMMAR) { + override fun generateProblemDescriptors(property: UnitFilePropertyType, holder: ProblemsHolder) { + val before = holder.resultCount + super.generateProblemDescriptors(property, holder) + if (holder.resultCount != before) { + return + } -class CgroupSocketBindOptionValue() : GrammarOptionValue("config_parse_cgroup_socket_bind", GRAMMAR) { + // The grammar accepted the value, so the ports (if any) are the last `:` field, and a `-` in it splits the + // range: https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L753-L754 + val ports = (property.valueText ?: return).substringAfterLast(':') + if (!ports.contains('-')) { + return + } + val low = SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 65535L, acceptsWhitespace = true, acceptsMinus = false) + .valueOf(ports.substringBefore('-')) + val high = SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 65535L, acceptsWhitespace = true, acceptsMinus = true) + .valueOf(ports.substringAfter('-')) + if (low != null && high != null && high < low) { + holder.registerProblem( + property.valueNode.psi, + "${property.key}'s port range ends below where it starts ($low-$high)", + ProblemHighlightType.GENERIC_ERROR_OR_WARNING + ) + } + } companion object { - // https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html - val ADDRESS_FAMILY = LiteralChoiceTerminal("ipv4", "ipv6") - val TRANSPORT_PROTOCOL = LiteralChoiceTerminal("tcp", "udp") - val IP_PORT = IntegerTerminal(1, 65536) - val DASH = LiteralChoiceTerminal("-") - val COLON = LiteralChoiceTerminal(":") - val IP_PORT_RANGE = SequenceCombinator(IP_PORT, DASH, IP_PORT) - val IP_PORTS = AlternativeCombinator(IP_PORT_RANGE, IP_PORT) - + // Order matters for the classic engine only, which falls back to the first branch that matches leniently: + // ports first (the lenient match of `ipv4` would take the first four digits of a port), then the address + // family (its lenient match takes a whole bad word such as `icmp`, which the 3-letter `tcp` would cut short). val GRAMMAR = SequenceCombinator( - AlternativeCombinator( + PreferValidAlternativeCombinator( + // ports only + SequenceCombinator( + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 65535L, acceptsWhitespace = true, acceptsMinus = false), + ZeroOrOne( + SequenceCombinator( + LiteralChoiceTerminal("-"), + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 65535L, acceptsWhitespace = true, acceptsMinus = true) + ) + ) + ), LiteralChoiceTerminal("any"), - // The grammar has three blocks address-family, transport-protocol, and ip-ports, if there is more than one, there is a colon. - // We break this up into a few cases - // All specified - SequenceCombinator(ADDRESS_FAMILY, COLON, TRANSPORT_PROTOCOL,COLON, IP_PORTS), - // No ports - SequenceCombinator(ADDRESS_FAMILY, COLON, TRANSPORT_PROTOCOL), - // No transport - SequenceCombinator(ADDRESS_FAMILY, COLON, IP_PORTS), - // No Address family - SequenceCombinator(TRANSPORT_PROTOCOL,COLON, IP_PORTS), - ADDRESS_FAMILY, - TRANSPORT_PROTOCOL, - IP_PORTS + // address family, then optionally protocol and/or ports + SequenceCombinator( + FlexibleLiteralChoiceTerminal("ipv4", "ipv6"), + ZeroOrOne( + SequenceCombinator( + LiteralChoiceTerminal(":"), + PreferValidAlternativeCombinator( + SequenceCombinator( + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 65535L, acceptsWhitespace = true, acceptsMinus = false), + ZeroOrOne( + SequenceCombinator( + LiteralChoiceTerminal("-"), + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 65535L, acceptsWhitespace = true, acceptsMinus = true) + ) + ) + ), + LiteralChoiceTerminal("any"), + SequenceCombinator( + FlexibleLiteralChoiceTerminal("tcp", "udp", ignoreCase = true), + ZeroOrOne( + SequenceCombinator( + LiteralChoiceTerminal(":"), + PreferValidAlternativeCombinator( + SequenceCombinator( + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 65535L, acceptsWhitespace = true, acceptsMinus = false), + ZeroOrOne( + SequenceCombinator( + LiteralChoiceTerminal("-"), + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 65535L, acceptsWhitespace = true, acceptsMinus = true) + ) + ) + ), + LiteralChoiceTerminal("any") + ) + ) + ) + ) + ) + ) + ) + ), + // protocol, then optionally ports + SequenceCombinator( + FlexibleLiteralChoiceTerminal("tcp", "udp", ignoreCase = true), + ZeroOrOne( + SequenceCombinator( + LiteralChoiceTerminal(":"), + PreferValidAlternativeCombinator( + SequenceCombinator( + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 65535L, acceptsWhitespace = true, acceptsMinus = false), + ZeroOrOne( + SequenceCombinator( + LiteralChoiceTerminal("-"), + SafeAtoTerminal(signed = false, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 65535L, acceptsWhitespace = true, acceptsMinus = true) + ) + ) + ), + LiteralChoiceTerminal("any") + ) + ) + ) + ) ), EOF()) @@ -42,4 +140,3 @@ class CgroupSocketBindOptionValue() : GrammarOptionValue("config_parse_cgroup_so ) } } - diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/EnumOptionValues.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/EnumOptionValues.kt index 4507918e..9f637104 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/EnumOptionValues.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/EnumOptionValues.kt @@ -4,17 +4,32 @@ import com.google.common.collect.ImmutableSet import com.intellij.openapi.project.Project import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.AlternativeCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BOOLEAN import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.FlexibleLiteralChoiceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.PreferValidAlternativeCombinator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SafeAtoTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator +/** + * FailureAction=, SuccessAction=, JobTimeoutAction=, StartLimitAction= and friends. + * + * Every name in systemd's + * [emergency_action_table](https://github.com/systemd/systemd/blob/0f0682b773/src/core/emergency-action.c#L15-L32), + * looked up case-sensitively by + * [parse_emergency_action](https://github.com/systemd/systemd/blob/0f0682b773/src/core/emergency-action.c#L245-L263). + * In the user manager everything after `exit-force` is refused with EOPNOTSUPP, but the plugin cannot tell the + * scope of a unit file, so it accepts the full system-manager table. + */ class EmergencyActionOptionValue : AbstractGrammarEnumOptionValue(validOptions, VALIDATOR_NAME){ companion object { - private val validOptions : Set = ImmutableSet.of("none", "reboot", "reboot-force", "reboot-immediate", "poweroff", "poweroff-force", "poweroff-immediate", "exit", "exit-force") + private val validOptions : Set = ImmutableSet.of( + "none", "reboot", "reboot-force", "reboot-immediate", "poweroff", "poweroff-force", "poweroff-immediate", "exit", "exit-force", + "soft-reboot", "soft-reboot-force", "kexec", "kexec-force", "halt", "halt-force", "halt-immediate", + ) const val VALIDATOR_NAME = "config_parse_emergency_action" } } @@ -111,7 +126,15 @@ class CpuSchedulingPolicyOptionValue : SimpleGrammarOptionValues( } } -class NumaPolicyOptionValue : AbstractGrammarEnumOptionValue(ImmutableSet.of("default", "bind", "interleave", "local"), VALIDATOR_NAME) { +/** + * NUMAPolicy=: every name in + * [mpol_table](https://github.com/systemd/systemd/blob/0f0682b773/src/shared/numa-util.c#L253-L261), parsed by + * [DEFINE_CONFIG_PARSE_ENUM_WITH_DEFAULT](https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L174). + */ +class NumaPolicyOptionValue : AbstractGrammarEnumOptionValue( + ImmutableSet.of("default", "preferred", "bind", "interleave", "local", "preferred-many", "weighted-interleave"), + VALIDATOR_NAME +) { companion object { const val VALIDATOR_NAME = "config_parse_numa_policy" } @@ -208,13 +231,51 @@ class SocketTimestampingOptionValue : AbstractGrammarEnumOptionValue(ImmutableSe } } -class SocketProtocolOptionValue : AbstractGrammarEnumOptionValue(ImmutableSet.of("udplite", "sctp"), VALIDATOR_NAME) { +/** + * [Socket] SocketProtocol=. + * + * [parse_socket_protocol](https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L87-L97) runs + * [parse_ip_protocol_full](https://github.com/systemd/systemd/blob/0f0682b773/src/shared/ip-protocol-list.c#L36-L70) + * (a protocol name in any case, or a safe_atoi number that has a name) and then keeps only IPPROTO_UDPLITE (136), + * IPPROTO_SCTP (132) and IPPROTO_MPTCP (262). So `mptcp`, `SCTP`, `136` and `0x84` are all accepted. + */ +class SocketProtocolOptionValue : SimpleGrammarOptionValues( + VALIDATOR_NAME, + SequenceCombinator( + PreferValidAlternativeCombinator( + FlexibleLiteralChoiceTerminal("udplite", "sctp", "mptcp", ignoreCase = true), + SafeAtoTerminal(signed = true, base = 0, flags = emptySet(), minInclusive = 136L, maxInclusive = 136L, acceptsWhitespace = true, acceptsMinus = true), + SafeAtoTerminal(signed = true, base = 0, flags = emptySet(), minInclusive = 132L, maxInclusive = 132L, acceptsWhitespace = true, acceptsMinus = true), + SafeAtoTerminal(signed = true, base = 0, flags = emptySet(), minInclusive = 262L, maxInclusive = 262L, acceptsWhitespace = true, acceptsMinus = true), + ), + EOF() + ) +) { + override fun getAutoCompleteOptions(project: Project): Set = setOf("udplite", "sctp", "mptcp") + companion object { const val VALIDATOR_NAME = "config_parse_socket_protocol" } } -class SocketBindOptionValue : AbstractGrammarEnumOptionValue(ImmutableSet.of("default", "both", "ipv6-only"), VALIDATOR_NAME) { +/** + * [Socket] BindIPv6Only=. + * + * [socket_address_bind_ipv6_only_or_bool_from_string](https://github.com/systemd/systemd/blob/0f0682b773/src/shared/socket-label.c#L21-L39) + * first tries parse_boolean (true means `ipv6-only`, false means `both`) and then the case-sensitive table + * `default`, `both`, `ipv6-only`. + */ +class SocketBindOptionValue : SimpleGrammarOptionValues( + VALIDATOR_NAME, + SequenceCombinator( + PreferValidAlternativeCombinator( + FlexibleLiteralChoiceTerminal("default", "both", "ipv6-only"), + BOOLEAN, + ), + EOF() + ) +) { + override fun getAutoCompleteOptions(project: Project): Set = setOf("default", "both", "ipv6-only") companion object { const val VALIDATOR_NAME = "config_parse_socket_bind" diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ModeStringOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ModeStringOptionValue.kt index c0588984..6d13ea89 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ModeStringOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ModeStringOptionValue.kt @@ -4,11 +4,12 @@ import com.intellij.openapi.project.Project import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator /** - * Matches a mode value written in octal. + * Matches a mode value written in octal (UMask=, SocketMode=, DirectoryMode=, ...). * - * - * Looks like this was done internally in C, but test-conf-parser.c ~ line 172 - * has some stuff. + * [parse_mode](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L90-L110) reads the value + * with safe_atou_full(base 8, SAFE_ATO_REFUSE_PLUS_MINUS) and refuses anything above 07777. So any number of octal + * digits is fine (`71`, `0`, `00022`) as long as the value is at most 7777; there is no 3 or 4 digit rule. + * A sign, `0x`/`0o` prefixes and the digits 8 and 9 are refused. */ class ModeStringOptionValue : OptionValueInformation { override fun getAutoCompleteOptions(project: Project): Set { @@ -16,10 +17,10 @@ class ModeStringOptionValue : OptionValueInformation { } override fun getErrorMessage(value: String): String? { - return if (value.matches("[0-7]{3,4}".toRegex())) { + return if (value.matches("[0-7]+".toRegex()) && value.trimStart('0').length <= 4) { null } else { - "Value is expected to be a 3 or 4 digit octal number not: $value" + "Value is expected to be an octal number between 0 and 7777 not: $value" } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/PathOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/PathOptionValue.kt index 72c63898..a1948664 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/PathOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/PathOptionValue.kt @@ -1,23 +1,26 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues -import com.intellij.openapi.project.Project import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator - -class PathOptionValue : OptionValueInformation { - override fun getAutoCompleteOptions(project: Project): Set { - return emptySet() - } - - override fun getErrorMessage(value: String): String? { - return if (PATH_REGEX.matches(value)) { - null - } else { - "This option must be a valid absolute path, not [$value]" - } - } - - override val validatorName: String - get() = VALIDATOR_NAME +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.BatchUnitCoreGrammar +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator + +/** + * config_parse_unit_path_printf: Mount.Where=, Automount.Where=, RootDirectory=, RootImage=, TTYPath=, ... + * + * [config_parse_unit_path_printf](https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L428-L458) + * expands the unit specifiers and hands the result to + * [config_parse_path](https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L1378-L1414), which runs + * [path_simplify_and_warn](https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L34-L79) with + * PATH_CHECK_ABSOLUTE: the path must be absolute and, once `.` components and repeated slashes are simplified away, + * have no `..` component. A `..` right after the root is simplified away too, so `/..` and `/../a` are fine. Spaces and a lone `/` are fine (`Where=/hoge/with space`, `Where=/`). A value that starts + * with a specifier (`%h/x`) is accepted, because what it expands to is only known when the unit is loaded; an + * unknown specifier is refused. ltype `true` only makes the same errors fatal. + */ +class PathOptionValue : SimpleGrammarOptionValues(VALIDATOR_NAME, SequenceCombinator(BatchUnitCoreGrammar.UNIT_ABSOLUTE_PATH, EOF())) { + + override fun invalidValueMessage(key: String, badValue: String): String = + "$key must be an absolute path with no .. component, not [$badValue]" companion object { const val VALIDATOR_NAME = "config_parse_unit_path_printf" @@ -26,8 +29,5 @@ class PathOptionValue : OptionValueInformation { Validator(VALIDATOR_NAME, "0") to PathOptionValue(), Validator(VALIDATOR_NAME, "true") to PathOptionValue(), ) - - val PATH_REGEX = """^/(?:[^\\ ]|\\\s|(?:\\\\))+$""".toRegex() - } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/TimeHelper.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/TimeHelper.kt index b2287586..8e1f90b7 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/TimeHelper.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/TimeHelper.kt @@ -1,5 +1,7 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues +import java.math.BigInteger + /** * This class is adapted from time-util.h/c * https://github.com/systemd/systemd/blob/main/src/basic/time-util.h#L46 @@ -63,9 +65,17 @@ object TimeHelper { Multiplier( "µs", 1 ), ) + private val LLONG_MAX: BigInteger = BigInteger.valueOf(Long.MAX_VALUE) + val WHITESPACE = """[ \t\n\r]""".toRegex() - val NUMBER = """^\d*\.?\d+""".toRegex() + // What parse_time() takes as one number: strtoll(…, 10) reads an optional `+` and digits, and a `.` with digits + // may follow. With no digits in front, only `.5` style works: `+.5` and `5.` are refused. + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1234-L1243 + val NUMBER = """^(?:\+?\d+(?:\.\d+)?|\.\d+)""".toRegex() + + /** parse_time()'s -ERANGE: the value is well formed but does not fit. */ + class OutOfRangeException(message: String) : IllegalArgumentException(message) fun parseSecs(time: String): ULong { return parseTime(time, Multiplier( "sec", USEC_PER_SEC )); @@ -99,10 +109,10 @@ object TimeHelper { throw IllegalArgumentException("Negatives are not allowed in $time") } - val result = NUMBER.find(p) - - val value = result?.groups?.get(0)?.value?.toDouble() ?: throw IllegalArgumentException("Could not parse leading number ($p) in $time") - p = p.substring(result.groups.get(0)?.value!!.length).trim() + val result = NUMBER.find(p) ?: throw IllegalArgumentException("Could not parse leading number ($p) in $time") + val numberText = result.value + val afterNumber = p.substring(numberText.length) + p = afterNumber.trim() var multiplier : Multiplier? = null for (m in multiplers) { @@ -114,24 +124,46 @@ object TimeHelper { } if (multiplier == null) { + // "Don't allow '12.34.56', but accept '12.34 .56' or '12.34s.56'": with no unit, the next number must be + // separated by whitespace. + if (p.isNotEmpty() && afterNumber.first() !in " \t\n\r") { + throw IllegalArgumentException("Could not parse ($afterNumber) after a number in $time") + } multiplier = defaultMultiplier } -// There is something odd with the below that I don't care about right now. -// For some reason k is turned a signed 32-bit integer. - -// if (value >= (ULong.MAX_VALUE / multiplier.usecs.toULong()).toDouble()) { -// // This checks that the single term won't cause an overflow. -// throw IllegalArgumentException("Value is too big $p in $time") -// } -// - val k : ULong = (multiplier.usecs.toULong() * value.toULong()) -// if (k > ULong.MAX_VALUE - usec) { -// // This checks that the addition of this value won't cause an overflow. -// throw IllegalArgumentException("Total value is too $p in $time") -// } + + // The overflow checks of parse_time(): + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1233-L1276 + // strtoll() refuses an integer part above LLONG_MAX (ERANGE), and every step must stay below USEC_INFINITY. + val integerPart = numberText.substringBefore('.').removePrefix("+") + val fractionPart = numberText.substringAfter('.', "") + val l = if (integerPart.isEmpty()) BigInteger.ZERO else BigInteger(integerPart) + if (l > LLONG_MAX) { + throw OutOfRangeException("Value is out of range in $time") + } + + val usecs = multiplier.usecs.toULong() + if (l.toLong().toULong() >= ULong.MAX_VALUE / usecs) { + throw OutOfRangeException("Value is out of range in $time") + } + + val k : ULong = usecs * l.toLong().toULong() + if (k >= ULong.MAX_VALUE - usec) { + throw OutOfRangeException("Value is out of range in $time") + } usec += k + var m = usecs / 10u + for (digit in fractionPart) { + val f = (digit - '0').toULong() * m + if (f >= ULong.MAX_VALUE - usec) { + throw OutOfRangeException("Value is out of range in $time") + } + usec += f + m /= 10u + } + something = true; } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/UnitDependencyOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/UnitDependencyOptionValue.kt index 7655f4dd..9836aa2c 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/UnitDependencyOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/UnitDependencyOptionValue.kt @@ -11,7 +11,9 @@ import com.intellij.psi.search.GlobalSearchScope import com.intellij.psi.util.CachedValueProvider import com.intellij.psi.util.CachedValuesManager import com.intellij.util.containers.CollectionFactory +import com.intellij.psi.util.PsiTreeUtil import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFilePropertyType +import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileSectionGroups import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.SemanticDataRepository import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import java.io.IOException @@ -57,41 +59,112 @@ class UnitDependencyOptionValue : OptionValueInformation { throw IllegalStateException("Not supported") } + /** + * Checks every whitespace-separated word the way systemd does. + * + * For [Unit] dependencies, + * [config_parse_unit_deps](https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L261-L316) + * runs [unit_name_printf](https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit-printf.c#L147-L177) on + * each word and then adds the dependency, which requires + * [unit_name_is_valid](https://github.com/systemd/systemd/blob/0f0682b773/src/basic/unit-name.c#L36-L82). A + * template such as `foo@.service` is fine: it gets this unit's instance filled in by + * [resolve_template](https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit.c#L3320-L3350). + * + * The [Install] keys are read by systemctl, not PID 1. Also= is checked with unit_name_is_valid while the file is + * parsed ([install_info_add](https://github.com/systemd/systemd/blob/0f0682b773/src/shared/install.c#L1194-L1218)), + * and WantedBy=/RequiredBy= when the unit is enabled + * ([install_info_symlink_wants](https://github.com/systemd/systemd/blob/0f0682b773/src/shared/install.c#L2090-L2117)). + * Both expand the same specifiers through + * [install_name_printf](https://github.com/systemd/systemd/blob/0f0682b773/src/shared/install-printf.c#L89-L114). + * + * PID 1 also refuses an instance or template of a type that cannot be templated + * ([unit_add_name](https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit.c#L282-L283), + * [unit_type_may_template](https://github.com/systemd/systemd/blob/0f0682b773/src/shared/unit-file.h#L35-L42)), so + * `After=foo@bar.mount` is dropped. systemctl does not check that for the [Install] keys, so neither does this. + * + * A word that uses a specifier (`foo@%i.service`, `notify@%n`) is accepted as long as every specifier is known, + * because what it expands to depends on the unit's own name. An unknown one (`%f`, `%T`, `%Z`) makes + * [specifier_printf](https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L78-L80) fail + * with EBADSLT, and the word is dropped. + */ override fun generateProblemDescriptors(property: UnitFilePropertyType, holder: ProblemsHolder) { - // Valid unit names consist of a "name prefix" and a dot and a suffix specifying the unit type. - // The "unit prefix" must consist of one or more valid characters (ASCII letters, digits, ":", "-", "_", ".", and "\"). - // The total length of the unit name including the suffix must not exceed 256 characters. - // The type suffix must be one of ".service", ".socket", ".device", ".mount", ".automount", ".swap", ".target", ".path", ".timer", ".slice", or ".scope". - val values = property.valueNode.text - var processedCharactersInString = 0 - while (processedCharactersInString < values.length) { - val indexOfNextSpace = values.indexOf(" ", processedCharactersInString) - var endOfWord = values.length - if (indexOfNextSpace > -1) { - endOfWord = indexOfNextSpace - } - val word = values.substring(processedCharactersInString, endOfWord) - if (word.length == 0) { - processedCharactersInString += 1 - continue - } - val lastDot = word.lastIndexOf('.') - val range = TextRange(processedCharactersInString + lastDot + 1, endOfWord) - if (lastDot > -1) { - // Valid unit name - val unitType = word.substring(lastDot + 1) - if (!validUnitTypes.contains(unitType)) { - holder.registerProblem( - property.valueNode.psi, range, - "Unit type " + unitType + " is unsupported, valid types are: " + Strings.join(validUnitTypes, ", ") - ) + // A line continuation separates words just like whitespace does. Blank it out with the same number of + // characters, so that offsets still line up with the raw value text. + val values = LINE_CONTINUATION.replace(property.valueNode.text) { " ".repeat(it.value.length) } + val inInstallSection = PsiTreeUtil.getParentOfType(property, UnitFileSectionGroups::class.java)?.sectionName == "Install" + + for (match in WORD.findAll(values)) { + val word = match.value + val wordStart = match.range.first + val problem = problemForWord(word, checkTemplateType = !inInstallSection) ?: continue + holder.registerProblem( + property.valueNode.psi, + TextRange(wordStart + problem.first.first, wordStart + problem.first.last + 1), + problem.second + ) + } + } + + /** The range within [word] to highlight and the message, or null if systemd accepts the word. */ + private fun problemForWord(word: String, checkTemplateType: Boolean): Pair? { + var hasSpecifier = false + var i = 0 + while (i < word.length) { + if (word[i] == '%' && i + 1 < word.length) { + val next = word[i + 1] + when { + next == '%' -> i += 2 + next in UNIT_NAME_PRINTF_SPECIFIERS -> { + hasSpecifier = true + i += 2 + } + next in 'a'..'z' || next in 'A'..'Z' || next in '0'..'9' -> + return Pair(i..i + 1, "Unknown specifier %$next, systemd ignores this unit name") + else -> i += 1 } } else { - holder.registerProblem(property.valueNode.psi, range, "Invalid unit name detected, all units must end with a . and then a unit type suffix") + i += 1 + } + } + + if (hasSpecifier) { + // What the specifiers expand to is only known when the unit is loaded. + return null + } + + if (word.length >= UNIT_NAME_MAX) { + return Pair(word.indices, "Unit name is too long, it must be shorter than $UNIT_NAME_MAX characters") + } + + val lastDot = word.lastIndexOf('.') + if (lastDot == -1) { + return Pair(word.indices, "Invalid unit name detected, all units must end with a . and then a unit type suffix") + } + + val unitType = word.substring(lastDot + 1) + if (!validUnitTypes.contains(unitType)) { + return Pair(lastDot + 1 until word.length, "Unit type " + unitType + " is unsupported, valid types are: " + Strings.join(validUnitTypes, ", ")) + } + + if (lastDot == 0) { + return Pair(word.indices, "Invalid unit name detected, the name in front of the unit type suffix must not be empty") + } + + for (index in 0 until lastDot) { + if (word[index] !in VALID_UNIT_NAME_CHARS) { + return Pair(index..index, "Invalid character '${word[index]}' in unit name, allowed are ASCII letters, digits, ':', '-', '_', '.', '\\' and '@'") } - processedCharactersInString += word.length } - return + + if (word[0] == '@') { + return Pair(0..0, "Invalid unit name detected, a unit name must not start with '@'") + } + + if (checkTemplateType && word.indexOf('@') in 0 until lastDot && unitType !in TEMPLATABLE_UNIT_TYPES) { + return Pair(word.indices, "Units of type $unitType cannot be templates or instances, only these can: " + Strings.join(TEMPLATABLE_UNIT_TYPES, ", ")) + } + + return null } override val validatorName: String @@ -147,6 +220,22 @@ class UnitDependencyOptionValue : OptionValueInformation { private var unitNames: Set? = null private val validUnitTypes = setOf("service", "socket", "device", "mount", "automount", "swap", "target", "path", "timer", "slice", "scope") + /** unit_type_may_template() in src/shared/unit-file.h. */ + private val TEMPLATABLE_UNIT_TYPES = setOf("service", "socket", "target", "timer", "path") + + /** UNIT_NAME_MAX: a unit name must be shorter than this. */ + private const val UNIT_NAME_MAX = 256 + + /** VALID_CHARS_WITH_AT in src/basic/unit-name.c. */ + private val VALID_UNIT_NAME_CHARS = (('a'..'z') + ('A'..'Z') + ('0'..'9') + listOf(':', '-', '_', '.', '\\', '@')).toSet() + + /** The specifiers unit_name_printf() and install_name_printf() know: their own five plus COMMON_SYSTEM and COMMON_CREDS. */ + private const val UNIT_NAME_PRINTF_SPECIFIERS = "ijnNpaAbBHlqmMovwWgGuU" + + private val LINE_CONTINUATION = Regex("\\\\[ \\t]*\\r?\\n") + + private val WORD = Regex("\\S+") + const val VALIDATOR_NAME = "config_parse_unit_deps" val validators = mapOf( Validator(VALIDATOR_NAME, "*") to UnitDependencyOptionValue(), diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCoalesceSecOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCoalesceSecOptionValue.kt index 0a8452b0..d726e2da 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCoalesceSecOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCoalesceSecOptionValue.kt @@ -1,7 +1,15 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai +import com.intellij.codeInspection.ProblemHighlightType +import com.intellij.codeInspection.ProblemsHolder +import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFilePropertyType import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.TimeHelper + +/* + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/shared/ethtool-util.c#L1370-L1415 config_parse_coalesce_sec + */ /** * Validator for Link.{RxCoalesceSec, RxCoalesceIrqSec, TxCoalesceSec, TxCoalesceIrqSec, @@ -9,10 +17,58 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.Simp * RxCoalesceHighSec, TxCoalesceHighSec, CoalescePacketRateSampleIntervalSec}. * C Function: config_parse_coalesce_sec(0) * - * Calls parse_sec, which accepts "infinity", a fractional or integer number with any - * of systemd's time-unit suffixes, and compound forms like "1h 30s". + * Calls parse_sec, which accepts a fractional or integer number with any of systemd's time-unit + * suffixes, and compound forms like "1h 30s". The result (in microseconds) must then fit in a uint32: + * anything above UINT32_MAX µs (about 71.6 minutes, so `infinity`, `2hour` and `4295s`) is refused + * with "Too large … value". StatisticsBlockCoalesceSec= and CoalescePacketRateSampleIntervalSec= also + * refuse 0 µs ("Invalid … value"). The value is parsed with TimeHelper, a port of parse_time(); the grammar + * is only used to locate a syntax error. */ class ConfigParseCoalesceSecOptionValue : SimpleGrammarOptionValues( "config_parse_coalesce_sec", SequenceCombinator(TIME_VALUE, EOF()) -) +) { + override fun generateProblemDescriptors(property: UnitFilePropertyType, holder: ProblemsHolder) { + val value = property.valueText ?: return + // parse_sec()'s own -ERANGE (e.g. `999999999999999999`) is as much "too large" as a value above UINT32_MAX. + val usec = try { + TimeHelper.parseSecs(value) + } catch (e: TimeHelper.OutOfRangeException) { + ULong.MAX_VALUE + } catch (e: IllegalArgumentException) { + null + } + + when { + usec == null -> { + // The grammar locates the error; TimeHelper (a port of parse_time()) has the last word. + val before = holder.resultCount + super.generateProblemDescriptors(property, holder) + if (holder.resultCount == before) { + holder.registerProblem( + property.valueNode.psi, + "${property.key}'s value is not a valid time span", + ProblemHighlightType.GENERIC_ERROR_OR_WARNING + ) + } + } + usec > UINT32_MAX -> holder.registerProblem( + property.valueNode.psi, + "${property.key}'s value is too large, it must be at most $UINT32_MAX µs (about 71 minutes)", + ProblemHighlightType.GENERIC_ERROR_OR_WARNING + ) + usec < 1u && property.key in NON_ZERO_KEYS -> holder.registerProblem( + property.valueNode.psi, + "${property.key}'s value must be at least 1 µs", + ProblemHighlightType.GENERIC_ERROR_OR_WARNING + ) + // parse_sec() took it (`+5` and `.5` too, which the shared TIME_VALUE grammar does not know). + else -> Unit + } + } + + private companion object { + val UINT32_MAX: ULong = 4294967295u + val NON_ZERO_KEYS = setOf("StatisticsBlockCoalesceSec", "CoalescePacketRateSampleIntervalSec") + } +} diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseColonSeparatedPathsOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseColonSeparatedPathsOptionValue.kt index 6df91f9e..85d5a1e5 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseColonSeparatedPathsOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseColonSeparatedPathsOptionValue.kt @@ -3,26 +3,39 @@ 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.* +/* + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L460-L516 config_parse_colon_separated_paths + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L34-L79 path_simplify_and_warn (PATH_CHECK_ABSOLUTE) + */ + /** * Validator for ExecSearchPath= (and similar colon-separated path lists). * * C function: config_parse_colon_separated_paths in src/core/load-fragment.c. Tokenizes with - * extract_first_word using ":" as the separator (no coalescing), expands specifiers via - * unit_path_printf, then requires each path to be absolute via path_simplify_and_warn with - * PATH_CHECK_ABSOLUTE. + * extract_first_word using ":" as the separator (no coalescing, so an empty entry is an error), expands + * specifiers via unit_path_printf, then requires each path to be absolute and normalized (no `..` + * component, except right after the root: `/../a` is simplified to `/a`) via path_simplify_and_warn with + * PATH_CHECK_ABSOLUTE. Any failure drops the rest of the value. * - * Grammar matches one or more absolute paths (each starting with "/") separated by ":". - * Per-path content excludes ":" since that's the separator. "%X" specifiers are allowed - * inline since the C parser expands them before the absolute-path check. + * Spaces are ordinary path characters here (`/a b:/c` is fine). An entry may start with a unit specifier + * (`%V`, `%h`), since what it expands to is only known when the unit is loaded; every `%X` must be a + * specifier unit_path_printf knows ([BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS]). */ class ConfigParseColonSeparatedPathsOptionValue : SimpleGrammarOptionValues( "config_parse_colon_separated_paths", SequenceCombinator( - RegexTerminal("/[^\\s:]*", "/[^\\s:]*"), + RegexTerminal("[^:]+", SEARCH_PATH_ENTRY), ZeroOrMore(SequenceCombinator( LiteralChoiceTerminal(":"), - RegexTerminal("/[^\\s:]*", "/[^\\s:]*") + RegexTerminal("[^:]+", SEARCH_PATH_ENTRY) )), EOF() ) -) +) { + private companion object { + /** One absolute, normalized path (or one starting with a specifier), up to the next `:`. */ + const val SEARCH_PATH_ENTRY = "(?!(?>(?:/+(?:\\.{1,2}(?:/+|(?![^:])))*)?)(?:[^:]*/)?\\.\\.(?:/|:|$))" + + "(?:/|%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}])" + + "(?:[^%:]|%%|%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}]|%(?![A-Za-z0-9%]))*" + } +} diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDhcpSocketPriorityOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDhcpSocketPriorityOptionValue.kt index b89a472d..7a65dff8 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDhcpSocketPriorityOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDhcpSocketPriorityOptionValue.kt @@ -3,19 +3,23 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-dhcp4.c#L2060-L2096 config_parse_dhcp_socket_priority + */ + /** * Validator for DHCPv4.SocketPriority * C Function: config_parse_dhcp_socket_priority(0) * Used by Options: DHCPv4.SocketPriority * - * The C implementation uses safe_atoi() with no range restriction beyond what fits in a - * signed 32-bit int. SO_PRIORITY values 0..6 are typical, but the parser itself accepts - * any signed int. Empty values are allowed (clears the setting) and so are skipped here. + * The C implementation reads the value with safe_atoi() and then refuses anything outside + * 0…TC_PRIO_MAX (15): "Invalid socket priority, must be in 0…15". Empty values are allowed + * (clears the setting) and so are skipped here. */ class ConfigParseDhcpSocketPriorityOptionValue : SimpleGrammarOptionValues( "config_parse_dhcp_socket_priority", SequenceCombinator( - IntegerTerminal(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong() + 1L), + SafeAtoTerminal(signed = true, base = 0, flags = emptySet(), minInclusive = 0L, maxInclusive = 15L, acceptsWhitespace = true, acceptsMinus = true), EOF() ) ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDisableControllersOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDisableControllersOptionValue.kt index 4c0ad7a1..fd65f959 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDisableControllersOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDisableControllersOptionValue.kt @@ -1,46 +1,68 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L5787-L5820 config_parse_disable_controllers + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/cgroup-util.c#L1661-L1687 cg_mask_from_string + * table https://github.com/systemd/systemd/blob/0f0682b773/src/basic/cgroup-util.c#L1792-L1807 cgroup_controller_table + */ + /** - * Validator for Swap.DisableControllers + * Validator for DisableControllers= ([Service], [Socket], [Mount], [Swap], [Slice], ...). * C Function: config_parse_disable_controllers(0) - * Used by Options: Swap.DisableControllers + * + * cg_mask_from_string() splits the value on whitespace and looks every word up case-sensitively in + * cgroup_controller_table. A word that is not a controller is silently skipped, so `cpu invalid memory` is + * fine. Only when no word at all names a controller (the mask stays 0) is the assignment refused + * ("Invalid cgroup string"). The legacy cgroup v1 names `cpuacct`, `blkio` and `devices` are still in the + * table and accepted. + * + * The grammar is therefore: any number of unknown words, then one known controller, then any words at all. + * The unknown-word terminal refuses a known controller name (negative lookahead), so the classic engine, + * which never backtracks, stops in front of the first known controller. It also takes the whitespace after + * the word, so that the last word of an all-unknown value is left for the controller terminal to flag. */ class ConfigParseDisableControllersOptionValue : SimpleGrammarOptionValues( "config_parse_disable_controllers", SequenceCombinator( + // An unknown word together with the whitespace after it, as one token: the classic engine reports a + // problem past the end of the value when a repetition matches a word but not the whitespace after it. + ZeroOrMore( + RegexTerminal( + "(?!(?:$CONTROLLER_NAMES)(?!\\S))\\S+\\s+", + "(?!(?:$CONTROLLER_NAMES)(?!\\S))\\S+\\s+" + ) + ), FlexibleLiteralChoiceTerminal( "cpu", + "cpuacct", "cpuset", "io", + "blkio", "memory", + "devices", "pids", "bpf-firewall", "bpf-devices", "bpf-foreign", "bpf-socket-bind", - "bpf-restrict-network-interfaces" + "bpf-restrict-network-interfaces", + "bpf-bind-network-interface" ), ZeroOrMore( SequenceCombinator( WhitespaceTerminal(), - FlexibleLiteralChoiceTerminal( - "cpu", - "cpuset", - "io", - "memory", - "pids", - "bpf-firewall", - "bpf-devices", - "bpf-foreign", - "bpf-socket-bind", - "bpf-restrict-network-interfaces" - ) + RegexTerminal("\\S+", "\\S+") ) ), EOF() ) -) +) { + private companion object { + /** cgroup_controller_table, as a regex alternation for the unknown-word lookahead. */ + const val CONTROLLER_NAMES = "cpu|cpuacct|cpuset|io|blkio|memory|devices|pids|bpf-firewall|bpf-devices|" + + "bpf-foreign|bpf-socket-bind|bpf-restrict-network-interfaces|bpf-bind-network-interface" + } +} diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTriggerUnitOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTriggerUnitOptionValue.kt index 8ba6e208..83871527 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTriggerUnitOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseTriggerUnitOptionValue.kt @@ -3,23 +3,38 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L2116-L2165 config_parse_trigger_unit + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit-printf.c#L147-L177 unit_name_printf + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/unit-name.c#L36-L82 unit_name_is_valid + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit.c#L282-L283 unit_add_name (no instances of untemplatable types) + */ + /** * Validator for [Path] Unit= and [Timer] Unit=. * C function: config_parse_trigger_unit (src/core/load-fragment.c) * - * The C path resolves specifiers in rvalue, then requires the result to be a - * valid systemd unit name (unit_name_to_type must succeed). A unit name is - * . where the body characters are letters, digits, '-', '_', '@', - * ':', and '.', and is one of the recognised unit type extensions. + * The C path expands specifiers with unit_name_printf, requires the result to be a valid unit name + * (unit_name_to_type), and adds the trigger dependency, which loads the unit and so refuses an instance + * or template of a type that cannot be templated (only service, socket, target, timer and path can). + * A template such as `foo@.service` gets this unit's instance filled in. * - * Used by .path (Path.Unit) and .timer (Timer.Unit) units. + * A value that uses a unit_name_printf specifier (`brltty@-etc-brltty_%i.conf.service`) is accepted as + * long as every specifier is known (`%i %j %n %N %p`, COMMON_SYSTEM and COMMON_CREDS), because what it + * expands to depends on this unit's own name. That a unit cannot trigger itself is not modelled. */ class ConfigParseTriggerUnitOptionValue : SimpleGrammarOptionValues( "config_parse_trigger_unit", SequenceCombinator( RegexTerminal( - "[A-Za-z0-9_:.@-]+\\.(service|socket|target|device|mount|automount|timer|swap|path|slice|scope)", - "[A-Za-z0-9_:.@-]+\\.(service|socket|target|device|mount|automount|timer|swap|path|slice|scope)" + "[\\s\\S]+", + // a literal unit name: shorter than UNIT_NAME_MAX (256), not starting with '@', VALID_CHARS_WITH_AT, a known type + "(?=[\\s\\S]{1,255}$)(?!@)(?:" + + "[A-Za-z0-9:_.\\\\-]+\\.(?:service|socket|target|device|mount|automount|timer|swap|path|slice|scope)" + + "|[A-Za-z0-9:_.\\\\@-]*@[A-Za-z0-9:_.\\\\@-]*\\.(?:service|socket|target|timer|path)" + + ")$" + + // or anything that uses at least one known specifier and no unknown one + "|(?=(?:[^%]|%%|%(?![A-Za-z0-9%]))*%[ijnNpaAbBHlqmMovwWgGuU])(?:[^%]|%%|%[ijnNpaAbBHlqmMovwWgGuU]|%(?![A-Za-z0-9%]))*$" ), EOF() ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitMountsForOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitMountsForOptionValue.kt index c4450a07..5bf90981 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitMountsForOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitMountsForOptionValue.kt @@ -3,25 +3,40 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* +/* + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L3190-L3241 config_parse_unit_mounts_for + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/parse-helpers.c#L34-L79 path_simplify_and_warn (PATH_CHECK_ABSOLUTE) + */ + /** * Validator for RequiresMountsFor= / WantsMountsFor=. * * C function: config_parse_unit_mounts_for in src/core/load-fragment.c. Tokenizes rvalue with * extract_first_word (whitespace-separated, EXTRACT_UNQUOTE), expands unit specifiers via - * unit_path_printf, then requires each path to be absolute via path_simplify_and_warn with - * PATH_CHECK_ABSOLUTE. + * unit_path_printf, then requires each path to be absolute and normalized (no `..` component) via + * path_simplify_and_warn with PATH_CHECK_ABSOLUTE (a `..` right after the root, as in `/../a`, is simplified + * away and fine). A bad word is skipped; the others still count. * - * The grammar allows any non-whitespace path that starts with "/", and accepts "%X" specifiers - * inline since the C parser expands them before the absolute-path check. + * Each word must start with `/` or with a unit specifier (`%f`, `%h`, `%t/x`, ...): what a specifier + * expands to is only known when the unit is loaded. Every `%X` in a word must be a specifier that + * unit_path_printf knows ([BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS]); an unknown one makes systemd + * drop the word. Quoted words are not modelled. */ class ConfigParseUnitMountsForOptionValue : SimpleGrammarOptionValues( "config_parse_unit_mounts_for", SequenceCombinator( - RegexTerminal("/\\S*", "/\\S*"), + RegexTerminal("\\S+", MOUNTS_FOR_WORD), ZeroOrMore(SequenceCombinator( WhitespaceTerminal(), - RegexTerminal("/\\S*", "/\\S*") + RegexTerminal("\\S+", MOUNTS_FOR_WORD) )), EOF() ) -) +) { + private companion object { + /** One absolute, normalized path (or one starting with a specifier), within a single word. */ + const val MOUNTS_FOR_WORD = "(?!(?>(?:/+(?:\\.{1,2}(?:/+|(?!\\S)))*)?)(?:\\S*/)?\\.\\.(?:/|\\s|$))" + + "(?:/|%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}])" + + "(?:[^%\\s]|%%|%[${BatchUnitCoreGrammar.UNIT_SPECIFIER_LETTERS}]|%(?![A-Za-z0-9%]))*" + } +} diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitSliceOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitSliceOptionValue.kt index e07d3911..fffdf7c2 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitSliceOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitSliceOptionValue.kt @@ -3,23 +3,35 @@ 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.* +/* + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L3714-L3754 config_parse_unit_slice + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/manager.c#L2748-L2755 manager_load_unit_prepare (valid plain or instance name) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit.c#L282-L283 unit_add_name (slices cannot be instances) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/core/unit.c#L3483-L3484 unit_set_slice (must be a slice) + */ + /** * Validator for Slice= (the unit's enclosing slice). * * C function: config_parse_unit_slice in src/core/load-fragment.c. It calls unit_name_printf - * (which expands specifiers like %p/%n) and then manager_load_unit, which validates that the - * resolved name is a real slice unit. Pre-expansion, the value must be a single token; if it - * contains specifiers it can produce anything, otherwise it must already look like a slice - * (ends in ".slice"). Whitespace-separated lists are not accepted. + * (which expands specifiers like %p/%n) and then manager_load_unit, which needs a valid unit name, and + * unit_set_slice, which needs a slice. Slices cannot be templated, so `some@instance.slice` and + * `foo@.slice` are refused. Whitespace-separated lists are not accepted. + * + * A value that uses a unit_name_printf specifier (`%i %j %n %N %p`, COMMON_SYSTEM and COMMON_CREDS) can + * produce anything, so it is accepted as long as every specifier is known. */ class ConfigParseUnitSliceOptionValue : SimpleGrammarOptionValues( "config_parse_unit_slice", SequenceCombinator( AlternativeCombinator( - // Literal slice unit name: no whitespace, must end in .slice - RegexTerminal("\\S+", "[^\\s%]+\\.slice"), - // Contains a specifier — expansion can produce any unit name - RegexTerminal("\\S+", "\\S*%\\S+|\\S+%\\S*") + // Literal slice unit name: VALID_CHARS (no '@'), shorter than UNIT_NAME_MAX, ends in .slice + RegexTerminal("\\S+", "(?=\\S{1,255}$)[A-Za-z0-9:_.\\\\-]+\\.slice"), + // Contains a known specifier and no unknown one: expansion can produce any unit name + RegexTerminal( + "\\S+", + "(?=(?:[^%\\s]|%%|%(?![A-Za-z0-9%]))*%[ijnNpaAbBHlqmMovwWgGuU])(?:[^%\\s]|%%|%[ijnNpaAbBHlqmMovwWgGuU]|%(?![A-Za-z0-9%]))*" + ) ), EOF() ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitStringPrintfOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitStringPrintfOptionValue.kt index 44b945cc..568270eb 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitStringPrintfOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitStringPrintfOptionValue.kt @@ -12,7 +12,10 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * therefore NO constraint on the resulting value beyond it being an ordinary (UTF-8) string -- so * the only thing we can meaningfully validate is the specifier syntax: every '%' must be either a * literal "%%" or a known unit specifier. unit_full_printf() returns an error (and the assignment - * is dropped) on an unknown specifier, so flagging those is faithful to systemd. + * is dropped) on an unknown specifier, so flagging those is faithful to systemd. Only a letter or + * digit after '%' is a specifier + * (https://github.com/systemd/systemd/blob/0f0682b773/src/shared/specifier.c#L78-L84): a '%' before + * anything else, or at the very end ("Ends with a percent %"), is copied through verbatim. * * Valid specifiers are the unit table plus COMMON_SYSTEM/CREDS/TMP_SPECIFIERS from * src/core/unit-printf.c and src/shared/specifier.h: @@ -24,7 +27,7 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram class ConfigParseUnitStringPrintfOptionValue : SimpleGrammarOptionValues( "config_parse_unit_string_printf", SequenceCombinator( - RegexTerminal(".*", "(?:[^%]|%%|%[iIjJnNpPfyYcrRCdDELSthsaAbBHlqmMovwWgGuUTV])*"), + RegexTerminal(".*", "(?:[^%]|%%|%[iIjJnNpPfyYcrRCdDELSthsaAbBHlqmMovwWgGuUTV]|%(?![A-Za-z0-9%]))*"), EOF() ) ) \ No newline at end of file diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt index 968a388d..0ae23e04 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/BatchUnitCoreCombinators.kt @@ -381,8 +381,12 @@ object BatchUnitCoreGrammar { // path_is_normalized() (which refuses any `..` component), and with PATH_CHECK_NON_API_VFS also // path_below_api_vfs() (refuses /dev, /sys, /proc and anything below them). - // Rejects a `..` component anywhere: the optional `(?:[\s\S]*/)` swallows any leading directories. - const val NO_DOT_DOT_COMPONENT = """(?!(?:[\s\S]*/)?\.\.(?:/|$))""" + // Rejects a `..` component anywhere except at the very start of an absolute path: path_simplify_full() + // drops `.` and `..` components that directly follow the root, so `/..`, `/../a` and `//./../a` are fine + // (https://github.com/systemd/systemd/blob/0f0682b773/src/basic/path-util.c#L384-L386). The atomic group + // claims that leading run of `/`, `.` and `..` once and for all; the optional `(?:[\s\S]*/)` then swallows + // any directories in front of a later `..`. + const val NO_DOT_DOT_COMPONENT = """(?!(?>(?:/+(?:\.{1,2}(?:/+|$))*)?)(?:[\s\S]*/)?\.\.(?:/|$))""" // path_startswith() is component-aware: /dev and /dev/x are API VFS, /devices is not. const val NOT_API_VFS = """(?!/+(?:dev|sys|proc)(?:/|$))""" diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Fix568UnitInspectionTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Fix568UnitInspectionTest.kt new file mode 100644 index 00000000..e4d40362 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Fix568UnitInspectionTest.kt @@ -0,0 +1,256 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest + +/** + * Inspection-level checks for the unit / service / cgroup validators fixed in #568. Every verdict was confirmed with + * steve-local/systemd-tools/bin/sd-check at 0f0682b773, one value per file, except the [Install] ones, which + * systemctl (src/shared/install.c) reads and the fuzzer ignores. + */ +class Fix568UnitInspectionTest : AbstractUnitFileTest() { + + private fun highlightTexts(fileName: String, file: String): List { + setupFileInEditor(fileName, file) + enableInspection(InvalidValueInspection::class.java) + return myFixture.doHighlighting().map { it.text } + } + + fun testEmergencyActionsAreAccepted() { + // language="unit file (systemd)" + val file = """ + [Unit] + FailureAction=halt + FailureAction=halt-force + FailureAction=halt-immediate + FailureAction=kexec + FailureAction=kexec-force + FailureAction=soft-reboot + FailureAction=soft-reboot-force + JobTimeoutAction=halt-force + SuccessAction=kexec + [Service] + FailureAction=halt + """.trimIndent() + + assertEmpty(highlightTexts("file.service", file)) + } + + fun testUnitDependencyContinuationLinesAndSpecifiers() { + // language="unit file (systemd)" + val file = """ + [Unit] + Before=before-me.socket \ + before-me.service + After=some.service.service \ + some@instance.service \ + some@%i.service + OnFailure=notify-failed-backup@%n + Wants=a@.service a@b@.service x:y\z.service -.mount + Requires=foo@%j-%p.service %N.target + """.trimIndent() + + assertEmpty(highlightTexts("file.service", file)) + } + + fun testUnitDependencyRejectsWhatSystemdRejects() { + // language="unit file (systemd)" + val file = """ + [Unit] + Conflicts=@conflicts@.service + Wants=foo + Wants=foo.bar + Wants=.service + Wants="b.service" + After=a%T.service + After=a@b.mount + After=a.SERVICE + """.trimIndent() + + val texts = highlightTexts("file.service", file) + assertSameElements(texts, "@", "foo", "bar", ".service", "service\"", "%T", "a@b.mount", "SERVICE") + } + + fun testUnitDependencyLengthLimit() { + val ok = "a".repeat(246) + ".service" + val tooLong = "a".repeat(250) + ".service" + val file = "[Unit]\nWants=$ok\nWants=$tooLong\n" + + assertSameElements(highlightTexts("file.service", file), tooLong) + } + + fun testInstallKeysStillNeedValidUnitNames() { + // Also= is refused while systemctl parses the file; WantedBy=/RequiredBy= when the unit is enabled. Instances of + // types that cannot be templated are only refused by PID 1, so they are not flagged here. + // language="unit file (systemd)" + val file = """ + [Install] + WantedBy=some@instance.service some.socket some.invalid + RequiredBy=some.service foo@bar.mount + Also=test3 + Also=a@%i.service + """.trimIndent() + + assertSameElements(highlightTexts("file.service", file), "invalid", "test3") + } + + fun testModeAcceptsAnyOctalNumberUpTo7777() { + // language="unit file (systemd)" + val file = """ + [Service] + UMask=71 + UMask=0 + UMask=7777 + UMask=00022 + UMask=10000 + UMask=8 + UMask=+22 + UMask=0x1 + """.trimIndent() + + assertSameElements(highlightTexts("file.service", file), "10000", "8", "+22", "0x1") + } + + fun testTimeSpanOverflow() { + // language="unit file (systemd)" + val file = """ + [Network] + IPv6RetransmissionTimeSec=18446744073708 + IPv6RetransmissionTimeSec=18446744073709551 us + IPv6RetransmissionTimeSec=9223372036854775807us + IPv6RetransmissionTimeSec=584541y + IPv6RetransmissionTimeSec=999999999999999999 + IPv6RetransmissionTimeSec=18446744073709 + IPv6RetransmissionTimeSec=9223372036854775808us + IPv6RetransmissionTimeSec=584542y + """.trimIndent() + + assertSize(4, highlightTexts("file.network", file)) + } + + fun testCoalesceSecMustFitInAUint32() { + // language="unit file (systemd)" + val file = """ + [Link] + RxCoalesceSec=4294s + RxCoalesceSec=4294.967295s + RxCoalesceSec=71min + StatisticsBlockCoalesceSec=1us + RxCoalesceSec=0 + RxCoalesceSec=infinity + TxCoalesceSec=2hour + RxCoalesceSec=4295s + RxCoalesceSec=4294.967296s + RxCoalesceSec=72min + StatisticsBlockCoalesceSec=0 + StatisticsBlockCoalesceSec=0.5us + CoalescePacketRateSampleIntervalSec=0 + """.trimIndent() + + assertSize(8, highlightTexts("file.link", file)) + } + + fun testCgroupSocketBindAndDisableControllersInAFile() { + // language="unit file (systemd)" + val file = """ + [Service] + SocketBindAllow=ipv4:tcp: 12345 + SocketBindAllow=TCP + DisableControllers=blkio + DisableControllers=cpu invalid memory + NUMAPolicy=preferred + ExecSearchPath=/tmp:/bin:/usr/bin:%V + [Unit] + Description=Ends with a percent % + RequiresMountsFor=/tmp /var/log %h + WantsMountsFor=%f + """.trimIndent() + + assertEmpty(highlightTexts("file.service", file)) + } + + fun testMountWhere() { + // language="unit file (systemd)" + val file = """ + [Mount] + Where=/hoge/with space + Where=/ + Where=/a/../b + Where=a + """.trimIndent() + + assertSize(2, highlightTexts("file.mount", file)) + } + + fun testSocketProtocolAndBindIpv6Only() { + // language="unit file (systemd)" + val file = """ + [Socket] + SocketProtocol=mptcp + SocketProtocol=262 + BindIPv6Only=false + BindIPv6Only=yes + SocketProtocol=tcp + BindIPv6Only=Both + """.trimIndent() + + assertSameElements(highlightTexts("file.socket", file), "tcp", "Both") + } + + fun testTimeSpanNumbersAsParseTimeReadsThem() { + // strtoll() takes a leading `+`; `.5` has no integer part; a unit-less number must be followed by whitespace + // (time-util.c#L1234-L1255). All verdicts confirmed with sd-check on RestartSec=. + // language="unit file (systemd)" + val file = """ + [Service] + RestartSec=+5 + RestartSec=+5.5 + RestartSec=+5min +3s + RestartSec=.5 + RestartSec=12.34 .56 + RestartSec=12.34s.56 + RestartSec=5 6 + RestartSec=12.34.56 + RestartSec=1.5.5s + RestartSec=+.5 + RestartSec=5. + RestartSec=++5 + """.trimIndent() + + assertSize(5, highlightTexts("file.service", file)) + } + + fun testCoalesceSecUsesParseTime() { + // `+5` and `.5` are fine for parse_sec(); `999999999999999999` overflows parse_sec() itself. + // language="unit file (systemd)" + val file = """ + [Link] + RxCoalesceSec=+5 + RxCoalesceSec=.5 + RxCoalesceSec=4294967295us + RxCoalesceSec=999999999999999999 + RxCoalesceSec=9223372036854775808 + RxCoalesceSec=12.34.56 + RxCoalesceSec=4294967296us + """.trimIndent() + + assertSize(4, highlightTexts("file.link", file)) + } + + fun testSocketBindPortRangeMustNotEndBelowItsStart() { + // parse_ip_port_range() refuses h < l (parse-util.c#L753-L754). + // language="unit file (systemd)" + val file = """ + [Service] + SocketBindDeny=0--0 + SocketBindDeny=ipv6:80-80 + SocketBindDeny=0x10-16 + SocketBindDeny=5-1 + SocketBindDeny=ipv4:tcp:90-80 + SocketBindDeny=0x10-15 + SocketBindAllow=tcp:2-1 + SocketBindAllow=1--0 + """.trimIndent() + + assertSize(5, highlightTexts("file.service", file)) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForCGroupSocketBind.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForCGroupSocketBind.kt index 042f5304..99b05d67 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForCGroupSocketBind.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForCGroupSocketBind.kt @@ -68,7 +68,8 @@ class InvalidValueInspectionForCGroupSocketBindOptionValue : AbstractUnitFileTes // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("SocketBindAllow's value does not match the expected format.", info!!.description) + // Both engines read "ipv5" as a misspelt address family. + assertStringContains("SocketBindAllow's value is correctly formatted but seems invalid.", info!!.description) TestCase.assertEquals("ipv5", info.text) } @@ -90,7 +91,7 @@ class InvalidValueInspectionForCGroupSocketBindOptionValue : AbstractUnitFileTes // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("SocketBindAllow's value does not match the expected format.", info!!.description) + assertStringContains("SocketBindAllow's value is correctly formatted but seems invalid.", info!!.description) TestCase.assertEquals("icmp", info.text) } @@ -134,9 +135,8 @@ class InvalidValueInspectionForCGroupSocketBindOptionValue : AbstractUnitFileTes assertSize(1, highlights) val info = highlights[0] assertStringContains("SocketBindAllow's value does not match the expected format.", info!!.description) - // The two engines localize this differently: the original highlights from after "ipv6"; the - // list-of-successes engine consumes "ipv6:" before getting stuck, so it highlights from there. - TestCase.assertEquals(if (GrammarOptionValue.FORCE_PARSE_ENGINE) ":tcp" else "::tcp", info.text) + // Both engines consume "ipv6:" before getting stuck on the empty field. + TestCase.assertEquals(":tcp", info.text) } fun testWeakWarningWhenInvalidPortRangeSpecified() { @@ -156,9 +156,13 @@ class InvalidValueInspectionForCGroupSocketBindOptionValue : AbstractUnitFileTes // Verification assertSize(1, highlights) val info = highlights[0] - assertStringContains("SocketBindAllow's value is correctly formatted but seems invalid.", info!!.description) - // The original engine gives up at the first "-"; the list-of-successes engine parses the whole - // range form and points at the out-of-range port "-21485". - TestCase.assertEquals(if (GrammarOptionValue.FORCE_PARSE_ENGINE) "-21485" else "-", info.text) + // Both engines point at the upper bound "-21485" (safe_atou refuses a negative number); the original engine + // reports it as malformed, the list-of-successes engine as out of range. + if (GrammarOptionValue.FORCE_PARSE_ENGINE) { + assertStringContains("SocketBindAllow's value is correctly formatted but seems invalid.", info!!.description) + } else { + assertStringContains("SocketBindAllow's value does not match the expected format.", info!!.description) + } + TestCase.assertEquals("-21485", info!!.text) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionTest.kt index a12ee5a2..35860cf9 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionTest.kt @@ -268,7 +268,6 @@ class InvalidValueInspectionTest : AbstractUnitFileTest() { UMask=0A548 UMask=0897 UMask=71777 - UMask=71 """.trimIndent() @@ -281,9 +280,10 @@ class InvalidValueInspectionTest : AbstractUnitFileTest() { // Verification - assertSize(6, highlights) + // UMask=71 used to be expected here, but parse_mode() takes any octal number up to 07777. + assertSize(5, highlights) for (info in highlights) { - assertStringContains("Value is expected to be a 3 or 4 digit octal number not:", info!!.description) + assertStringContains("Value is expected to be an octal number between 0 and 7777 not:", info!!.description) TestCase.assertEquals(HighlightInfoType.WARNING, info.type) val highlightElement = myFixture.file.findElementAt(info.getStartOffset()) TestCase.assertNotNull(highlightElement) diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCoalesceSecOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCoalesceSecOptionValueTest.kt index cdcec364..bf8226af 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCoalesceSecOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCoalesceSecOptionValueTest.kt @@ -23,8 +23,6 @@ class ConfigParseCoalesceSecOptionValueTest : AbstractUnitFileTest() { CoalescePacketRateSampleIntervalSec=1 RxCoalesceSec=1.5s TxCoalesceSec=1min 30sec - RxCoalesceSec=infinity - TxCoalesceSec=2hour """.trimIndent() setupFileInEditor("file.link", file) @@ -40,10 +38,14 @@ class ConfigParseCoalesceSecOptionValueTest : AbstractUnitFileTest() { RxCoalesceSec=-1 TxCoalesceSec=abc TxCoalesceIrqSec=5z + RxCoalesceSec=infinity + TxCoalesceSec=2hour """.trimIndent() + // infinity and 2hour used to be listed as valid, but the result must fit in a uint32 of microseconds + // ("Too large RxCoalesceSec= value"). setupFileInEditor("file.link", file) enableInspection(InvalidValueInspection::class.java) - assertSize(3, myFixture.doHighlighting()) + assertSize(5, myFixture.doHighlighting()) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseDhcpSocketPriorityOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseDhcpSocketPriorityOptionValueTest.kt index 35dc952b..923686f3 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseDhcpSocketPriorityOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseDhcpSocketPriorityOptionValueTest.kt @@ -14,9 +14,7 @@ class ConfigParseDhcpSocketPriorityOptionValueTest : AbstractUnitFileTest() { [DHCPv4] SocketPriority=0 SocketPriority=6 - SocketPriority=-1 - SocketPriority=2147483647 - SocketPriority=-2147483648 + SocketPriority=15 """.trimIndent() // Execute SUT @@ -39,6 +37,10 @@ class ConfigParseDhcpSocketPriorityOptionValueTest : AbstractUnitFileTest() { SocketPriority=abc SocketPriority=1.5 SocketPriority=2147483648 + SocketPriority=-1 + SocketPriority=16 + SocketPriority=2147483647 + SocketPriority=-2147483648 """.trimIndent() // Execute SUT @@ -47,6 +49,8 @@ class ConfigParseDhcpSocketPriorityOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(5, highlights) + // -1, 2147483647 and -2147483648 used to be listed as valid, but systemd refuses anything outside 0…15 + // ("Invalid socket priority, must be in 0…15"). + assertSize(9, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseDisableControllersOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseDisableControllersOptionValueTest.kt index 37c58956..f6ea3912 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseDisableControllersOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseDisableControllersOptionValueTest.kt @@ -99,7 +99,7 @@ class ConfigParseDisableControllersOptionValueTest : AbstractUnitFileTest() { } @Test - fun testMixedValidAndInvalidControllers() { + fun testUnknownControllerNextToKnownOnesIsSkipped() { // Fixture Setup // language="unit file (systemd)" val file = """ @@ -112,8 +112,9 @@ class ConfigParseDisableControllersOptionValueTest : AbstractUnitFileTest() { enableInspection(InvalidValueInspection::class.java) val highlights = myFixture.doHighlighting() - // Verification - assertSize(1, highlights) + // Verification: this used to expect one warning, but cg_mask_from_string() skips unknown words and only + // refuses a value with no known controller at all. + assertSize(0, highlights) } @Test diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseUnitStringPrintfOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseUnitStringPrintfOptionValueTest.kt index 569b4f15..2688d57b 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseUnitStringPrintfOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseUnitStringPrintfOptionValueTest.kt @@ -15,6 +15,8 @@ class ConfigParseUnitStringPrintfOptionValueTest : AbstractUnitFileTest() { Description=Service for %i on %H Description=100%% complete Description=café and 日本 + Description=Ends with a percent % + Description=a %-x """.trimIndent() setupFileInEditor("file.service", file) @@ -26,12 +28,13 @@ class ConfigParseUnitStringPrintfOptionValueTest : AbstractUnitFileTest() { @Test fun testInvalidValues() { - // Unknown specifier (%e) and a trailing bare '%' are rejected by unit_full_printf. + // Unknown specifiers (%e, %1) are rejected by unit_full_printf. A trailing bare '%' used to be listed here, but + // specifier_printf copies a '%' that is not followed by a letter or digit verbatim. // language=unit file (systemd) val file = """ [Unit] Description=Bad %e specifier - Description=Ends with a percent % + Description=Bad %1 specifier """.trimIndent() setupFileInEditor("file.service", file) diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/Fix568UnitGrammarVerdictsTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/Fix568UnitGrammarVerdictsTest.kt new file mode 100644 index 00000000..7fc5b46e --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/Fix568UnitGrammarVerdictsTest.kt @@ -0,0 +1,148 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.inspections.ai + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.CgroupSocketBindOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.EmergencyActionOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.NumaPolicyOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.PathOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SocketBindOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SocketProtocolOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai.ConfigParseColonSeparatedPathsOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai.ConfigParseDhcpSocketPriorityOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai.ConfigParseDisableControllersOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai.ConfigParseTriggerUnitOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai.ConfigParseUnitMountsForOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai.ConfigParseUnitSliceOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai.ConfigParseUnitStringPrintfOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.GrammarEngineVerdict +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.GrammarOptionValue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +/** + * Verdicts for the unit / service / cgroup validators fixed in #568, checked on the grammar under both engines. + * Every value was confirmed with steve-local/systemd-tools/bin/sd-check at 0f0682b773, one value per file. + */ +class Fix568UnitGrammarVerdictsTest { + + private fun accepted(optionValue: GrammarOptionValue, vararg values: String) { + for (value in values) { + assertEquals("classic engine on <$value>", GrammarEngineVerdict.VALID, GrammarEngineVerdict.classic(optionValue.combinator, value)) + assertEquals("parse engine on <$value>", GrammarEngineVerdict.VALID, GrammarEngineVerdict.parseEngine(optionValue.combinator, value)) + } + } + + private fun rejected(optionValue: GrammarOptionValue, vararg values: String) { + for (value in values) { + assertNotEquals("classic engine on <$value>", GrammarEngineVerdict.VALID, GrammarEngineVerdict.classic(optionValue.combinator, value)) + assertNotEquals("parse engine on <$value>", GrammarEngineVerdict.VALID, GrammarEngineVerdict.parseEngine(optionValue.combinator, value)) + } + } + + @Test + fun testEmergencyActionTakesTheWholeSystemTable() { + accepted( + EmergencyActionOptionValue(), + "none", "exit", "exit-force", "reboot", "reboot-force", "reboot-immediate", "poweroff", "poweroff-force", + "poweroff-immediate", "soft-reboot", "soft-reboot-force", "kexec", "kexec-force", "halt", "halt-force", "halt-immediate" + ) + rejected(EmergencyActionOptionValue(), "Halt", "halt-now", "foo", "reboot force") + } + + @Test + fun testNumaPolicyTakesTheWholeMpolTable() { + accepted(NumaPolicyOptionValue(), "default", "preferred", "bind", "interleave", "local", "preferred-many", "weighted-interleave") + rejected(NumaPolicyOptionValue(), "Bind", "1", "prefer") + } + + @Test + fun testSocketProtocol() { + accepted(SocketProtocolOptionValue(), "udplite", "sctp", "mptcp", "MPTCP", "UdpLite", "136", "132", "262", "0x84", "+136") + // 0206 is octal 134; tcp and 6 are real protocols that SocketProtocol= does not allow. + rejected(SocketProtocolOptionValue(), "0206", "tcp", "6", "foo") + } + + @Test + fun testBindIpv6OnlyTakesBooleans() { + accepted(SocketBindOptionValue(), "default", "both", "ipv6-only", "false", "TRUE", "yes", "1", "off") + rejected(SocketBindOptionValue(), "Both", "x", "ipv6") + } + + @Test + fun testDisableControllersNeedsOnlyOneKnownController() { + accepted( + ConfigParseDisableControllersOptionValue(), + "blkio", "cpuacct", "devices", "cpu invalid memory", "foo cpu", "cpu foo", "foo bar io", "bpf-bind-network-interface" + ) + rejected(ConfigParseDisableControllersOptionValue(), "invalid", "foo bar", "CPU", "'cpu'", "cpux", "foo cpux") + } + + @Test + fun testUnitPathPrintf() { + accepted(PathOptionValue(), "/", "/hoge/with space", "/a/./b", "//a", "%h/x", "%i", "/a\\b", "/a/b/", "/a%", "/a/..b") + rejected(PathOptionValue(), "/a/../b", "/a/..", "a", "/a/%Z", "%%") + } + + @Test + fun testDotDotRightAfterTheRootIsSimplifiedAway() { + // path_simplify_full() drops `..` components at the start of an absolute path (sd-check accepts all of these). + accepted(PathOptionValue(), "/..", "/../a", "//../../a", "/./../a", "/.././..", "/../", "/../%t", "/.. /x") + rejected(PathOptionValue(), "/../a/..", "/a/../..", "%t/..", "/../.../..") + accepted(ConfigParseUnitMountsForOptionValue(), "/..", "/../a /b", "//./../a") + rejected(ConfigParseUnitMountsForOptionValue(), "/b /../a/..", "/../a/.. /b") + accepted(ConfigParseColonSeparatedPathsOptionValue(), "/..", "/..:/a", "/a:/..", "/a:/../b", "/./../a") + rejected(ConfigParseColonSeparatedPathsOptionValue(), "/../a:/b/..", "/../a/..:/b", "/a:/../a/..") + } + + @Test + fun testUnitMountsFor() { + accepted(ConfigParseUnitMountsForOptionValue(), "%f", "/tmp /var/log %h", "%t/x", "/a /b") + rejected(ConfigParseUnitMountsForOptionValue(), "%Z", "a%h", "%%h", "/a/%Z", "/a/..", "/a/.. /b", "/a /b/..") + } + + @Test + fun testColonSeparatedPaths() { + accepted(ConfigParseColonSeparatedPathsOptionValue(), "/tmp:/bin:/usr/bin:%V", "/a b:/c", "%h:/a", "/a") + rejected(ConfigParseColonSeparatedPathsOptionValue(), "/a::/b", "/a:", ":/a", "/a/..:/b", "/a:%Z", "/a:b") + } + + @Test + fun testTriggerUnit() { + accepted( + ConfigParseTriggerUnitOptionValue(), + "brltty@-etc-brltty_%i.conf.service", "a%i.service", "x@.service", "foo.service", "a.service.service", "a@b.timer" + ) + rejected(ConfigParseTriggerUnitOptionValue(), "a%T.service", "@x.service", "a.foo", "a@b.mount", "a@.slice", "foo", "a b.service") + } + + @Test + fun testSliceCannotBeAnInstance() { + accepted(ConfigParseUnitSliceOptionValue(), "a.slice", "-.slice", "a-b.slice", "a\\x2d.slice", "%p.slice") + rejected(ConfigParseUnitSliceOptionValue(), "some@instance.slice", "a@.slice", "foo.service", "foo", "a b.slice", "%T.slice") + } + + @Test + fun testStringPrintfCopiesAPercentThatIsNotASpecifier() { + accepted(ConfigParseUnitStringPrintfOptionValue(), "Ends with a percent %", "a %-x", "%%", "% x", "100%") + rejected(ConfigParseUnitStringPrintfOptionValue(), "a %Z", "%1") + } + + @Test + fun testCgroupSocketBind() { + accepted( + CgroupSocketBindOptionValue(), + "ipv4:tcp: 12345", "TCP", "Udp", "any", "ipv4:any", "tcp:any", "ipv4:tcp:any", "0", "0-0", "0--0", "1-65535", + "ipv6:80-90", "1- 2", "0x50", "6", "ipv6:10000-65535", "ipv4" + ) + rejected( + CgroupSocketBindOptionValue(), + "IPv4", "65536", "ipv4:", ":80", "ipv4:tcp:80:1", "tcp:ipv4", "80:tcp", "ipv4:ipv6", "1-", "-1", "any:80", "ipv4:6:80" + ) + } + + @Test + fun testDhcpSocketPriorityIsZeroToFifteen() { + accepted(ConfigParseDhcpSocketPriorityOptionValue(), "0", "6", "15", "0xf") + rejected(ConfigParseDhcpSocketPriorityOptionValue(), "-1", "16", "2147483647", "-2147483648", "abc") + } +} From 3c79ef29146db302f2e6f3117a5906bf8117f8ba Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sat, 24 Oct 2026 15:50:52 -0700 Subject: [PATCH 3/4] fix: align networkd, netdev and link validators with systemd's parsers (#568) Fixes ERSPANHardwareId= and other integer settings that rejected hex or signed spellings systemd accepts, FooOverUDP Protocol=, Xfrm InterfaceId=, DHCP SocketPriority= bounds and related network-file settings. Part of #568. Verdicts were checked against systemd's own parsers built at the pinned commit 0f0682b773 (condition values against src/shared/condition.c, since systemd only evaluates them at unit start). Values systemd accepts at parse time but rejects only later are accepted (see #565). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../optionvalues/NetworkAddressOptionValue.kt | 60 +- .../optionvalues/SimpleGrammarOptionValues.kt | 20 +- .../optionvalues/UIntOptionValues.kt | 65 +- .../ai/ConfigParse6rdPrefixOptionValue.kt | 46 +- .../ai/ConfigParseAdActorSystemOptionValue.kt | 69 +- ...igParseAddressGenerationTypeOptionValue.kt | 17 +- ...seAddressSectionAddressScopeOptionValue.kt | 48 +- ...nfigParseBridgeFdbMaxLearnedOptionValue.kt | 39 +- .../ai/ConfigParseCakeFwmarkOptionValue.kt | 38 +- .../ai/ConfigParseCakeRttOptionValue.kt | 25 +- .../ai/ConfigParseCanTimeQuantaOptionValue.kt | 91 ++- .../ai/ConfigParseCodelU32OptionValue.kt | 39 +- ...ConfigParseDhcp6PdPrefixHintOptionValue.kt | 46 +- .../ai/ConfigParseDnsOptionValue.kt | 55 +- .../ai/ConfigParseErspanHwidOptionValue.kt | 40 +- .../ConfigParseFairQueueingSizeOptionValue.kt | 35 +- .../ai/ConfigParseInAddrNonNullOptionValue.kt | 28 +- .../ai/ConfigParseIpProtocolOptionValue.kt | 53 +- .../ai/ConfigParseNetdevHwAddrOptionValue.kt | 24 +- .../ai/ConfigParsePfifoSizeOptionValue.kt | 38 +- .../ai/ConfigParsePrefixOptionValue.kt | 46 +- .../ai/ConfigParseQfqWeightOptionValue.kt | 36 +- ...igParseRoutePrefixPreferenceOptionValue.kt | 28 +- ...eRoutingPolicyRuleIpProtocolOptionValue.kt | 5 +- .../ai/RouteAddressNextHopOptionValues.kt | 4 +- .../InvalidValueForNetworkAddressesTest.kt | 9 +- ...ueInspectionForSimpleGrammarOptionValue.kt | 7 +- .../ai/ConfigParse6rdPrefixOptionValueTest.kt | 9 +- ...ConfigParseAdActorSystemOptionValueTest.kt | 10 +- ...dressSectionAddressScopeOptionValueTest.kt | 7 +- ...ParseBridgeFdbMaxLearnedOptionValueTest.kt | 5 +- .../ai/ConfigParseCakeRttOptionValueTest.kt | 8 +- ...ConfigParseCanTimeQuantaOptionValueTest.kt | 6 +- .../ai/ConfigParseCodelU32OptionValueTest.kt | 9 +- ...igParseDhcp6PdPrefixHintOptionValueTest.kt | 6 +- ...figParseFairQueueingSizeOptionValueTest.kt | 12 +- ...ConfigParseInAddrNonNullOptionValueTest.kt | 6 +- .../ai/ConfigParsePfifoSizeOptionValueTest.kt | 5 +- .../ai/ConfigParsePrefixOptionValueTest.kt | 6 +- .../ai/ConfigParseQfqWeightOptionValueTest.kt | 5 +- ...rseRoutePrefixPreferenceOptionValueTest.kt | 7 +- .../ai/Issue568NetworkValidatorsTest.kt | 659 ++++++++++++++++++ 42 files changed, 1471 insertions(+), 300 deletions(-) create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/Issue568NetworkValidatorsTest.kt diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/NetworkAddressOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/NetworkAddressOptionValue.kt index 13a22825..d9b7e26d 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/NetworkAddressOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/NetworkAddressOptionValue.kt @@ -7,7 +7,7 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.GrammarOptionValue import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.IPV4_ADDR import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.IPV6_ADDR -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.IntegerTerminal +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 @@ -15,8 +15,9 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * [Address] Address= (and the [Network] Address= shorthand) in a .network file. * * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#Address= - * parser https://github.com/systemd/systemd/blob/a8e93919c3/src/network/networkd-address.c config_parse_address - * https://github.com/systemd/systemd/blob/a8e93919c3/src/shared/conf-parser.c config_parse_in_addr_prefix + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-address.c#L2131-L2172 config_parse_address + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L2114-L2149 config_parse_in_addr_prefix + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/in-addr-util.c#L779-L797 in_addr_parse_prefixlen (safe_atou8) */ /** @@ -24,27 +25,60 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * * config_parse_address delegates to config_parse_in_addr_prefix asking for PREFIXLEN_REFUSE. When the * prefix length is missing that returns -ENOANO, and the caller retries with - * in_addr_prefix_from_string_auto and logs: + * in_addr_prefix_from_string_auto and only logs: * * > Address=… is specified without prefix length. Assuming the prefix length is N. * > Please specify the prefix length explicitly. * - * Requiring the prefix on IPv6 therefore matches systemd's own advice rather than over-reaching, and - * that behaviour is kept. What is *not* faithful is bounding the prefix: in_addr_prefix_from_string - * only rejects a length above the family's address width, so the full 0…32 and 0…128 ranges are - * legal. The previous grammar demanded /8…/32 and /64…/128, which flagged `Address=2600::1/0` — a - * line out of systemd's own test/test-network/conf/25-veth-peer.network — and `Address=…/7`. + * That is advice, not a rejection: the address is used with the full-width prefix. So a bare address is + * accepted for both families (`Address=2001:db8::1` used to be flagged for IPv6; issue #568). * - * `Peer=` is the same ConfigSectionParser slot but takes the fully faithful grammar, since it has no - * comparable history; see ConfigParseAddressSectionPeerOptionValue. + * The prefix length goes through in_addr_parse_prefixlen → safe_atou8 (base 0, so `/0x40` works, and + * whitespace after the slash reaches it: `/ 8`), and is refused only when wider than the address, so the + * full 0…32 and 0…128 ranges are legal (systemd's own test/test-network/conf/25-veth-peer.network uses + * `Address=2600::1/0`). All confirmed with sd-check at 0f0682b773. + * + * `Peer=` is the same ConfigSectionParser slot; see ConfigParseAddressSectionPeerOptionValue. */ class NetworkAddressOptionValue() : GrammarOptionValue("config_parse_address_section", GRAMMAR) { companion object { val GRAMMAR = SequenceCombinator( AlternativeCombinator( - SequenceCombinator(IPV6_ADDR, CIDR_SEPARATOR, IntegerTerminal(0, 129)), - SequenceCombinator(IPV4_ADDR, ZeroOrOne(SequenceCombinator(CIDR_SEPARATOR, IntegerTerminal(0, 33)))), + SequenceCombinator( + IPV6_ADDR, + ZeroOrOne( + SequenceCombinator( + CIDR_SEPARATOR, + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 128L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + ), + ), + ), + SequenceCombinator( + IPV4_ADDR, + ZeroOrOne( + SequenceCombinator( + CIDR_SEPARATOR, + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 32L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + ), + ), + ), ), EOF() ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/SimpleGrammarOptionValues.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/SimpleGrammarOptionValues.kt index e400331e..813f81fc 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/SimpleGrammarOptionValues.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/SimpleGrammarOptionValues.kt @@ -56,7 +56,25 @@ open class SimpleGrammarOptionValues(validatorName: String, grammar: Combinator) ) val validators = mapOf( - Validator("config_parse_ip_port", "0") to SimpleGrammarOptionValues("config_parse_ip_port", SequenceCombinator(IntegerTerminal(0, 65536 ), EOF())), + // config_parse_ip_port → parse_ip_port: safe_atou16_full(…, SAFE_ATO_REFUSE_LEADING_WHITESPACE), then 0 is + // refused (sd-check: `FooOverUDP.Port=0` is "Invalid argument"; `0x10` is fine). + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L1704-L1735 + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L718-L734 + Validator("config_parse_ip_port", "0") to SimpleGrammarOptionValues( + "config_parse_ip_port", + SequenceCombinator( + SafeAtoTerminal( + signed = false, + base = 0, + flags = setOf(SafeAtoTerminal.Flag.REFUSE_LEADING_WHITESPACE), + minInclusive = 1L, + maxInclusive = 65_535L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + EOF(), + ), + ), Validator("config_parse_coalesce_u32", "0") to SimpleGrammarOptionValues("config_parse_coalesce_u32", SequenceCombinator( AlternativeCombinator( diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/UIntOptionValues.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/UIntOptionValues.kt index be22d8d8..7adfa24b 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/UIntOptionValues.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/UIntOptionValues.kt @@ -3,17 +3,70 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* - - +/* + * The generic config_parse_uint32 / config_parse_uint16 / config_parse_uint8 table parsers. + * + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L956-L966 DEFINE_PARSER(uint8/uint16/uint32, …, safe_atou8/16/32) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L429-L478 safe_atou_full + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L583-L613 safe_atou8_full / safe_atou16_full + * + * Each is a safe_ato* call on the whole value (base 0), so `Xfrm.InterfaceId=0x98`, `077`, `0b101` and + * `+5` are accepted (sd-check at 0f0682b773), and anything above the type maximum is -ERANGE. See + * [SafeAtoTerminal] for the exact syntax. + * + * Lenient on purpose: `[Xfrm] InterfaceId=0` is accepted by the parser and only refused later by the + * netdev verify step ("Xfrm interface ID cannot be zero"), which is not a parser rejection (#565). + */ class UIntOptionValues(validatorName: String, grammar: Combinator) : GrammarOptionValue(validatorName, grammar) { companion object { val validators = mapOf( - Validator("config_parse_uint32", "0") to UIntOptionValues("config_parse_uint32", SequenceCombinator(IntegerTerminal(0, 4_294_967_296 ), EOF())), - Validator("config_parse_uint16", "0") to UIntOptionValues("config_parse_uint16", SequenceCombinator(IntegerTerminal(0, 65536), EOF())), - Validator("config_parse_uint8", "0") to UIntOptionValues("config_parse_uint8", SequenceCombinator(IntegerTerminal(0, 256), EOF())), + Validator("config_parse_uint32", "0") to UIntOptionValues( + "config_parse_uint32", + SequenceCombinator( + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 4_294_967_295L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + EOF(), + ), + ), + Validator("config_parse_uint16", "0") to UIntOptionValues( + "config_parse_uint16", + SequenceCombinator( + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 65_535L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + EOF(), + ), + ), + Validator("config_parse_uint8", "0") to UIntOptionValues( + "config_parse_uint8", + SequenceCombinator( + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 255L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + EOF(), + ), + ), ) } } - diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParse6rdPrefixOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParse6rdPrefixOptionValue.kt index ff1185fe..594fce27 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParse6rdPrefixOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParse6rdPrefixOptionValue.kt @@ -1,23 +1,43 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * [Tunnel] IPv6RapidDeploymentPrefix= (.netdev) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.netdev.html#IPv6RapidDeploymentPrefix= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/netdev/tunnel.c#L979-L1014 config_parse_6rd_prefix + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/in-addr-util.c#L799-L843 in_addr_prefix_from_string (no slash: length 128) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/in-addr-util.c#L779-L797 in_addr_parse_prefixlen (safe_atou8, at most 128) + */ + /** - * Validator for Tunnel.IPv6RapidDeploymentPrefix - * C Function: config_parse_6rd_prefix(0) - * Used by Options: Tunnel.IPv6RapidDeploymentPrefix - * - * Validates IPv6 addresses with a non-zero prefix length for 6rd (IPv6 Rapid Deployment). - * Format: IPv6_address/prefix_length where prefix_length must be 1-128. + * Validator for [Tunnel] IPv6RapidDeploymentPrefix= (.netdev). + * + * C Function: config_parse_6rd_prefix(0). in_addr_prefix_from_string(…, AF_INET6, …) takes an IPv6 + * address with an optional `/LEN`; without one the length is 128, so `2001:db8::` and `2001:db8::1` are + * accepted (sd-check at 0f0682b773). LEN is read with safe_atou8 (base 0, `/0x10` works) and may be at + * most 128; config_parse_6rd_prefix then refuses a length of 0. */ class ConfigParse6rdPrefixOptionValue : SimpleGrammarOptionValues( - "config_parse_6rd_prefix", - SequenceCombinator( - IPV6_ADDR, + "config_parse_6rd_prefix", + SequenceCombinator( + IPV6_ADDR, + ZeroOrOne( + SequenceCombinator( CIDR_SEPARATOR, - IntegerTerminal(1, 129), // Non-zero prefix length: 1-128 inclusive (129 is exclusive upper bound) - EOF() - ) + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 1L, + maxInclusive = 128L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + ), + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAdActorSystemOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAdActorSystemOptionValue.kt index 9611a6d2..515e5577 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAdActorSystemOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAdActorSystemOptionValue.kt @@ -1,41 +1,46 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * [Bond] AdActorSystem= (.netdev) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.netdev.html#AdActorSystem= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/netdev/bond.c#L368-L405 config_parse_ad_actor_system + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/ether-addr-util.c#L196-L280 parse_hw_addr_full (via parse_ether_addr) + */ + /** - * Validator for Bond.AdActorSystem - * C Function: config_parse_ad_actor_system(0) - * Used by Options: Bond.AdActorSystem - * - * Validates 802.3ad system MAC addresses in the format XX:XX:XX:XX:XX:XX - * where each XX is a hexadecimal byte (00-FF). - * - * Note: The C implementation also validates that the address is not null - * and not multicast, but those semantic checks cannot be implemented in - * the grammar layer. + * The six bytes of a [MAC_ADDRESS] spelling: colon- or hyphen-separated 1-byte fields, or three + * dot-separated 2-byte fields (`0223.4567.89ab`, where a short field such as `123` is 0x0123). */ -class ConfigParseAdActorSystemOptionValue : SimpleGrammarOptionValues( - "config_parse_ad_actor_system", - SequenceCombinator( - MAC_ADDRESS, - EOF() - ) -) { - companion object { - // MAC address octet: 2 hexadecimal digits - private val MAC_OCTET = RegexTerminal("[0-9a-fA-F]{2}", "[0-9a-fA-F]{2}") - private val COLON = LiteralChoiceTerminal(":") - - // MAC address format: XX:XX:XX:XX:XX:XX - val MAC_ADDRESS = SequenceCombinator( - MAC_OCTET, COLON, - MAC_OCTET, COLON, - MAC_OCTET, COLON, - MAC_OCTET, COLON, - MAC_OCTET, COLON, - MAC_OCTET - ) +private fun macAddressBytes(text: String): IntArray? { + val bytes = when { + '.' in text -> text.split('.').flatMap { word -> + val v = word.toIntOrNull(16) ?: return null + listOf(v shr 8, v and 0xFF) } + else -> text.split(':', '-').map { it.toIntOrNull(16) ?: return null } + } + return if (bytes.size == 6) bytes.toIntArray() else null } + +/** + * Validator for [Bond] AdActorSystem= (.netdev). + * + * C Function: config_parse_ad_actor_system(0). parse_ether_addr() takes a 6-byte MAC in any of the three + * spellings, and the parser then refuses the all-zero address and any multicast address (the low bit of + * the first byte set): "Not an appropriate MAC address …, cannot be null or multicast". So + * `01:23:45:67:89:ab`, `0123.4567.89ab` and `00:00:00:00:00:00` are refused and `02:23:45:67:89:ab` and + * `0223.4567.89ab` are accepted (sd-check at 0f0682b773). + */ +class ConfigParseAdActorSystemOptionValue : SimpleGrammarOptionValues( + "config_parse_ad_actor_system", + SequenceCombinator( + Checked(MAC_ADDRESS) { text -> + macAddressBytes(text)?.let { bytes -> bytes.any { it != 0 } && (bytes[0] and 0x01) == 0 } ?: true + }, + EOF(), + ), +) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAddressGenerationTypeOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAddressGenerationTypeOptionValue.kt index ca4d3e11..42282d81 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAddressGenerationTypeOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAddressGenerationTypeOptionValue.kt @@ -3,12 +3,14 @@ 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.Checked import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.IPV6_ADDR import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.RegexTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ipLiteralBytes /* * The IPv6 address-generation tokens: [Network] IPv6Token=, [IPv6AcceptRA] Token= and @@ -16,6 +18,7 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#IPv6Token= * parser https://github.com/systemd/systemd/blob/a8e93919c3/src/network/networkd-address-generation.c config_parse_address_generation_type + * static https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-address-generation.c#L523-L533 static mode: only the low 64 bits are used and must not be all zero * secret https://github.com/systemd/systemd/blob/a8e93919c3/src/libsystemd/sd-id128/id128-util.c id128_from_string_nonzero * * systemd's own test/test-network/conf/25-ipv6-prefix-veth-token-prefixstable.network doubles as the @@ -34,9 +37,10 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * ADDRESS is always parsed as IPv6, and SECRET_KEY goes through id128_from_string_nonzero, i.e. 32 * hex digits or the dashed UUID spelling, and not all-zero. * - * One divergence: for the static mode systemd also rejects an address whose low 64 bits are zero - * (`static:::`), since only those bits are used. That is a property of the parsed value rather than - * of its shape, so this grammar accepts it. + * For the static mode systemd also rejects an address whose low 64 bits are zero (`static:::`, `::`, + * `static:2001:db8::`: "cannot be the ANY address"), since only those bits are used; `static:::1` and + * `::1:0:0:0` are fine (sd-check at 0f0682b773). prefixstable keeps the high 64 bits instead and has no + * such check, so `prefixstable:::` is accepted. */ class ConfigParseAddressGenerationTypeOptionValue : SimpleGrammarOptionValues( "config_parse_address_generation_type", @@ -48,8 +52,11 @@ class ConfigParseAddressGenerationTypeOptionValue : SimpleGrammarOptionValues( ZeroOrOne(SequenceCombinator(LiteralChoiceTerminal(","), SECRET_KEY)), ), LiteralChoiceTerminal("eui64"), - SequenceCombinator(LiteralChoiceTerminal("static:"), IPV6_ADDR), - IPV6_ADDR, + SequenceCombinator( + LiteralChoiceTerminal("static:"), + Checked(IPV6_ADDR) { text -> ipLiteralBytes(text)?.let { bytes -> bytes.drop(8).any { it.toInt() != 0 } } ?: true }, + ), + Checked(IPV6_ADDR) { text -> ipLiteralBytes(text)?.let { bytes -> bytes.drop(8).any { it.toInt() != 0 } } ?: true }, ), EOF() ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAddressSectionAddressScopeOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAddressSectionAddressScopeOptionValue.kt index e76e8125..eff61775 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAddressSectionAddressScopeOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseAddressSectionAddressScopeOptionValue.kt @@ -3,26 +3,38 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues -/** - * Validator for Address.Scope - * C Function: config_parse_address_section(ADDRESS_SCOPE) - * Used by Options: Address.Scope +/* + * [Address] Scope= (.network) * - * Valid values: - * - "global" - valid everywhere on the network, even through a gateway - * - "link" - only valid on this device, will not traverse a gateway - * - "host" - only valid within the device itself - * - Integer in the range 0-255 + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#Scope= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-address.c#L2230-L2258 config_parse_address_scope + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-route-util.c#L358-L366 route_scope_table, WITH_FALLBACK(…, UINT8_MAX) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/string-table.c#L65-L80 string_table_lookup_from_string_fallback (safe_atou) + */ + +/** + * Validator for [Address] Scope= (.network). * - * Note: IPv4 only - IPv6 scope is automatically assigned by the kernel + * C Function: config_parse_address_section(ADDRESS_SCOPE), i.e. config_parse_address_scope → + * route_scope_from_string(). That is an exact (case-sensitive) lookup in route_scope_table — `global`, + * `site`, `link`, `host`, `nowhere` — with a fallback to safe_atou() (base 0) for a number up to 255. + * `site` and `nowhere` were missing before (sd-check accepts `Scope=site`). */ class ConfigParseAddressSectionAddressScopeOptionValue : SimpleGrammarOptionValues( - "config_parse_address_section", - SequenceCombinator( - AlternativeCombinator( - FlexibleLiteralChoiceTerminal("global", "link", "host"), - IntegerTerminal(0, 256) - ), - EOF() - ) + "config_parse_address_section", + SequenceCombinator( + PreferValidAlternativeCombinator( + FlexibleLiteralChoiceTerminal("global", "site", "link", "host", "nowhere"), + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 255L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBridgeFdbMaxLearnedOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBridgeFdbMaxLearnedOptionValue.kt index 3a483112..fc3c049e 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBridgeFdbMaxLearnedOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseBridgeFdbMaxLearnedOptionValue.kt @@ -1,22 +1,35 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * [Bridge] FDBMaxLearned= (.netdev) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.netdev.html#FDBMaxLearned= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/netdev/bridge.c#L250-L282 config_parse_bridge_fdb_max_learned → config_parse_uint32_bounded(0, UINT32_MAX) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L429-L478 safe_atou_full (safe_atou32 / safe_atou) + */ + /** - * Validator for Bridge.FDBMaxLearned - * C Function: config_parse_bridge_fdb_max_learned(0) - * Used by Options: Bridge.FDBMaxLearned - * - * Validates an unsigned 32-bit integer value representing the maximum number of learned - * Ethernet addresses for the bridge. Valid range is 0 to 4294967295 (UINT32_MAX). - * A value of 0 disables the limit. + * Validator for [Bridge] FDBMaxLearned= (.netdev). + * + * C Function: config_parse_bridge_fdb_max_learned(0). An empty assignment unsets it and is handled + * before this grammar runs. config_parse_uint32_bounded(…, 0, UINT32_MAX, …) reads the value with + * safe_atou() (base 0), so `0x100`, `0o400` and `0b1` are accepted (sd-check at 0f0682b773). */ class ConfigParseBridgeFdbMaxLearnedOptionValue : SimpleGrammarOptionValues( - "config_parse_bridge_fdb_max_learned", - SequenceCombinator( - IntegerTerminal(0, 4294967296), // 0 to UINT32_MAX (4294967295), max is exclusive - EOF() - ) + "config_parse_bridge_fdb_max_learned", + SequenceCombinator( + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 4_294_967_295L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCakeFwmarkOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCakeFwmarkOptionValue.kt index ee41b192..59ca6346 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCakeFwmarkOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCakeFwmarkOptionValue.kt @@ -1,21 +1,35 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * [CAKE] FirewallMark= (.network) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#FirewallMark= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/tc/cake.c#L556-L612 config_parse_cake_fwmark + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L429-L478 safe_atou_full (safe_atou32 / safe_atou) + */ + /** - * Validator for CAKE.FirewallMark - * C Function: config_parse_cake_fwmark(QDISC_KIND_CAKE) - * Used by Options: CAKE.FirewallMark - * - * Validates firewall mark values for CAKE queueing discipline. - * Valid range: 1 to 4294967295 (positive 32-bit unsigned integer) + * Validator for [CAKE] FirewallMark= (.network). + * + * C Function: config_parse_cake_fwmark(QDISC_KIND_CAKE). An empty assignment resets it and is handled + * before this grammar runs. The value is read with safe_atou32() (base 0, so `0xff00` is accepted; + * sd-check at 0f0682b773) and must not be 0. */ class ConfigParseCakeFwmarkOptionValue : SimpleGrammarOptionValues( - "config_parse_cake_fwmark", - SequenceCombinator( - IntegerTerminal(1, 4294967296), // Range 1-4294967295 (max is exclusive) - EOF() - ) + "config_parse_cake_fwmark", + SequenceCombinator( + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 1L, + maxInclusive = 4_294_967_295L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCakeRttOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCakeRttOptionValue.kt index 84598835..f13ccfa1 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCakeRttOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCakeRttOptionValue.kt @@ -2,15 +2,28 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import java.math.BigInteger + +/* + * [CAKE] RTTSec= (.network) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#RTTSec= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/tc/cake.c#L614-L670 config_parse_cake_rtt + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1198-L1291 parse_time / parse_sec + */ /** - * Validator for CAKE.RTTSec (.network). - * C Function: config_parse_cake_rtt(QDISC_KIND_CAKE) + * Validator for [CAKE] RTTSec= (.network). * - * Calls parse_sec, which accepts "infinity", a fractional or integer number - * with any of systemd's time-unit suffixes, and compound forms like "1h 30s". + * C Function: config_parse_cake_rtt(QDISC_KIND_CAKE). An empty assignment resets it and is handled before + * this grammar runs. The value is read with parse_sec() and then must satisfy `0 < t <= UINT32_MAX` + * microseconds (about 71.6 minutes). So `0`, `72min`, `2hour` and `infinity` (USEC_INFINITY) are refused + * ("Invalid 'RTTSec='"), and `71min` and `4294967295us` are accepted (sd-check at 0f0682b773). */ class ConfigParseCakeRttOptionValue : SimpleGrammarOptionValues( - "config_parse_cake_rtt", - SequenceCombinator(TIME_VALUE, EOF()) + "config_parse_cake_rtt", + SequenceCombinator( + Checked(parseSec(maxUsec = UINT32_MAX_USEC)) { text -> evaluateParseSec(text)?.let { it > BigInteger.ZERO } ?: true }, + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCanTimeQuantaOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCanTimeQuantaOptionValue.kt index b914cfc9..7bad3641 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCanTimeQuantaOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCanTimeQuantaOptionValue.kt @@ -2,16 +2,93 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import java.math.BigInteger + +/* + * [CAN] TimeQuantaNSec=, DataTimeQuantaNSec= (.network) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#TimeQuantaNSec= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-can.c#L173-L208 config_parse_can_time_quanta + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1320-L1466 extract_nsec_multiplier / parse_nsec + */ + +// extract_nsec_multiplier()'s table, in table order (the C takes the first suffix the text starts with, +// and so does a regex alternation). A missing suffix means nanoseconds. +private const val NSEC_SUFFIXES = + "seconds|second|sec|s|minutes|minute|min|months|month|M|msec|ms|m|hours|hour|hr|h|days|day|d|" + + "weeks|week|w|years|year|y|usec|us|μs|µs|nsec|ns" + +private val NSEC_ELEMENT = Regex("""\s*\+?([0-9]*)(?:\.([0-9]*))?\s*($NSEC_SUFFIXES)?""") + +private val NSEC_MULTIPLIERS: Map = run { + val sec = 1_000_000_000L + val minute = 60 * sec + val hour = 60 * minute + val day = 24 * hour + mapOf( + "seconds" to sec, "second" to sec, "sec" to sec, "s" to sec, + "minutes" to minute, "minute" to minute, "min" to minute, "m" to minute, + "months" to 2_629_800L * sec, "month" to 2_629_800L * sec, "M" to 2_629_800L * sec, + "msec" to 1_000_000L, "ms" to 1_000_000L, + "hours" to hour, "hour" to hour, "hr" to hour, "h" to hour, + "days" to day, "day" to day, "d" to day, + "weeks" to 7 * day, "week" to 7 * day, "w" to 7 * day, + "years" to 31_557_600L * sec, "year" to 31_557_600L * sec, "y" to 31_557_600L * sec, + "usec" to 1000L, "us" to 1000L, "μs" to 1000L, "µs" to 1000L, + "nsec" to 1L, "ns" to 1L, + ).mapValues { BigInteger.valueOf(it.value) } +} + +/** Stands for a parse_nsec() -EINVAL that the grammar cannot see; below the 1…UINT32_MAX range. */ +private val NSEC_REFUSED: BigInteger = BigInteger.valueOf(-1) + +/** + * What parse_nsec() gives a finite [text], in nanoseconds, or null if it cannot be evaluated. Like the C, + * each fractional digit adds digit × (multiplier / 10^k) with integer division, so `1.5` (nanoseconds) is 1. + * Values parse_nsec() would refuse with -ERANGE come out larger than UINT32_MAX, so the range check below + * refuses them too. Text parse_nsec() refuses with -EINVAL although [NSEC_DURATION_FINITE] matches it gives + * [NSEC_REFUSED], which the range check also refuses. + */ +private fun parseNsecValue(text: String): BigInteger? { + var pos = 0 + var total = BigInteger.ZERO + while (pos < text.length) { + while (pos < text.length && text[pos].isWhitespace()) pos++ + if (pos >= text.length) break + val m = NSEC_ELEMENT.matchAt(text, pos) ?: return null + if (m.value.isBlank()) return null + pos += m.value.length + // parse_nsec refuses a unit-less number that is followed by more text without whitespace in between + // (`s == p && *s != '\0'`): `12.34.56` and `1+2` are -EINVAL, while `12.34 .56` and `1s.5` are fine + // (sd-check at 0f0682b773). NSEC_DURATION_FINITE lets these through, so refuse them here. + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/time-util.c#L1427-L1430 + if (m.groupValues[3].isEmpty() && !m.value.last().isWhitespace() && pos < text.length) return NSEC_REFUSED + val multiplier = m.groupValues[3].takeIf { it.isNotEmpty() }?.let { NSEC_MULTIPLIERS[it] } ?: BigInteger.ONE + total += BigInteger(m.groupValues[1].ifEmpty { "0" }) * multiplier + var step = multiplier / BigInteger.TEN + for (digit in m.groupValues[2]) { + total += BigInteger.valueOf((digit - '0').toLong()) * step + step /= BigInteger.TEN + } + } + return total +} /** - * Validator for CAN.TimeQuantaNSec, CAN.DataTimeQuantaNSec. - * C Function: config_parse_can_time_quanta(0) + * Validator for [CAN] TimeQuantaNSec= and DataTimeQuantaNSec= (.network). * - * Per parse_nsec, accepts "infinity", a fractional or integer number with any of - * systemd's time-unit suffixes (default unit: nanoseconds), and compound forms - * like "1ms 500us". + * C Function: config_parse_can_time_quanta(0). The value is read with parse_nsec() (one or more + * `number[unit]` elements, a bare number meaning nanoseconds; see [NSEC_DURATION_FINITE]) and must then + * satisfy `0 < val <= UINT32_MAX` nanoseconds. So `infinity` (NSEC_INFINITY), `0` and `4294967296` are + * refused ("Time quanta out of permitted range 1...4294967295"), while `4.2s` and `1ms 500us` are accepted + * (sd-check at 0f0682b773). A unit-less number glued to the next one (`12.34.56`, `1+2`) is -EINVAL too. */ class ConfigParseCanTimeQuantaOptionValue : SimpleGrammarOptionValues( - "config_parse_can_time_quanta", - SequenceCombinator(TIME_VALUE, EOF()) + "config_parse_can_time_quanta", + SequenceCombinator( + Checked(NSEC_DURATION_FINITE) { text -> + parseNsecValue(text)?.let { it > BigInteger.ZERO && it <= SafeAtoTerminal.UINT32_MAX } ?: true + }, + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCodelU32OptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCodelU32OptionValue.kt index d2942465..bd45838a 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCodelU32OptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseCodelU32OptionValue.kt @@ -1,21 +1,36 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * [ControlledDelay] PacketLimit= (.network) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#PacketLimit= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/tc/codel.c#L77-L127 config_parse_codel_u32 + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L429-L478 safe_atou_full (safe_atou32 / safe_atou) + */ + /** - * Validator for ControlledDelay.PacketLimit - * C Function: config_parse_codel_u32(QDISC_KIND_CODEL) - * Used by Options: ControlledDelay.PacketLimit - * - * Validates an unsigned 32-bit integer in the range 0 to 4294967294. - * This specifies the hard limit on the queue size in number of packets. + * Validator for [ControlledDelay] PacketLimit= (.network). + * + * C Function: config_parse_codel_u32(QDISC_KIND_CODEL). An empty assignment resets it and is handled + * before this grammar runs. The value is read with safe_atou32() (base 0) and nothing else is checked, + * so the whole uint32 range is accepted, including 4294967295 (sd-check at 0f0682b773; the old + * validator stopped at 4294967294). */ class ConfigParseCodelU32OptionValue : SimpleGrammarOptionValues( - "config_parse_codel_u32", - SequenceCombinator( - IntegerTerminal(0, 4294967295), // Range 0 to 4294967294 inclusive (max is exclusive) - EOF() - ) + "config_parse_codel_u32", + SequenceCombinator( + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 4_294_967_295L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDhcp6PdPrefixHintOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDhcp6PdPrefixHintOptionValue.kt index 6145b9ff..f5485153 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDhcp6PdPrefixHintOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDhcp6PdPrefixHintOptionValue.kt @@ -1,23 +1,43 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * [DHCPv6] PrefixDelegationHint= (.network) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#PrefixDelegationHint= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-dhcp6.c#L1010-L1048 config_parse_dhcp6_pd_prefix_hint + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/in-addr-util.c#L799-L843 in_addr_prefix_from_string (no slash: length 128) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/in-addr-util.c#L779-L797 in_addr_parse_prefixlen (safe_atou8, at most 128) + */ + /** - * Validator for DHCPv6.PrefixDelegationHint - * C Function: config_parse_dhcp6_pd_prefix_hint(0) - * Used by Options: DHCPv6.PrefixDelegationHint - * - * Validates IPv6 addresses with prefix length in the format: 2001:db8::/64 - * Prefix length must be in the range 1-128 (inclusive). + * Validator for [DHCPv6] PrefixDelegationHint= (.network). + * + * C Function: config_parse_dhcp6_pd_prefix_hint(0). in_addr_prefix_from_string(…, AF_INET6, …) takes an + * IPv6 address with an optional `/LEN`; without one the length is 128, so `2001:db8::` is accepted + * (sd-check at 0f0682b773). LEN is read with safe_atou8 (base 0, `/0b1` works) and the parser then + * requires 1…128. */ class ConfigParseDhcp6PdPrefixHintOptionValue : SimpleGrammarOptionValues( - "config_parse_dhcp6_pd_prefix_hint", - SequenceCombinator( - IPV6_ADDR, + "config_parse_dhcp6_pd_prefix_hint", + SequenceCombinator( + IPV6_ADDR, + ZeroOrOne( + SequenceCombinator( CIDR_SEPARATOR, - IntegerTerminal(1, 129), // 1-128 inclusive (max is exclusive) - EOF() - ) + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 1L, + maxInclusive = 128L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + ), + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDnsOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDnsOptionValue.kt index 9b6d732d..2a4b61e3 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDnsOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseDnsOptionValue.kt @@ -5,33 +5,61 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.EOF import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.IPV4_ADDR import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.IPV6_ADDR -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.IntegerTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.LiteralChoiceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.RegexTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SafeAtoTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.WhitespaceTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrMore import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.ZeroOrOne -private val DNS_PORT = IntegerTerminal(0, 65536) +/* + * [Network] DNS= (.network) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#DNS= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-dns.c#L211-L268 config_parse_dns + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/socket-netlink.c#L220-L354 in_addr_port_ifindex_name_from_string_auto + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L718-L734 parse_ip_port + */ + +// parse_ip_port(): safe_atou16_full(…, SAFE_ATO_REFUSE_LEADING_WHITESPACE) and then 0 is refused. The +// list is split on whitespace first, so no whitespace reaches it. +private val DNS_PORT = SafeAtoTerminal( + signed = false, + base = 0, + flags = setOf(SafeAtoTerminal.Flag.REFUSE_LEADING_WHITESPACE), + minInclusive = 1L, + maxInclusive = 65_535L, + acceptsWhitespace = false, + acceptsMinus = true, +) private val DNS_IFACE = RegexTerminal("[A-Za-z0-9_][A-Za-z0-9_.-]*", "[A-Za-z0-9_][A-Za-z0-9_.-]*") private val DNS_IFACE_SUFFIX = SequenceCombinator(LiteralChoiceTerminal("%"), DNS_IFACE) +// Everything after the first '#' is the server name; it must not be empty and is not checked further +// (sd-check accepts `1.2.3.4#!!` and `1.2.3.4#a#b`, and refuses `1.2.3.4#`). +private val DNS_SERVER_NAME_SUFFIX = SequenceCombinator(LiteralChoiceTerminal("#"), RegexTerminal("\\S+", "\\S+")) + private val DNS_IPV4_ENTRY = SequenceCombinator( IPV4_ADDR, ZeroOrOne(SequenceCombinator(LiteralChoiceTerminal(":"), DNS_PORT)), - ZeroOrOne(DNS_IFACE_SUFFIX) + ZeroOrOne(DNS_IFACE_SUFFIX), + ZeroOrOne(DNS_SERVER_NAME_SUFFIX), ) +// A bracketed address needs the port: `[::1]` alone is -EINVAL. private val DNS_IPV6_BRACKETED_ENTRY = SequenceCombinator( LiteralChoiceTerminal("["), IPV6_ADDR, LiteralChoiceTerminal("]"), - ZeroOrOne(SequenceCombinator(LiteralChoiceTerminal(":"), DNS_PORT)), - ZeroOrOne(DNS_IFACE_SUFFIX) + LiteralChoiceTerminal(":"), + DNS_PORT, + ZeroOrOne(DNS_IFACE_SUFFIX), + ZeroOrOne(DNS_SERVER_NAME_SUFFIX), ) private val DNS_IPV6_BARE_ENTRY = SequenceCombinator( IPV6_ADDR, - ZeroOrOne(DNS_IFACE_SUFFIX) + ZeroOrOne(DNS_IFACE_SUFFIX), + ZeroOrOne(DNS_SERVER_NAME_SUFFIX), ) private val DNS_ENTRY = AlternativeCombinator( DNS_IPV6_BRACKETED_ENTRY, @@ -40,14 +68,17 @@ private val DNS_ENTRY = AlternativeCombinator( ) /** - * Validator for Network.DNS (.network). + * Validator for [Network] DNS= (.network). * - * C Function: config_parse_dns(0) + * C Function: config_parse_dns(0). A whitespace-separated list; each word goes through + * in_addr_full_new_from_string → in_addr_port_ifindex_name_from_string_auto, which accepts + * `ADDRESS[:PORT][%IFACE][#SERVERNAME]`: an IPv4 address with an optional port, a bare IPv6 address + * (no port), or `[IPv6]:PORT`. The `#SERVERNAME` suffix was missing before, so + * `10.10.10.10#aaa.com 10.10.10.11:1111#bbb.com [1111:2222::3333]:1234#ccc.com` was flagged although + * sd-check accepts it. PORT is parse_ip_port (1…65535, base 0, so `:0x35` works and `:0` does not). * - * Accepts a whitespace-separated list of DNS server addresses. Each entry is an IPv4 - * or IPv6 address. IPv4 addresses may optionally include a port suffix (":port") and - * an interface suffix ("%iface"). IPv6 addresses can be bare, or bracketed with a port - * ("[ipv6]:port") and may also have an interface suffix. + * Left lenient: `%IFACE` must name an existing interface (rtnl_resolve_interface), which depends on the + * machine, so only its shape is checked. */ class ConfigParseDnsOptionValue : SimpleGrammarOptionValues( "config_parse_dns", diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseErspanHwidOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseErspanHwidOptionValue.kt index e12eec72..19524e68 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseErspanHwidOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseErspanHwidOptionValue.kt @@ -1,18 +1,40 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * [Tunnel] ERSPANHardwareId= (.netdev) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.netdev.html#ERSPANHardwareId= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/netdev/tunnel.c#L1103-L1129 config_parse_erspan_hwid + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.h#L473-L498 config_parse_uint16_bounded + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L2151-L2182 config_parse_unsigned_bounded + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L480-L495 safe_atou_bounded + */ + /** - * Validator for Tunnel.ERSPANHardwareId - * C Function: config_parse_erspan_hwid(0) - * Used by Options: Tunnel.ERSPANHardwareId + * Validator for [Tunnel] ERSPANHardwareId= (.netdev). + * + * C Function: config_parse_erspan_hwid(0). An empty assignment resets the setting and is handled before + * this grammar runs. + * + * config_parse_uint16_bounded(…, 0, 63, …) reads the value with safe_atou() (base 0), so hex (`0x1f`), + * octal (`077`, `0o77`), binary (`0b1`) and a leading `+` are all accepted, as long as the value is at + * most 63 (sd-check: `0x40` is "allowed range is 0..63"). */ class ConfigParseErspanHwidOptionValue : SimpleGrammarOptionValues( - "config_parse_erspan_hwid", - SequenceCombinator( - IntegerTerminal(0, 64), - EOF() - ) + "config_parse_erspan_hwid", + SequenceCombinator( + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 63L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseFairQueueingSizeOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseFairQueueingSizeOptionValue.kt index f176bcde..3d3dcf51 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseFairQueueingSizeOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseFairQueueingSizeOptionValue.kt @@ -2,19 +2,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.* +import java.math.BigInteger + +/* + * [FairQueueing] QuantumBytes=, InitialQuantumBytes= (and the old Quantum=, InitialQuantum= spellings) (.network) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#QuantumBytes= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/tc/fq.c#L170-L236 config_parse_fq_size + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L157-L283 parse_size + */ /** - * Validator for FairQueueing.QuantumBytes, FairQueueing.InitialQuantumBytes (and the deprecated - * Quantum / InitialQuantum aliases). + * Validator for [FairQueueing] QuantumBytes= and InitialQuantumBytes= (and the old Quantum= and + * InitialQuantum= spellings). * - * C function: config_parse_fq_size (QDISC_KIND_FQ) in src/network/tc/fq.c. Internally calls - * parse_size(rvalue, 1024, &sz), so the value is a decimal byte count optionally suffixed with - * an IEC unit (B, K, M, G, T, P, E). Hexadecimal and octal forms are not accepted. + * C function: config_parse_fq_size (QDISC_KIND_FQ). An empty assignment resets it and is handled before + * this grammar runs. parse_size(rvalue, 1024, …) accepts fractions (`10.5M`, `10.M`) and several + * descending elements (`1G 512M`, `3G1023M`); see [parseSize]. The result must then fit in a uint32, so + * `4G` and `1T` are refused ("is too large"). All confirmed with sd-check at 0f0682b773. */ class ConfigParseFairQueueingSizeOptionValue : SimpleGrammarOptionValues( - "config_parse_fq_size", - SequenceCombinator( - OptionalWhitespacePrefix(BYTES), - EOF() - ) + "config_parse_fq_size", + SequenceCombinator( + parseSize( + base = 1024, + minInclusive = BigInteger.ZERO, + maxInclusive = SIZE_UINT32_MAX, + allowInnerWhitespace = true, + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInAddrNonNullOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInAddrNonNullOptionValue.kt index 97e7bfea..1633368f 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInAddrNonNullOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseInAddrNonNullOptionValue.kt @@ -1,21 +1,25 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * config_parse_in_addr_non_null with ltype AF_INET, e.g. [DHCPServer] RelayTarget= (.network) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#RelayTarget= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L2036-L2082 config_parse_in_addr_non_null + */ + /** - * Validator for DHCPServer.RelayTarget - * C Function: config_parse_in_addr_non_null(AF_INET) - * Used by Options: DHCPServer.RelayTarget - * - * Validates IPv4 addresses in inet_pton(3) format. The C implementation also rejects - * the ANY address (0.0.0.0) semantically, but syntactically it's a valid IPv4 address. + * Validator for config_parse_in_addr_non_null(AF_INET), e.g. [DHCPServer] RelayTarget=. + * + * An IPv4 address in inet_pton(3) form that is not the ANY address: `0.0.0.0` is refused ("cannot be the + * ANY address", sd-check at 0f0682b773). Same check as [ConfigParseInAddrNonNullIpv6OptionValue]. */ class ConfigParseInAddrNonNullOptionValue : SimpleGrammarOptionValues( - "config_parse_in_addr_non_null", - SequenceCombinator( - IPV4_ADDR, - EOF() - ) + "config_parse_in_addr_non_null", + SequenceCombinator( + Checked(IPV4_ADDR) { text -> ipLiteralBytes(text)?.let { !ipIsNull(it) } ?: true }, + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseIpProtocolOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseIpProtocolOptionValue.kt index 27521ff0..773d0529 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseIpProtocolOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseIpProtocolOptionValue.kt @@ -1,25 +1,46 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* 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.FlexibleLiteralChoiceTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.PreferValidAlternativeCombinator +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SafeAtoTerminal +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SequenceCombinator + +/* + * [FooOverUDP] Protocol= (.netdev) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.netdev.html#Protocol= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L2257-L2287 config_parse_ip_protocol (ltype true = relaxed) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/ip-protocol-list.c#L36-L70 parse_ip_protocol_full + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L497-L520 safe_atoi + */ /** - * Validator for FooOverUDP.Protocol - * C Function: config_parse_ip_protocol(true) - * Used by Options: FooOverUDP.Protocol + * Validator for [FooOverUDP] Protocol= (.netdev). * - * Accepts an IP protocol name from /etc/protocols (e.g. tcp, udp, icmp) or an integer 0..255. - * The list of protocol names below is intentionally conservative; rare names will produce a - * (false-positive) warning, which is acceptable. + * C Function: config_parse_ip_protocol(true). parse_ip_protocol_full(…, relaxed = true) accepts an + * IPPROTO_* name, looked up case-insensitively (so `GRE` and `ipip` work; see [IP_PROTOCOL_NAMES]), or a + * number read with safe_atoi() (base 0, so `0x2f` works) that is not negative. Being relaxed, it does not + * require a name for the number, so `3` is accepted here (unlike [RoutingPolicyRule] IPProtocol=). + * config_parse_ip_protocol then refuses anything above 255, which also rules out the names `smc` (256) + * and `mptcp` (262). All confirmed with sd-check at 0f0682b773. */ class ConfigParseIpProtocolOptionValue : SimpleGrammarOptionValues( - "config_parse_ip_protocol", - SequenceCombinator( - AlternativeCombinator( - FlexibleLiteralChoiceTerminal("tcp", "udp", "icmp", "icmpv6", "sctp", "udplite", "dccp"), - IntegerTerminal(0, 256) - ), - EOF() - ) + "config_parse_ip_protocol", + SequenceCombinator( + PreferValidAlternativeCombinator( + FlexibleLiteralChoiceTerminal(*IP_PROTOCOL_NAMES, ignoreCase = true), + SafeAtoTerminal( + signed = true, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 255L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNetdevHwAddrOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNetdevHwAddrOptionValue.kt index 64fc77b4..01f77af0 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNetdevHwAddrOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseNetdevHwAddrOptionValue.kt @@ -3,11 +3,29 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * [NetDev] MACAddress=, [Peer] MACAddress= (.netdev) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.netdev.html#MACAddress= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/netdev/netdev.c#L1264-L1286 config_parse_netdev_hw_addr + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/shared/conf-parser.c#L1864-L1893 config_parse_hw_addr + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/ether-addr-util.c#L196-L280 parse_hw_addr_full + */ + /** * Validator for [NetDev] MACAddress= and [Peer] MACAddress= (.netdev). - * C Function: config_parse_netdev_hw_addr (ltype ETH_ALEN) -> a single 6-byte Ethernet MAC. + * + * C Function: config_parse_netdev_hw_addr (ltype ETH_ALEN). The exact word `none` (streq, so + * case-sensitive) means "no address"; anything else goes to config_parse_hw_addr, i.e. a single 6-byte + * Ethernet MAC. `none` was missing before (sd-check accepts it; `NONE` is refused). */ class ConfigParseNetdevHwAddrOptionValue : SimpleGrammarOptionValues( - "config_parse_netdev_hw_addr", - SequenceCombinator(MAC_ADDRESS, EOF()) + "config_parse_netdev_hw_addr", + SequenceCombinator( + AlternativeCombinator( + MAC_ADDRESS, + LiteralChoiceTerminal("none"), + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePfifoSizeOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePfifoSizeOptionValue.kt index 81b65a93..78ac68a7 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePfifoSizeOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePfifoSizeOptionValue.kt @@ -1,21 +1,35 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * [PFIFO] PacketLimit= and [PFIFOHeadDrop] PacketLimit= (.network) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#PacketLimit= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/tc/fifo.c#L43-L101 config_parse_pfifo_size + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L429-L478 safe_atou_full (safe_atou32 / safe_atou) + */ + /** - * Validator for PFIFOHeadDrop.PacketLimit - * C Function: config_parse_pfifo_size(QDISC_KIND_PFIFO_HEAD_DROP) - * Used by Options: PFIFOHeadDrop.PacketLimit - * - * Parses a packet limit value as an unsigned 32-bit integer. - * Valid range: 0 to 4294967295 (2^32 - 1) + * Validator for [PFIFO] PacketLimit= and [PFIFOHeadDrop] PacketLimit= (.network). + * + * C Function: config_parse_pfifo_size(QDISC_KIND_PFIFO / QDISC_KIND_PFIFO_HEAD_DROP). An empty assignment + * resets it and is handled before this grammar runs. The value is read with safe_atou32() (base 0, so + * `0x100` is accepted; sd-check at 0f0682b773) and nothing else is checked. */ class ConfigParsePfifoSizeOptionValue : SimpleGrammarOptionValues( - "config_parse_pfifo_size", - SequenceCombinator( - IntegerTerminal(0, 4294967296), // 0 to 2^32-1 (max exclusive) - EOF() - ) + "config_parse_pfifo_size", + SequenceCombinator( + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 4_294_967_295L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePrefixOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePrefixOptionValue.kt index 75d08fa2..e8bb6de1 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePrefixOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParsePrefixOptionValue.kt @@ -1,23 +1,43 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * [IPv6Prefix] Prefix= (.network) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#Prefix= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-radv.c#L872-L910 config_parse_prefix + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/in-addr-util.c#L799-L843 in_addr_prefix_from_string (no slash: length 128) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/in-addr-util.c#L779-L797 in_addr_parse_prefixlen (safe_atou8, at most 128) + */ + /** - * Validator for IPv6Prefix.Prefix - * C Function: config_parse_prefix(0) - * Used by Options: IPv6Prefix.Prefix - * - * Validates IPv6 prefix in the format: IPv6_address/prefix_length - * Example: 2001:db8::/32 + * Validator for [IPv6Prefix] Prefix= (.network). + * + * C Function: config_parse_prefix(0). in_addr_prefix_from_string(…, AF_INET6, …) takes an IPv6 address + * with an optional `/LEN` (safe_atou8, base 0, at most 128); without one the length is 128, so + * `2001:db8::` is accepted (sd-check at 0f0682b773). The parser masks the address to the length and + * refuses nothing else, so `/0` is accepted too. */ class ConfigParsePrefixOptionValue : SimpleGrammarOptionValues( - "config_parse_prefix", - SequenceCombinator( - IPV6_ADDR, + "config_parse_prefix", + SequenceCombinator( + IPV6_ADDR, + ZeroOrOne( + SequenceCombinator( CIDR_SEPARATOR, - IntegerTerminal(0, 129), // IPv6 prefix length: 0-128 (129 is exclusive) - EOF() - ) + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 128L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + ), + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseQfqWeightOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseQfqWeightOptionValue.kt index e24ec3d8..962bf3ce 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseQfqWeightOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseQfqWeightOptionValue.kt @@ -1,18 +1,36 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * [QuickFairQueueingClass] Weight= (.network) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#Weight= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/tc/qfq.c#L55-L113 config_parse_qfq_weight (QFQ_MAX_WEIGHT = 1 << 10, qfq.c#L13) + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L429-L478 safe_atou_full (safe_atou32 / safe_atou) + */ + /** - * Validator for QuickFairQueueingClass.Weight - * C Function: config_parse_qfq_weight(TCLASS_KIND_QFQ) - * Used by Options: QuickFairQueueingClass.Weight + * Validator for [QuickFairQueueingClass] Weight= (.network). + * + * C Function: config_parse_qfq_weight(TCLASS_KIND_QFQ). An empty assignment resets it and is handled + * before this grammar runs. The value is read with safe_atou32() (base 0) and must be 1…QFQ_MAX_WEIGHT, + * where QFQ_MAX_WEIGHT is 1 << 10 = 1024; so 1024 itself is accepted (sd-check at 0f0682b773; the old + * validator stopped at 1023). */ class ConfigParseQfqWeightOptionValue : SimpleGrammarOptionValues( - "config_parse_qfq_weight", - SequenceCombinator( - IntegerTerminal(1, 1024), // Range 1-1023 (max is exclusive) - EOF() - ) + "config_parse_qfq_weight", + SequenceCombinator( + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 1L, + maxInclusive = 1024L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseRoutePrefixPreferenceOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseRoutePrefixPreferenceOptionValue.kt index f4cbf4bb..58d04943 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseRoutePrefixPreferenceOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseRoutePrefixPreferenceOptionValue.kt @@ -1,18 +1,28 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.Validator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.* import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +/* + * [IPv6RoutePrefix] Preference= (.network) + * + * man https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#Preference= + * parser https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-radv.c#L1161-L1190 config_parse_route_prefix_preference + * leaf https://github.com/systemd/systemd/blob/0f0682b773/src/network/networkd-radv.c#L1557-L1581 config_parse_router_preference + */ + /** - * Validator for IPv6RoutePrefix.Preference - * C Function: config_parse_route_prefix_preference(0) - * Used by Options: IPv6RoutePrefix.Preference + * Validator for [IPv6RoutePrefix] Preference= (.network). + * + * C Function: config_parse_route_prefix_preference(0), which hands the value to + * config_parse_router_preference: `medium`, `normal` and `default` all mean medium, plus `high` and + * `low` (exact, case-sensitive matches). `normal` and `default` were missing before (sd-check accepts + * `Preference=normal`). */ class ConfigParseRoutePrefixPreferenceOptionValue : SimpleGrammarOptionValues( - "config_parse_route_prefix_preference", - SequenceCombinator( - FlexibleLiteralChoiceTerminal("high", "medium", "low"), - EOF() - ) + "config_parse_route_prefix_preference", + SequenceCombinator( + FlexibleLiteralChoiceTerminal("high", "medium", "normal", "default", "low"), + EOF(), + ), ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseRoutingPolicyRuleIpProtocolOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseRoutingPolicyRuleIpProtocolOptionValue.kt index 21f3a51c..754962ab 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseRoutingPolicyRuleIpProtocolOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseRoutingPolicyRuleIpProtocolOptionValue.kt @@ -28,9 +28,10 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * The IPPROTO_* names the generated gperf table knows, lower-cased. The table is generated from * systemd's own vendored uapi headers (the override includes and * , not glibc's), so it is fixed per systemd commit. `smc` (256) and `mptcp` (262) are left - * out because config_parse_ip_protocol refuses anything above 255. + * out because config_parse_ip_protocol refuses anything above 255. Also used by the relaxed + * [ConfigParseIpProtocolOptionValue] (FooOverUDP Protocol=). */ -private val IP_PROTOCOL_NAMES = arrayOf( +internal val IP_PROTOCOL_NAMES = arrayOf( "ip", "hopopts", "icmp", "igmp", "ipip", "tcp", "egp", "pup", "udp", "idp", "tp", "dccp", "ipv6", "routing", "fragment", "rsvp", "gre", "esp", "ah", "icmpv6", "none", "dstopts", "mtp", "beetph", "encap", "pim", "comp", "l2tp", "mh", "sctp", "udplite", "mpls", "ethernet", "aggfrag", "raw", diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/RouteAddressNextHopOptionValues.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/RouteAddressNextHopOptionValues.kt index 41da2ec0..1da3f729 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/RouteAddressNextHopOptionValues.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/RouteAddressNextHopOptionValues.kt @@ -90,8 +90,8 @@ class ConfigParseRouteScopeOptionValue : SimpleGrammarOptionValues( * in_addr_prefix_from_string_auto and only logs a hint, so a bare address is accepted too; the prefix, * when present, may be anything the family allows. * - * `Address=` is the same table entry but keeps the older, stricter [NetworkAddressOptionValue] because - * that behaviour is pinned by existing tests — see the note there. + * `Address=` is the same table entry and has its own [NetworkAddressOptionValue], which also accepts a + * bare address but reads the prefix length with safe_atou8. */ class ConfigParseAddressSectionPeerOptionValue : SimpleGrammarOptionValues( ADDRESS, SequenceCombinator(IP_ADDR_AND_ANY_PREFIX, EOF()) diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueForNetworkAddressesTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueForNetworkAddressesTest.kt index 6ea9c32d..9ff142f8 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueForNetworkAddressesTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueForNetworkAddressesTest.kt @@ -76,6 +76,11 @@ class InvalidValueForNetworkAddressesTest : AbstractUnitFileTest() { Address=2001:db8::/65 Address=ff02::1/128 Address=2600::1/0 + # A missing prefix length is only advice ("specified without prefix length. Assuming the prefix + # length is 128"), not a parse error, so it is accepted (#568). Was asserted invalid before. + Address=2001:db8::1 + # The prefix length is read with safe_atou8, so base prefixes work. + Address=2001:db8::1/0x40 # Honestly I don't know what matches this Address=2001:0db8:85a3:0000:0000:8a2e:192.168.0.1/96 """.trimIndent() @@ -106,8 +111,6 @@ class InvalidValueForNetworkAddressesTest : AbstractUnitFileTest() { Address=2001:db8::1/129 # Prefix too small (<0) Address=2001:db8::1/-1 - # Missing prefix - Address=2001:db8::1 # Empty address Address=/64 # Trailing colon @@ -134,7 +137,7 @@ class InvalidValueForNetworkAddressesTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(16, highlights) + assertSize(15, highlights) } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForSimpleGrammarOptionValue.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForSimpleGrammarOptionValue.kt index b31f507f..ed7437e8 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForSimpleGrammarOptionValue.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/InvalidValueInspectionForSimpleGrammarOptionValue.kt @@ -10,7 +10,6 @@ class InvalidValueInspectionForSimpleGrammarOptionValue : AbstractUnitFileTest() // language="unit file (systemd)" val file=""" [FooOverUDP] - Port=0 Port=1 Port=32768 Port=65535 @@ -33,7 +32,9 @@ class InvalidValueInspectionForSimpleGrammarOptionValue : AbstractUnitFileTest() Port=-1 Port=a Port=65536 - + # parse_ip_port() refuses 0 (sd-check: "Failed to parse Port=0, ignoring: Invalid argument"); this + # was asserted valid before #568. + Port=0 """.trimIndent() // Execute SUT @@ -42,7 +43,7 @@ class InvalidValueInspectionForSimpleGrammarOptionValue : AbstractUnitFileTest() val highlights = myFixture.doHighlighting() // Verification - assertSize(3, highlights) + assertSize(4, highlights) } fun testNoWarningWhenValidCoalescedUint32Specified() { diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParse6rdPrefixOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParse6rdPrefixOptionValueTest.kt index 2c02094a..fe4d7143 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParse6rdPrefixOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParse6rdPrefixOptionValueTest.kt @@ -36,7 +36,6 @@ class ConfigParse6rdPrefixOptionValueTest : AbstractUnitFileTest() { val file = """ [Tunnel] IPv6RapidDeploymentPrefix=2001:db8::/0 - IPv6RapidDeploymentPrefix=2001:db8:: IPv6RapidDeploymentPrefix=192.168.1.0/24 IPv6RapidDeploymentPrefix=not-an-address/32 IPv6RapidDeploymentPrefix=2001:db8::/129 @@ -48,16 +47,18 @@ class ConfigParse6rdPrefixOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(5, highlights) + assertSize(4, highlights) } @Test fun testMissingPrefixLength() { - // Fixture Setup - IPv6 address without prefix length should be invalid + // Fixture Setup - without a prefix length in_addr_prefix_from_string uses 128, so sd-check accepts + // these (#568; this test asserted the opposite before). // language="unit file (systemd)" val file = """ [Tunnel] IPv6RapidDeploymentPrefix=2001:db8::1 + IPv6RapidDeploymentPrefix=2001:db8:: """.trimIndent() // Execute SUT @@ -66,7 +67,7 @@ class ConfigParse6rdPrefixOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(1, highlights) + assertSize(0, highlights) } @Test diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseAdActorSystemOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseAdActorSystemOptionValueTest.kt index 65ca45d9..561198f9 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseAdActorSystemOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseAdActorSystemOptionValueTest.kt @@ -14,8 +14,11 @@ class ConfigParseAdActorSystemOptionValueTest : AbstractUnitFileTest() { [Bond] AdActorSystem=00:11:22:33:44:55 AdActorSystem=AA:BB:CC:DD:EE:FF - AdActorSystem=01:23:45:67:89:ab AdActorSystem=FE:DC:BA:98:76:54 + # parse_ether_addr() also takes the hyphen form and 1-digit fields (sd-check accepts both); these + # were asserted invalid before #568. + AdActorSystem=00-11-22-33-44-55 + AdActorSystem=0:1:2:3:4:5 """.trimIndent() // Execute SUT @@ -35,9 +38,8 @@ class ConfigParseAdActorSystemOptionValueTest : AbstractUnitFileTest() { [Bond] AdActorSystem=00:11:22:33:44 AdActorSystem=00:11:22:33:44:55:66 - AdActorSystem=00-11-22-33-44-55 AdActorSystem=GG:HH:II:JJ:KK:LL - AdActorSystem=0:1:2:3:4:5 + AdActorSystem=01:23:45:67:89:ab AdActorSystem=001122334455 """.trimIndent() @@ -47,7 +49,7 @@ class ConfigParseAdActorSystemOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(6, highlights) + assertSize(5, highlights) } @Test diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseAddressSectionAddressScopeOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseAddressSectionAddressScopeOptionValueTest.kt index e821b996..5b278d00 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseAddressSectionAddressScopeOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseAddressSectionAddressScopeOptionValueTest.kt @@ -15,6 +15,10 @@ class ConfigParseAddressSectionAddressScopeOptionValueTest : AbstractUnitFileTes Scope=global Scope=link Scope=host + # route_scope_table also has site and nowhere (sd-check accepts both; site was asserted invalid + # before #568). + Scope=site + Scope=nowhere """.trimIndent() // Execute SUT @@ -54,7 +58,6 @@ class ConfigParseAddressSectionAddressScopeOptionValueTest : AbstractUnitFileTes [Address] Scope=invalid Scope=localhost - Scope=site """.trimIndent() // Execute SUT @@ -63,7 +66,7 @@ class ConfigParseAddressSectionAddressScopeOptionValueTest : AbstractUnitFileTes val highlights = myFixture.doHighlighting() // Verification - assertSize(3, highlights) + assertSize(2, highlights) } @Test diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBridgeFdbMaxLearnedOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBridgeFdbMaxLearnedOptionValueTest.kt index 344e126b..1970b85b 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBridgeFdbMaxLearnedOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseBridgeFdbMaxLearnedOptionValueTest.kt @@ -18,6 +18,8 @@ class ConfigParseBridgeFdbMaxLearnedOptionValueTest : AbstractUnitFileTest() { FDBMaxLearned=1000 FDBMaxLearned=65535 FDBMaxLearned=4294967295 + # safe_atou() takes base prefixes (sd-check accepts it; asserted invalid before #568). + FDBMaxLearned=0x100 """.trimIndent() // Execute SUT @@ -39,7 +41,6 @@ class ConfigParseBridgeFdbMaxLearnedOptionValueTest : AbstractUnitFileTest() { FDBMaxLearned=4294967296 FDBMaxLearned=abc FDBMaxLearned=1.5 - FDBMaxLearned=0x100 """.trimIndent() // Execute SUT @@ -48,7 +49,7 @@ class ConfigParseBridgeFdbMaxLearnedOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(5, highlights) + assertSize(4, highlights) } @Test diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCakeRttOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCakeRttOptionValueTest.kt index 22e9c763..3c82e48b 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCakeRttOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCakeRttOptionValueTest.kt @@ -17,8 +17,6 @@ class ConfigParseCakeRttOptionValueTest : AbstractUnitFileTest() { RTTSec=5m RTTSec=200 RTTSec=10.5s - RTTSec=infinity - RTTSec=2hour """.trimIndent() setupFileInEditor("file.network", file) @@ -33,10 +31,14 @@ class ConfigParseCakeRttOptionValueTest : AbstractUnitFileTest() { [CAKE] RTTSec=abc RTTSec=-1 + # config_parse_cake_rtt refuses anything above UINT32_MAX microseconds (sd-check: "Invalid + # 'RTTSec='"); these two were asserted valid before #568. + RTTSec=infinity + RTTSec=2hour """.trimIndent() setupFileInEditor("file.network", file) enableInspection(InvalidValueInspection::class.java) - assertSize(2, myFixture.doHighlighting()) + assertSize(4, myFixture.doHighlighting()) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCanTimeQuantaOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCanTimeQuantaOptionValueTest.kt index 54141ae7..06b78aca 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCanTimeQuantaOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCanTimeQuantaOptionValueTest.kt @@ -15,7 +15,6 @@ class ConfigParseCanTimeQuantaOptionValueTest : AbstractUnitFileTest() { TimeQuantaNSec=500ns TimeQuantaNSec=1us TimeQuantaNSec=1ms - DataTimeQuantaNSec=infinity DataTimeQuantaNSec=1ms 500us DataTimeQuantaNSec=10.5ms """.trimIndent() @@ -32,10 +31,13 @@ class ConfigParseCanTimeQuantaOptionValueTest : AbstractUnitFileTest() { [CAN] TimeQuantaNSec=invalid TimeQuantaNSec=-10ns + # config_parse_can_time_quanta requires 1…UINT32_MAX ns (sd-check: "Time quanta out of permitted + # range"); infinity was asserted valid before #568. + DataTimeQuantaNSec=infinity """.trimIndent() setupFileInEditor("file.network", file) enableInspection(InvalidValueInspection::class.java) - assertSize(2, myFixture.doHighlighting()) + assertSize(3, myFixture.doHighlighting()) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCodelU32OptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCodelU32OptionValueTest.kt index 9e37bee2..80d95a89 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCodelU32OptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseCodelU32OptionValueTest.kt @@ -17,6 +17,10 @@ class ConfigParseCodelU32OptionValueTest : AbstractUnitFileTest() { PacketLimit=100 PacketLimit=1000 PacketLimit=4294967294 + # safe_atou32 takes the whole uint32 range and base prefixes (sd-check accepts both; 4294967295 was + # asserted invalid before #568). + PacketLimit=4294967295 + PacketLimit=0x10 """.trimIndent() // Execute SUT @@ -35,7 +39,6 @@ class ConfigParseCodelU32OptionValueTest : AbstractUnitFileTest() { val file = """ [ControlledDelay] PacketLimit=-1 - PacketLimit=4294967295 PacketLimit=5000000000 PacketLimit=abc PacketLimit=12.5 @@ -47,7 +50,7 @@ class ConfigParseCodelU32OptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(5, highlights) + assertSize(4, highlights) } @Test @@ -75,7 +78,7 @@ class ConfigParseCodelU32OptionValueTest : AbstractUnitFileTest() { // language="unit file (systemd)" val file = """ [ControlledDelay] - PacketLimit=4294967295 + PacketLimit=4294967296 """.trimIndent() // Execute SUT diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseDhcp6PdPrefixHintOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseDhcp6PdPrefixHintOptionValueTest.kt index c781210f..26659cd2 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseDhcp6PdPrefixHintOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseDhcp6PdPrefixHintOptionValueTest.kt @@ -19,6 +19,9 @@ class ConfigParseDhcp6PdPrefixHintOptionValueTest : AbstractUnitFileTest() { PrefixDelegationHint=2001:db8::1/48 PrefixDelegationHint=fd00::/8 PrefixDelegationHint=::/1 + # Without a prefix length in_addr_prefix_from_string uses 128 (sd-check accepts it; asserted invalid + # before #568). + PrefixDelegationHint=2001:db8:: """.trimIndent() // Execute SUT @@ -39,7 +42,6 @@ class ConfigParseDhcp6PdPrefixHintOptionValueTest : AbstractUnitFileTest() { PrefixDelegationHint=2001:db8::/0 PrefixDelegationHint=fe80::1/129 PrefixDelegationHint=192.168.1.0/24 - PrefixDelegationHint=2001:db8:: PrefixDelegationHint=not-an-address/64 PrefixDelegationHint=2001:db8::/ PrefixDelegationHint=2001:db8::/256 @@ -51,7 +53,7 @@ class ConfigParseDhcp6PdPrefixHintOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(7, highlights) + assertSize(6, highlights) } @Test diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseFairQueueingSizeOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseFairQueueingSizeOptionValueTest.kt index dab14235..58f4f9f1 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseFairQueueingSizeOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseFairQueueingSizeOptionValueTest.kt @@ -18,8 +18,12 @@ class ConfigParseFairQueueingSizeOptionValueTest : AbstractUnitFileTest() { InitialQuantumBytes=64K QuantumBytes=1M InitialQuantumBytes=2G - QuantumBytes=1T InitialQuantumBytes=0 + # parse_size() takes fractions and several elements (sd-check accepts these; 10.5M was asserted + # invalid before #568). + QuantumBytes=10.5M + QuantumBytes=1G 512M + QuantumBytes=4294967295 """.trimIndent() // Execute SUT @@ -39,9 +43,11 @@ class ConfigParseFairQueueingSizeOptionValueTest : AbstractUnitFileTest() { [FairQueueing] QuantumBytes=abc InitialQuantumBytes=1024L - QuantumBytes=10.5M InitialQuantumBytes=0x1000 QuantumBytes=K + # More than UINT32_MAX bytes is "too large" (sd-check); 1T was asserted valid before #568. + QuantumBytes=1T + InitialQuantumBytes=4G """.trimIndent() // Execute SUT @@ -50,6 +56,6 @@ class ConfigParseFairQueueingSizeOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(5, highlights) + assertSize(6, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInAddrNonNullOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInAddrNonNullOptionValueTest.kt index 3f86e570..482d29ac 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInAddrNonNullOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseInAddrNonNullOptionValueTest.kt @@ -42,6 +42,9 @@ class ConfigParseInAddrNonNullOptionValueTest : AbstractUnitFileTest() { RelayTarget=192.168.-1.1 RelayTarget=192.168.1.1/24 RelayTarget=::1 + # The ANY address is refused (sd-check: "RelayTarget= cannot be the ANY address"); it was asserted + # valid before #568. + RelayTarget=0.0.0.0 """.trimIndent() // Execute SUT @@ -50,7 +53,7 @@ class ConfigParseInAddrNonNullOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(7, highlights) + assertSize(8, highlights) } @Test @@ -59,7 +62,6 @@ class ConfigParseInAddrNonNullOptionValueTest : AbstractUnitFileTest() { // language="unit file (systemd)" val file = """ [DHCPServer] - RelayTarget=0.0.0.0 RelayTarget=127.0.0.1 RelayTarget=255.0.0.0 RelayTarget=0.0.0.1 diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePfifoSizeOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePfifoSizeOptionValueTest.kt index 2ebf394f..b02f4576 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePfifoSizeOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePfifoSizeOptionValueTest.kt @@ -18,6 +18,8 @@ class ConfigParsePfifoSizeOptionValueTest : AbstractUnitFileTest() { PacketLimit=1000 PacketLimit=65535 PacketLimit=4294967295 + # safe_atou32 takes base prefixes (sd-check accepts it; asserted invalid before #568). + PacketLimit=0x100 """.trimIndent() // Execute SUT @@ -39,7 +41,6 @@ class ConfigParsePfifoSizeOptionValueTest : AbstractUnitFileTest() { PacketLimit=4294967296 PacketLimit=abc PacketLimit=10.5 - PacketLimit=0x100 """.trimIndent() // Execute SUT @@ -48,7 +49,7 @@ class ConfigParsePfifoSizeOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(5, highlights) + assertSize(4, highlights) } @Test diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePrefixOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePrefixOptionValueTest.kt index 9133897c..e987d4ce 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePrefixOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParsePrefixOptionValueTest.kt @@ -19,6 +19,9 @@ class ConfigParsePrefixOptionValueTest : AbstractUnitFileTest() { Prefix=::/0 Prefix=fd00::/8 Prefix=2001:db8::1/64 + # Without a prefix length in_addr_prefix_from_string uses 128 (sd-check accepts it; asserted invalid + # before #568). + Prefix=2001:db8:: """.trimIndent() // Execute SUT @@ -36,7 +39,6 @@ class ConfigParsePrefixOptionValueTest : AbstractUnitFileTest() { // language="unit file (systemd)" val file = """ [IPv6Prefix] - Prefix=2001:db8:: Prefix=fe80::/ Prefix=2001:db8::/129 Prefix=192.168.1.0/24 @@ -50,7 +52,7 @@ class ConfigParsePrefixOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(6, highlights) + assertSize(5, highlights) } @Test diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseQfqWeightOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseQfqWeightOptionValueTest.kt index f48f6eb0..d10f65ed 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseQfqWeightOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseQfqWeightOptionValueTest.kt @@ -15,6 +15,9 @@ class ConfigParseQfqWeightOptionValueTest : AbstractUnitFileTest() { Weight=1 Weight=500 Weight=1023 + # QFQ_MAX_WEIGHT is 1 << 10, and the check is v > QFQ_MAX_WEIGHT (sd-check accepts 1024; asserted + # invalid before #568). + Weight=1024 """.trimIndent() // Execute SUT @@ -33,7 +36,7 @@ class ConfigParseQfqWeightOptionValueTest : AbstractUnitFileTest() { val file = """ [QuickFairQueueingClass] Weight=0 - Weight=1024 + Weight=1025 Weight=-1 Weight=abc """.trimIndent() diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseRoutePrefixPreferenceOptionValueTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseRoutePrefixPreferenceOptionValueTest.kt index 57bc235d..685f5224 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseRoutePrefixPreferenceOptionValueTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/ConfigParseRoutePrefixPreferenceOptionValueTest.kt @@ -15,6 +15,10 @@ class ConfigParseRoutePrefixPreferenceOptionValueTest : AbstractUnitFileTest() { Preference=high Preference=medium Preference=low + # config_parse_router_preference also takes normal and default for medium (sd-check accepts + # both; normal was asserted invalid before #568). + Preference=normal + Preference=default """.trimIndent() // Execute SUT @@ -33,7 +37,6 @@ class ConfigParseRoutePrefixPreferenceOptionValueTest : AbstractUnitFileTest() { val file = """ [IPv6RoutePrefix] Preference=invalid - Preference=normal Preference=highest Preference=lowest Preference=1 @@ -46,6 +49,6 @@ class ConfigParseRoutePrefixPreferenceOptionValueTest : AbstractUnitFileTest() { val highlights = myFixture.doHighlighting() // Verification - assertSize(6, highlights) + assertSize(5, highlights) } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/Issue568NetworkValidatorsTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/Issue568NetworkValidatorsTest.kt new file mode 100644 index 00000000..41ee68f8 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/Issue568NetworkValidatorsTest.kt @@ -0,0 +1,659 @@ +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 + +/** + * Issue #568, network theme: networkd / netdev values whose plugin verdict disagreed with systemd's parser. + * Every value below was checked with steve-local/systemd-tools/bin/sd-check at 0f0682b773, one value per + * file: the "valid" ones produce no complaint (or, for Address=, only the "specified without prefix length" + * advice), the "invalid" ones are refused by the parser. + * + * Accepted on purpose although systemd refuses them later: `[Xfrm] InterfaceId=0` (netdev verify: "Xfrm + * interface ID cannot be zero"). Validators flag only what the config parser rejects (#565 covers the rest). + */ +class Issue568NetworkValidatorsTest : AbstractUnitFileTest() { + + private fun assertHighlights(fileName: String, file: String, expected: Int) { + setupFileInEditor(fileName, file) + enableInspection(InvalidValueInspection::class.java) + val highlights = myFixture.doHighlighting() + assertSize(expected, highlights) + } + + @Test + fun testErspanHardwareIdValid() { + // language="unit file (systemd)" + val file = """ + [Tunnel] + ERSPANHardwareId=0 + ERSPANHardwareId=63 + ERSPANHardwareId=0x1f + ERSPANHardwareId=0x2f + ERSPANHardwareId=0x3f + ERSPANHardwareId=077 + ERSPANHardwareId=0o77 + ERSPANHardwareId=0b1 + ERSPANHardwareId=+5 + ERSPANHardwareId=-0 + """.trimIndent() + assertHighlights("file.netdev", file, 0) + } + + @Test + fun testErspanHardwareIdInvalid() { + // language="unit file (systemd)" + val file = """ + [Tunnel] + ERSPANHardwareId=64 + ERSPANHardwareId=0x40 + ERSPANHardwareId=-1 + ERSPANHardwareId=abc + ERSPANHardwareId=0x + """.trimIndent() + assertHighlights("file.netdev", file, 5) + } + + @Test + fun testXfrmInterfaceIdValid() { + // language="unit file (systemd)" + val file = """ + [Xfrm] + InterfaceId=0x98 + InterfaceId=0x99 + InterfaceId=0 + InterfaceId=4294967295 + InterfaceId=0xffffffff + InterfaceId=077 + InterfaceId=+7 + """.trimIndent() + assertHighlights("file.netdev", file, 0) + } + + @Test + fun testXfrmInterfaceIdInvalid() { + // language="unit file (systemd)" + val file = """ + [Xfrm] + InterfaceId=4294967296 + InterfaceId=-1 + InterfaceId=0x100000000 + InterfaceId=08 + InterfaceId=abc + """.trimIndent() + assertHighlights("file.netdev", file, 5) + } + + @Test + fun testFooOverUdpProtocolValid() { + // language="unit file (systemd)" + val file = """ + [FooOverUDP] + Protocol=GRE + Protocol=ipip + Protocol=IPIP + Protocol=gre + Protocol=tcp + Protocol=Udp + Protocol=3 + Protocol=255 + Protocol=0x11 + Protocol=0 + """.trimIndent() + assertHighlights("file.netdev", file, 0) + } + + @Test + fun testFooOverUdpProtocolInvalid() { + // language="unit file (systemd)" + val file = """ + [FooOverUDP] + Protocol=256 + Protocol=-1 + Protocol=smc + Protocol=mptcp + Protocol=foo + """.trimIndent() + assertHighlights("file.netdev", file, 5) + } + + @Test + fun testFooOverUdpPortValid() { + // language="unit file (systemd)" + val file = """ + [FooOverUDP] + Port=1 + Port=65535 + Port=0x10 + Port=+5 + """.trimIndent() + assertHighlights("file.netdev", file, 0) + } + + @Test + fun testFooOverUdpPortInvalid() { + // language="unit file (systemd)" + val file = """ + [FooOverUDP] + Port=0 + Port=-0 + Port=65536 + Port=0x10000 + Port=abc + """.trimIndent() + assertHighlights("file.netdev", file, 5) + } + + @Test + fun testSixRdPrefixValid() { + // language="unit file (systemd)" + val file = """ + [Tunnel] + IPv6RapidDeploymentPrefix=2001:db8:: + IPv6RapidDeploymentPrefix=2001:db8::1 + IPv6RapidDeploymentPrefix=2001:db8::/16 + IPv6RapidDeploymentPrefix=2001:db8::/0x10 + IPv6RapidDeploymentPrefix=2001:db8::/128 + """.trimIndent() + assertHighlights("file.netdev", file, 0) + } + + @Test + fun testSixRdPrefixInvalid() { + // language="unit file (systemd)" + val file = """ + [Tunnel] + IPv6RapidDeploymentPrefix=2001:db8::/0 + IPv6RapidDeploymentPrefix=2001:db8::/129 + IPv6RapidDeploymentPrefix=1.2.3.4 + IPv6RapidDeploymentPrefix=2001:db8::/ + """.trimIndent() + assertHighlights("file.netdev", file, 4) + } + + @Test + fun testPdPrefixHintValid() { + // language="unit file (systemd)" + val file = """ + [DHCPv6] + PrefixDelegationHint=2001:db8:: + PrefixDelegationHint=2001:db8::/56 + PrefixDelegationHint=2001:db8::/0b1 + PrefixDelegationHint=2001:db8::/128 + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testPdPrefixHintInvalid() { + // language="unit file (systemd)" + val file = """ + [DHCPv6] + PrefixDelegationHint=2001:db8::/0 + PrefixDelegationHint=2001:db8::/129 + PrefixDelegationHint=1.2.3.4/24 + """.trimIndent() + assertHighlights("file.network", file, 3) + } + + @Test + fun testIpv6PrefixPrefixValid() { + // language="unit file (systemd)" + val file = """ + [IPv6Prefix] + Prefix=2001:db8:: + Prefix=2001:db8::/0 + Prefix=2001:db8::/64 + Prefix=2001:db8::/0x40 + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testIpv6PrefixPrefixInvalid() { + // language="unit file (systemd)" + val file = """ + [IPv6Prefix] + Prefix=2001:db8::/129 + Prefix=2001:db8::/abc + Prefix=1.2.3.4/24 + """.trimIndent() + assertHighlights("file.network", file, 3) + } + + @Test + fun testAddressScopeValid() { + // language="unit file (systemd)" + val file = """ + [Address] + Scope=global + Scope=site + Scope=link + Scope=host + Scope=nowhere + Scope=0 + Scope=200 + Scope=255 + Scope=0x10 + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testAddressScopeInvalid() { + // language="unit file (systemd)" + val file = """ + [Address] + Scope=SITE + Scope=Global + Scope=256 + Scope=-1 + Scope=universe + """.trimIndent() + assertHighlights("file.network", file, 5) + } + + @Test + fun testBridgeFdbMaxLearnedValid() { + // language="unit file (systemd)" + val file = """ + [Bridge] + FDBMaxLearned=0 + FDBMaxLearned=0x100 + FDBMaxLearned=4294967295 + FDBMaxLearned=0o400 + """.trimIndent() + assertHighlights("file.netdev", file, 0) + } + + @Test + fun testBridgeFdbMaxLearnedInvalid() { + // language="unit file (systemd)" + val file = """ + [Bridge] + FDBMaxLearned=4294967296 + FDBMaxLearned=-1 + FDBMaxLearned=abc + """.trimIndent() + assertHighlights("file.netdev", file, 3) + } + + @Test + fun testCakeFirewallMarkValid() { + // language="unit file (systemd)" + val file = """ + [CAKE] + FirewallMark=1 + FirewallMark=0xff00 + FirewallMark=4294967295 + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testCakeFirewallMarkInvalid() { + // language="unit file (systemd)" + val file = """ + [CAKE] + FirewallMark=0 + FirewallMark=4294967296 + FirewallMark=-1 + FirewallMark=abc + """.trimIndent() + assertHighlights("file.network", file, 4) + } + + @Test + fun testCodelPacketLimitValid() { + // language="unit file (systemd)" + val file = """ + [ControlledDelay] + PacketLimit=0 + PacketLimit=4294967295 + PacketLimit=0x10 + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testCodelPacketLimitInvalid() { + // language="unit file (systemd)" + val file = """ + [ControlledDelay] + PacketLimit=4294967296 + PacketLimit=-1 + """.trimIndent() + assertHighlights("file.network", file, 2) + } + + @Test + fun testPfifoHeadDropPacketLimitValid() { + // language="unit file (systemd)" + val file = """ + [PFIFOHeadDrop] + PacketLimit=0 + PacketLimit=0x100 + PacketLimit=4294967295 + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testPfifoHeadDropPacketLimitInvalid() { + // language="unit file (systemd)" + val file = """ + [PFIFOHeadDrop] + PacketLimit=4294967296 + PacketLimit=-1 + """.trimIndent() + assertHighlights("file.network", file, 2) + } + + @Test + fun testQfqWeightValid() { + // language="unit file (systemd)" + val file = """ + [QuickFairQueueingClass] + Weight=1 + Weight=1023 + Weight=1024 + Weight=0x400 + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testQfqWeightInvalid() { + // language="unit file (systemd)" + val file = """ + [QuickFairQueueingClass] + Weight=0 + Weight=1025 + Weight=-1 + """.trimIndent() + assertHighlights("file.network", file, 3) + } + + @Test + fun testFqQuantumBytesValid() { + // language="unit file (systemd)" + val file = """ + [FairQueueing] + QuantumBytes=10.5M + QuantumBytes=10.M + QuantumBytes=0 + QuantumBytes=4294967295 + QuantumBytes=3G1023M + QuantumBytes=1G 512M + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testFqQuantumBytesInvalid() { + // language="unit file (systemd)" + val file = """ + [FairQueueing] + QuantumBytes=1T + QuantumBytes=4G + QuantumBytes=4294967296 + QuantumBytes=5k + QuantumBytes=-1 + """.trimIndent() + assertHighlights("file.network", file, 5) + } + + @Test + fun testCakeRttSecValid() { + // language="unit file (systemd)" + val file = """ + [CAKE] + RTTSec=100ms + RTTSec=71min + RTTSec=1us + RTTSec=4294967295us + RTTSec=1min 30s + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testCakeRttSecInvalid() { + // language="unit file (systemd)" + val file = """ + [CAKE] + RTTSec=2hour + RTTSec=infinity + RTTSec=0 + RTTSec=72min + RTTSec=4294967296us + """.trimIndent() + assertHighlights("file.network", file, 5) + } + + @Test + fun testCanDataTimeQuantaNSecValid() { + // language="unit file (systemd)" + val file = """ + [CAN] + DataTimeQuantaNSec=1 + DataTimeQuantaNSec=4294967295 + DataTimeQuantaNSec=4s + DataTimeQuantaNSec=4.2s + DataTimeQuantaNSec=1us + DataTimeQuantaNSec=5ns + DataTimeQuantaNSec=1ms 500us + DataTimeQuantaNSec=1.5 + DataTimeQuantaNSec=12.34 .56 + DataTimeQuantaNSec=1s.5 + DataTimeQuantaNSec=1 2 + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testCanDataTimeQuantaNSecInvalid() { + // language="unit file (systemd)" + val file = """ + [CAN] + DataTimeQuantaNSec=infinity + DataTimeQuantaNSec=0 + DataTimeQuantaNSec=4294967296 + DataTimeQuantaNSec=4.3s + DataTimeQuantaNSec=5s + DataTimeQuantaNSec=12.34.56 + DataTimeQuantaNSec=1+2 + DataTimeQuantaNSec=1.5+2 + """.trimIndent() + assertHighlights("file.network", file, 8) + } + + @Test + fun testNetworkDnsValid() { + // language="unit file (systemd)" + val file = """ + [Network] + DNS=10.10.10.10#aaa.com 10.10.10.11:1111#bbb.com [1111:2222::3333]:1234#ccc.com + DNS=1.2.3.4#a#b + DNS=1.2.3.4:0x35 + DNS=[::1]:53 + DNS=::1#x + DNS=1.2.3.4:+53 + DNS=::ffff:1.2.3.4 + DNS=1.2.3.4 + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testNetworkDnsInvalid() { + // language="unit file (systemd)" + val file = """ + [Network] + DNS=1.2.3.4# + DNS=1.2.3.4:0 + DNS=[::1] + DNS=1.2.3.4:65536 + DNS=1.2.3.4 1.2.3.4:0 + """.trimIndent() + assertHighlights("file.network", file, 5) + } + + @Test + fun testNetDevMacAddressValid() { + // language="unit file (systemd)" + val file = """ + [NetDev] + MACAddress=none + MACAddress=00:11:22:33:44:55 + MACAddress=0011.2233.4455 + MACAddress=00-11-22-33-44-55 + """.trimIndent() + assertHighlights("file.netdev", file, 0) + } + + @Test + fun testNetDevMacAddressInvalid() { + // language="unit file (systemd)" + val file = """ + [NetDev] + MACAddress=NONE + MACAddress=None + MACAddress=001122334455 + MACAddress=00:11:22:33:44 + """.trimIndent() + assertHighlights("file.netdev", file, 4) + } + + @Test + fun testRoutePrefixPreferenceValid() { + // language="unit file (systemd)" + val file = """ + [IPv6RoutePrefix] + Preference=normal + Preference=default + Preference=medium + Preference=high + Preference=low + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testRoutePrefixPreferenceInvalid() { + // language="unit file (systemd)" + val file = """ + [IPv6RoutePrefix] + Preference=Normal + Preference=HIGH + Preference=highest + """.trimIndent() + assertHighlights("file.network", file, 3) + } + + @Test + fun testAdActorSystemValid() { + // language="unit file (systemd)" + val file = """ + [Bond] + AdActorSystem=02:23:45:67:89:ab + AdActorSystem=0223.4567.89ab + AdActorSystem=2:2:3:4:5:6 + AdActorSystem=00:00:00:00:00:01 + """.trimIndent() + assertHighlights("file.netdev", file, 0) + } + + @Test + fun testAdActorSystemInvalid() { + // language="unit file (systemd)" + val file = """ + [Bond] + AdActorSystem=01:23:45:67:89:ab + AdActorSystem=00:00:00:00:00:00 + AdActorSystem=0123.4567.89ab + AdActorSystem=123.4567.89ab + AdActorSystem=03-00-00-00-00-00 + """.trimIndent() + assertHighlights("file.netdev", file, 5) + } + + @Test + fun testRelayTargetValid() { + // language="unit file (systemd)" + val file = """ + [DHCPServer] + RelayTarget=1.2.3.4 + RelayTarget=10.0.0.1 + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testRelayTargetInvalid() { + // language="unit file (systemd)" + val file = """ + [DHCPServer] + RelayTarget=0.0.0.0 + RelayTarget=::1 + """.trimIndent() + assertHighlights("file.network", file, 2) + } + + @Test + fun testIpv6AcceptRaTokenValid() { + // language="unit file (systemd)" + val file = """ + [IPv6AcceptRA] + Token=static:::1 + Token=::1:0:0:0 + Token=prefixstable:2001:db8:: + Token=prefixstable::: + Token=eui64 + Token=static:::1:0:0:0 + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testIpv6AcceptRaTokenInvalid() { + // language="unit file (systemd)" + val file = """ + [IPv6AcceptRA] + Token=static::: + Token=:: + Token=static:2001:db8:: + Token=2001:db8::1:0:0:0:0 + """.trimIndent() + assertHighlights("file.network", file, 4) + } + + @Test + fun testNetworkAddressValid() { + // language="unit file (systemd)" + val file = """ + [Network] + Address=2001:db8::1 + Address=0.0.0.0 + Address=:: + Address=1.2.3.4/0x10 + Address=2001:db8::1/0x40 + Address=1.2.3.4/ 8 + Address=2001:db8::1/0b 1 + """.trimIndent() + assertHighlights("file.network", file, 0) + } + + @Test + fun testNetworkAddressInvalid() { + // language="unit file (systemd)" + val file = """ + [Network] + Address=1.2.3.4/33 + Address=2001:db8::1/129 + Address=2001:db8::1/-1 + """.trimIndent() + assertHighlights("file.network", file, 3) + } +} From 2c66c5d9babbf3c1ba15169a6aa5b41cdfb32904 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sat, 24 Oct 2026 15:50:52 -0700 Subject: [PATCH 4/4] fix: align Condition*/Assert* validators with systemd's condition checks (#568) Re-checked every flagged condition value against systemd's runtime condition code, fixed the values that can work, and pinned the ones that can never work (for example ConditionVirtualization= with several words) with tests. Part of #568. Verdicts were checked against systemd's own parsers built at the pinned commit 0f0682b773 (condition values against src/shared/condition.c, since systemd only evaluates them at unit start). Values systemd accepts at parse time but rejects only later are accepted (see #565). Co-Authored-By: Claude Opus 5.5 (1M context) --- ...ParseUnitConditionCapabilityOptionValue.kt | 22 ++- ...ConfigParseUnitConditionCpusOptionValue.kt | 25 +++- .../ai/Fix568ConditionMarkerVerdictsTest.kt | 140 ++++++++++++++++++ 3 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/Fix568ConditionMarkerVerdictsTest.kt diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitConditionCapabilityOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitConditionCapabilityOptionValue.kt index cfdeceff..eb2a5f9b 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitConditionCapabilityOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitConditionCapabilityOptionValue.kt @@ -3,8 +3,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.AlternativeCombinator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.CAPABILITY_NAME +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SafeAtoTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.conditionString -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.unsignedNumber /* * [Unit] ConditionCapability= / AssertCapability=. @@ -24,7 +24,10 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * 0…CAP_LIMIT (62) or a single capability name. One capability, not a list. * * The number is tried first so the numeric form isn't swallowed by the name terminal's lenient shape - * match, and it is an [unsignedNumber] because capability_from_name reads it with safe_atoi(). + * match. capability_from_name reads it with safe_atoi() and refuses anything outside 0…CAP_LIMIT, so it is a + * signed base-0 [SafeAtoTerminal] bounded to 0…62 (#568): `+3`, `0b11`, `0o3` and `-0` are capabilities too. + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/capability-list.c#L45-L58 + * https://github.com/systemd/systemd/blob/0f0682b773/src/basic/capability-util.h#L17 * * Case: the name lookup is gperf-generated with `--ignore-case` * (src/basic/meson.build: `gperf … '--ignore-case'`), and capability_to_name() actually renders the @@ -34,5 +37,18 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram */ class ConfigParseUnitConditionCapabilityOptionValue : SimpleGrammarOptionValues( "config_parse_unit_condition_string", - conditionString(AlternativeCombinator(unsignedNumber(63), CAPABILITY_NAME)) + conditionString( + AlternativeCombinator( + SafeAtoTerminal( + signed = true, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 62L, + acceptsWhitespace = true, + acceptsMinus = true, + ), + CAPABILITY_NAME, + ) + ) ) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitConditionCpusOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitConditionCpusOptionValue.kt index 8a4780c1..c016cdf7 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitConditionCpusOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/ai/ConfigParseUnitConditionCpusOptionValue.kt @@ -1,9 +1,9 @@ package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.SimpleGrammarOptionValues +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SafeAtoTerminal import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.comparisonPrefixed import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.conditionString -import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.unsignedNumber /* * [Unit] ConditionCPUs= / AssertCPUs=. @@ -18,11 +18,26 @@ import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.gram * * C function: config_parse_unit_condition_string with ltype CONDITION_CPUS. condition_test_cpus * (src/shared/condition.c) reads an optional comparison operator via parse_compare_operator(&p, 0) — - * defaulting to `>=` when none is given — and then the number of CPUs with safe_atou(). safe_atou() - * passes base 0 to strtoul(), so the count may be written decimal, `0x` hex or leading-zero octal; - * [unsignedNumber] models exactly that, bounded by the `unsigned` range. + * defaulting to `>=` when none is given — and then the number of CPUs with safe_atou() (base 0, no flags): + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/condition.c#L417-L441 + * + * [SafeAtoTerminal] models safe_atou() exactly (#568): besides decimal, `0x` hex and leading-zero octal it + * also takes a `+` sign, `-0`, and mangle_base()'s `0b`/`0o` prefixes, all of which condition_test_cpus + * accepts at unit start. The bound is the `unsigned` range. */ class ConfigParseUnitConditionCpusOptionValue : SimpleGrammarOptionValues( "config_parse_unit_condition_string", - conditionString(comparisonPrefixed(unsignedNumber(4_294_967_296L))) + conditionString( + comparisonPrefixed( + SafeAtoTerminal( + signed = false, + base = 0, + flags = emptySet(), + minInclusive = 0L, + maxInclusive = 4_294_967_295L, + acceptsWhitespace = true, + acceptsMinus = true, + ) + ) + ) ) diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/Fix568ConditionMarkerVerdictsTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/Fix568ConditionMarkerVerdictsTest.kt new file mode 100644 index 00000000..c7e6b15e --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/ai/Fix568ConditionMarkerVerdictsTest.kt @@ -0,0 +1,140 @@ +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 + +/** + * Pins the #568 decisions for `Condition…=` / `Assert…=` values that systemd's config parser stores + * without complaint but that the runtime check in src/shared/condition.c can never satisfy. + * + * config_parse_unit_condition_string strips at most ONE `|` and then at most ONE `!`, each followed by + * optional whitespace, and stores everything after that verbatim as the parameter: + * https://github.com/systemd/systemd/blob/0f0682b773/src/core/load-fragment.c#L3167-L3173 + * + * So `!! x`, `!| x`, `|| x`, `! | x` and `| | x` do not stack markers; the second marker becomes the + * first character of the parameter. None of the condition_test_* checks below accept a parameter that + * starts with `!` or `|`: they return false (never matches) or an error, which + * condition_test_list_impl treats as a failed/undeterminable condition: + * https://github.com/systemd/systemd/blob/0f0682b773/src/shared/condition.c#L1465-L1529 + * + * condition.c, not the parser, is the oracle for conditions, so these stay flagged. + */ +class Fix568ConditionMarkerVerdictsTest : AbstractUnitFileTest() { + + private fun highlights(text: String): Int { + setupFileInEditor("f.service", text) + enableInspection(InvalidValueInspection::class.java) + return myFixture.doHighlighting().size + } + + private fun assertAccepted(vararg lines: String) = + assertEquals(lines.joinToString(), 0, highlights("[Unit]\n" + lines.joinToString("\n") + "\n")) + + // The classic engine can split a partial match into more than one region, so require "at least one". + private fun assertRejected(line: String) = + assertTrue(line, highlights("[Unit]\n$line\n") >= 1) + + @Test + fun testSingleMarkersWithOptionalWhitespaceAreAccepted() { + assertAccepted( + "AssertACPower=! true", + "AssertACPower=| true", + "AssertACPower=| ! true", + "AssertArchitecture=|!alpha", + "AssertCapability=| ! CAP_CHOWN", + "AssertSecurity=!audit", + "AssertVirtualization=| ! vm", + "AssertControlGroupController=| ! cpu", + ) + } + + @Test + fun testDoubledMarkersOnBooleanConditionsAreRejected() { + // condition_test_ac_power: parse_boolean("! true") / ("| true") fails -> -EINVAL. + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/condition.c#L850-L862 + assertRejected("AssertACPower=!! true") + assertRejected("AssertACPower=! | true") + assertRejected("AssertACPower=| | true") + } + + @Test + fun testDoubledMarkersOnNameConditionsAreRejected() { + // condition_test_architecture_parameter: architecture_from_string("| alpha") fails -> false. + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/condition.c#L561-L579 + assertRejected("AssertArchitecture=!! alpha") + assertRejected("AssertArchitecture=!| alpha") + assertRejected("AssertArchitecture=|| alpha") + // condition_test_capability: capability_from_name("! CAP_CHOWN") fails -> -EINVAL. + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/condition.c#L905-L923 + assertRejected("AssertCapability=!! CAP_CHOWN") + assertRejected("AssertCapability=!| CAP_CHOWN") + assertRejected("AssertCapability=|| CAP_CHOWN") + // condition_test_security: only exact names match, anything else -> false. + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/condition.c#L874-L903 + assertRejected("AssertSecurity=!! audit") + assertRejected("AssertSecurity=!| audit") + assertRejected("AssertSecurity=|| audit") + // condition_test_virtualization: "| true" is no boolean, category or id -> false. + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/condition.c#L530-L559 + assertRejected("AssertVirtualization=!! true") + assertRejected("AssertVirtualization=! | true") + assertRejected("AssertVirtualization=| | true") + assertRejected("AssertVirtualization=true false") + } + + @Test + fun testDoubledMarkersOnControlGroupControllerAreRejected() { + // The one subtle case: cg_mask_from_string splits "! cpu" into words and silently skips the + // unknown "!", so `!! cpu` behaves exactly like `!cpu` rather than as a double negation. + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/cgroup-util.c#L1661-L1686 + // The stray marker is a no-op word, like any other unknown controller name, which this validator + // already flags (an all-unknown list makes the check unconditionally true, condition.c#L500-L506): + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/condition.c#L482-L509 + assertRejected("AssertControlGroupController=!! cpu") + assertRejected("AssertControlGroupController=!| cpu") + assertRejected("AssertControlGroupController=|| cpu") + assertRejected("AssertControlGroupController=invalid") + } + + @Test + fun testCpusNumberIsReadExactlyAsSafeAtou() { + // condition_test_cpus reads the count with safe_atou() (base 0, no flags), which takes a sign, `-0` + // and mangle_base()'s 0b/0o prefixes as well as 0x/leading-zero octal: + // https://github.com/systemd/systemd/blob/0f0682b773/src/shared/condition.c#L417-L441 + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/parse-util.c#L403-L478 + assertAccepted( + "ConditionCPUs=+4", + "ConditionCPUs=0b101", + "ConditionCPUs=0o7", + "ConditionCPUs=-0", + "ConditionCPUs=0x4", + "ConditionCPUs=010", + "ConditionCPUs=>=+2", + "ConditionCPUs=4294967295", + "AssertCPUs=| ! 0B11", + ) + assertRejected("ConditionCPUs=-1") // -ERANGE + assertRejected("ConditionCPUs=4294967296") // does not fit `unsigned` + assertRejected("ConditionCPUs=08") // not octal + assertRejected("ConditionCPUs=0b2") + assertRejected("ConditionCPUs=+-1") + } + + @Test + fun testCapabilityNumberIsReadExactlyAsSafeAtoi() { + // capability_from_name tries safe_atoi() first and refuses numbers outside 0..CAP_LIMIT (62): + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/capability-list.c#L45-L58 + // https://github.com/systemd/systemd/blob/0f0682b773/src/basic/capability-util.h#L17 + assertAccepted( + "ConditionCapability=+3", + "ConditionCapability=0b11", + "ConditionCapability=0o3", + "ConditionCapability=-0", + "ConditionCapability=0x3e", + ) + assertRejected("ConditionCapability=-1") + assertRejected("ConditionCapability=0x3f") + assertRejected("ConditionCapability=0b2") + } +}