diff --git a/Plugin/unraid/Tools/conf_widget_check.php b/Plugin/unraid/Tools/conf_widget_check.php new file mode 100644 index 0000000..701640f --- /dev/null +++ b/Plugin/unraid/Tools/conf_widget_check.php @@ -0,0 +1,210 @@ + $l) { + if (preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/u', $l, $m)) $hdrs[] = [$i, trim($m[1])]; +} + +$list = in_array('--list', $argv, true); +$tally = []; $n = 0; $enums = []; $ranges = []; +foreach ($hdrs as $k => [$i, $name]) { + $end = $hdrs[$k + 1][0] ?? count($lines); + $rows = _vv_conf_parse_field_range($lines, $i + 1, $end, 'master.conf'); + if ($list && $rows) printf("\n── %s\n", $name); + foreach ($rows as $x) { + $n++; + $w = $x['widget'] ?? '?'; + $tally[$w] = ($tally[$w] ?? 0) + 1; + if ($w === 'enum') $enums[] = $x['key'] . ' = ' . implode(' | ', array_column($x['choices'], 'value')); + if (isset($x['min'])) $ranges[] = $x['key'] . ' ' . $x['min'] . '-' . $x['max']; + if ($list) { + $e = []; + if (isset($x['unit'])) $e[] = $x['unit']; + if (isset($x['min'])) $e[] = $x['min'] . '-' . $x['max']; + printf(" %-34s %-7s %s\n", $x['key'], $w, $e ? '[' . implode(' ', $e) . ']' : ''); + } + } +} + +printf("\nlive master.conf: %d fields across %d sections\n", $n, count($hdrs)); +ksort($tally); +foreach ($tally as $k => $v) printf(" %-8s %d\n", $k, $v); +if ($enums) { echo "\nchoice lists found:\n"; foreach ($enums as $e) echo " $e\n"; } +if ($ranges) { echo "\nbounded numbers found:\n"; foreach ($ranges as $e) echo " $e\n"; } + +exit($fail ? 1 : 0); diff --git a/Plugin/unraid/include/confform.php b/Plugin/unraid/include/confform.php index 44b8277..0917498 100644 --- a/Plugin/unraid/include/confform.php +++ b/Plugin/unraid/include/confform.php @@ -234,6 +234,112 @@ function vv_conf_parse_subsection(string $raw, string $subName, string $filename return _vv_conf_parse_field_range($lines, $start, $end, $filename) ?: null; } +// Which control a field should be drawn with. Derived, never stored — the conf file stays the +// only schema, so this reads what is already written there rather than asking for the file to be +// annotated first. A key added tomorrow gets the right control with no code change and no markup. +// +// The order matters: an explicit statement in the comment beats a guess from the value, because +// the guess cannot tell "false" the boolean from "false" the string a script compares against. +// +// Nothing here validates. Consistent with the rest of this file, it decides shape and leaves +// correctness to the consuming script — a number field with min/max is a courtesy to whoever is +// typing, not a promise that the value is sensible. +function _vv_conf_widget(string $key, string $value, string $type, string $desc, array $descLines): array { + // Arrays get the block editor whatever they contain. One value per line is already the right + // control for a list, and there is no second guess worth making. + if ($type !== 'scalar') return ['widget' => 'lines']; + + // Secrets never render in the clear, whatever their value looks like. Reuses the same rule + // the audit log redacts by, so a key cannot be a secret in the log and plain text in a form. + if (vv_conf_key_is_secret($key)) return ['widget' => 'secret']; + + // The convention already in the confs: each choice on its own comment line, quoted, then a + // dash and what it does. It was written as documentation and happens to be a complete enum + // declaration, which is exactly the property this file is built around. + // + // # "warn" — log warning and continue + // # "abort" — refuse to continue (strict) + // UNRAID_VERSION_MISMATCH_ACTION="warn" + $choices = []; + foreach ($descLines as $line) { + if (preg_match('/^"([^"]+)"\s*(?:[—–-]\s*(.*))?$/u', trim($line), $m)) { + $choices[] = ['value' => $m[1], 'hint' => trim($m[2] ?? '')]; + } + } + // A single quoted line is a quotation, not a choice list. + if (count($choices) > 1) { + // A value outside its own choice list is a misconfiguration, and dropping it to make the + // list tidy would change the setting the moment the form saved. It is offered as a choice + // instead, so the dropdown shows what is actually set and can also correct it. + $have = array_column($choices, 'value'); + if (!in_array($value, $have, true)) { + array_unshift($choices, ['value' => $value, 'hint' => 'current value — not one of the documented options']); + } + return ['widget' => 'enum', 'choices' => $choices]; + } + + // The other convention already in the confs: the choices listed inline after the value, either + // as the whole comment or after a colon. + // + // DIGEST_PROFILE="weekly" # always | smart | weekly + // SONARR_DISCOVERY_MONITOR_MODE="all" # monitor mode on add: all | future | first | none + // + // Anchored to the end of the comment and required to contain the current value. The pattern is + // loose enough to match ordinary prose containing a pipe, and "the value is one of these" is + // the only signal that separates a choice list from a sentence. + if (preg_match('/(?:^|:\s*)([A-Za-z0-9_.\-]+(?:\s*\|\s*[A-Za-z0-9_.\-]+)+)\s*$/', trim($desc), $m)) { + $opts = array_map('trim', explode('|', $m[1])); + if (count($opts) > 1 && in_array($value, $opts, true)) { + return ['widget' => 'enum', + 'choices' => array_map(fn($o) => ['value' => $o, 'hint' => ''], $opts)]; + } + } + + // The escape hatch, for a setting whose options are not already spelled out above it. Written + // to read as English so adding one does not turn the conf into a config language. + if (preg_match('/\bone of:\s*([^.;#]+)/i', $desc, $m)) { + $opts = array_values(array_filter(array_map('trim', preg_split('/\s*[,|]\s*/', $m[1])), 'strlen')); + if (count($opts) > 1) { + return ['widget' => 'enum', + 'choices' => array_map(fn($o) => ['value' => trim($o, '"\''), 'hint' => ''], $opts)]; + } + } + + $v = strtolower(trim($value)); + if ($v === 'true' || $v === 'false') return ['widget' => 'bool']; + + // A file mode is digits and is not a number. A spinner invites arrowing 755 to 756, and a + // number input normalises a leading zero away — so 0755 would save as 755, which is the same + // mode today and a different one the day anything reads it as octal. Kept as plain text. + if (preg_match('/(CHMOD|_MODE|PERM|UMASK)/', $key) && preg_match('/^0?[0-7]{3}$/', trim($value))) { + return ['widget' => 'text']; + } + + if (preg_match('/^-?\d+$/', trim($value))) { + $out = ['widget' => 'int']; + // "clamped to 1-50", "between 1 and 50". Already written in several confs, so the range + // that documents the setting also bounds the field. + if (preg_match('/(?:clamped to|between)\s*(-?\d+)\s*(?:-|to|and)\s*(-?\d+)/i', $desc, $r)) { + $out['min'] = (int) $r[1]; + $out['max'] = (int) $r[2]; + } + // The house style opens an inline comment with the unit — "# seconds — probe when + // resolving which node has Ollama" — so the unit is already there to be shown beside + // the box instead of buried in the description. + if (preg_match('/^\s*(seconds?|minutes?|hours?|days?|weeks?|months?|percent|%|[KMGT]B|' + . 'chars?|characters?|tokens?|conversations?|entries|items|lines|runs|' + . 'attempts|retries|consecutive)\b/i', $desc, $u)) { + $out['unit'] = strtolower($u[1]); + } + return $out; + } + + // A path gets a wider box and no monospace surprises. ${VAR}/... is the established idiom. + if (str_starts_with($value, '/') || str_contains($value, '${')) return ['widget' => 'path']; + + return ['widget' => 'text']; +} + // Parse all config fields between two line indices. Shared by vv_conf_parse_subsection() // (per-script editor) and vv_conf_all_groups() (full settings view). function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $filename): array { @@ -255,6 +361,10 @@ function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $ } $desc = implode(' ', $pendingDesc); + // Kept as lines as well as joined. The enum convention lives in the line breaks — one + // quoted choice per comment line — and joining them destroys the only thing that + // distinguishes a choice list from a paragraph that happens to contain quotes. + $descLines = $pendingDesc; $pendingDesc = []; // declare -A KEY=( @@ -297,17 +407,30 @@ function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $ // Scalar: KEY="value" or KEY=value if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*"([^"]*)"(?:\s+#\s*(.+))?$/', $line, $m)) { $fields[] = ['key' => $m[1], 'value' => $m[2], - 'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename]; + 'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename, + '_lines' => $descLines]; continue; } if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*([^(\n#]*?)(?:\s+#\s*(.+))?$/', $line, $m)) { $val = trim($m[2]); if ($val === '') continue; $fields[] = ['key' => $m[1], 'value' => $val, - 'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename]; + 'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename, + '_lines' => $descLines]; } } + // Widget is added in one pass rather than at each of the five construction sites above, so + // every field type goes through the same rules and a new type cannot quietly skip them. + foreach ($fields as &$f) { + $f += _vv_conf_widget((string) $f['key'], (string) $f['value'], (string) $f['type'], + (string) $f['desc'], (array) ($f['_lines'] ?? [])); + // Internal to this pass. Left in, it would ride out to the browser on every field as a + // second, subtly different copy of the description. + unset($f['_lines']); + } + unset($f); + return $fields; } @@ -367,8 +490,19 @@ function vv_conf_key_valid(string $key): bool { } // Credential-shaped keys are logged by name only. The audit log is the record that a change -// happened, not a second copy of the secret that changed. +// happened, not a second copy of the secret that changed. Also decides which fields render +// masked, so the same key cannot be redacted in the log and legible in a form. +// +// The match is deliberately loose — the second alternative is unanchored, so anything merely +// CONTAINING "token" or "pass" counts. Over-redacting a setting costs a little readability; +// under-redacting one puts a credential in a log file, so the bias is correct and stays. +// +// The exception exists because one family trips it on a word that means something else entirely: +// AI_TOKEN_* is LLM token accounting — a path, a row cap, a switch — and nothing there is a +// credential. Named as a literal prefix rather than by relaxing the rule, so this cannot widen +// into "token sometimes means tokens" and quietly expose a real one. function vv_conf_key_is_secret(string $key): bool { + if (str_starts_with($key, 'AI_TOKEN_')) return false; return (bool) preg_match('/(PASS|PASSWORD|SECRET|TOKEN|API_KEY|APIKEY|_KEY)$|(PASS|PASSWORD|SECRET|TOKEN|APIKEY)/i', $key); }