Files
Varaverk/Plugin/unraid/include/confform.php
T
Gmer4Lfe 8157673291 Add AUTH_STACK so the Auth tab follows the stack in force
Authentik is the likely destination and the page had Authelia and lldap wired in at every
level, so the seam goes in now: the panels and every endpoint action route off one conf value,
and a stack that cannot be driven yet says so rather than drawing controls with nothing behind.
2026-08-15 15:27:50 -04:00

1071 lines
59 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Maps each script to the conf subsections that configure it, parses those fields into
// form definitions, and writes edits back to the conf file. This is what lets the
// scheduler page edit a script's settings without the user opening master.conf.
//
// DESIGN PRINCIPLES
// Section headers in the conf are the schema.
// The map keys off the literal `# ━━━ Name ━━━` and `# ── Name ──` headers already in
// the conf files. Documentation structure and form structure are the same thing, so a
// new setting placed under an existing header appears in the UI with no code change.
//
// Edits are surgical, never a rewrite.
// Only the changed lines are replaced. Comments, ordering, spacing and every unrelated
// value survive untouched — these files are hand-maintained and heavily commented, and
// a regenerating writer would destroy that.
//
// Structure only; values are not validated.
// Consistent with conf_upgrade.sh, this reconciles shape and leaves correctness to the
// consuming script. Shape is enforced — the file must parse, source, and read back the
// value that was asked for. Whether 300 is a sensible timeout is still not this file's
// question.
//
// One guarded write path, not one set of guards per caller.
// vv_conf_edit() owns the lock, the backup, the validation and the audit line, and every
// conf write in the plugin goes through it — the Settings form, both raw editors, the
// flag toggles, orchestrator membership, script moves and reorders, the rsync window
// arrays, the docker folder map and first-run setup. vv_write_conf_raw() has exactly one
// caller left, inside vv_conf_edit() itself.
//
// A caller that reaches past it gets tmp + rename and nothing else: no backup, no bash -n,
// no read-back, no audit line. That is how this started — seven files each with their own
// partial idea of what a safe conf write was, two of them carrying a copy-pasted bash -n
// block that failed open.
//
// The rewrite happens inside the lock, or it proves nothing moved.
// $mutate receives the current contents, so a caller that can rebuild from them has no
// window at all. Callers that must assemble the result first — the ones driven by a form
// payload — compare against what they read and return null if it no longer matches,
// which abandons the write rather than reverting a concurrent edit.
//
// OPERATIONAL SAFEGUARDS
// An unmatched section yields no fields rather than a wrong write.
// If the named subsection is not found the field list comes back empty and nothing is
// written. Guessing at a target line in a conf file is how an unrelated setting gets
// overwritten.
//
// Writes are confined to the parsed line range.
// Each field carries the exact line it came from, so a write cannot land outside the
// subsection it was read from.
//
// A value is shell source code, so command substitution is refused outright.
// Scalars are written inside double quotes and array values are spliced in verbatim, so
// $(...) or a backtick in a value runs as root in every script that sources the conf. No
// conf in this repo uses either, so rejecting them costs nothing. $VAR and ${VAR} stay
// legal — AI_DATA_DIR="${DATA_DIR}/ai" is the established idiom, and a reference resolves
// to a value where a substitution runs a program.
//
// Nothing is written without a backup in hand.
// The previous contents are copied to CONF_BACKUP_DIR/<file>.<stamp> first, and a backup
// that cannot be taken cancels the write. The confs are gitignored, so that directory is
// the entire recovery story — there is no history to revert to.
//
// Validated, then verified, then rolled back on failure.
// bash -n proves the candidate parses; sourcing the installed file and reading the keys
// back proves the values survived quoting. A scalar that does not read back as the value
// requested restores the backup. A conf that parses cleanly and holds the wrong string is
// the failure a syntax check cannot see.
//
// Held under an exclusive lock for the whole read-modify-write.
// Two concurrent savers would otherwise read the same original, and the second rename
// would discard the first one's change without either reporting a failure.
//
// Every outcome is logged, and secrets are logged by name only.
// LOG_DIR/conf_changes.log records applied, rejected, failed and rolled-back alike. A
// credential-shaped key logs value=<redacted> — the log proves a change happened, it is
// not a second copy of the secret.
//
// EXPORTS
// vv_conf_has_sections() does this script have an editable conf section
// vv_conf_parse_subsection() fields within one named subsection
// vv_conf_all_groups() every mapped group
// vv_conf_fields_for_script() form definition for one script
// vv_conf_write_changes() apply edits back to the conf file
//
// CONFIGURATION
// CONF_DIR master.conf and host*.conf are the read and write targets
// CONF_BACKUP_DIR DATA_DIR/Backups/Confs — pre-write copies, 0700
// LOG_DIR conf_changes.log is written here
// CONF_BACKUP_RETAIN backups kept per conf file (default 30)
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/config.php';
// confform.php — script→conf-section mapping, field parsing, and write-back.
// Which page shows conf sections by subject rather than by script, and the route a person would
// be told to follow to reach it. Declared once because two things need it and they must agree:
// the page itself builds its ?sections= query from this, and Tools/ui_map_build.php turns it into
// navigation instructions the assistant can give. A route written down in only one of those two
// places is a route that goes stale the first time a card moves.
//
// `match` is the whole-word needle matched against section headers — see api/confform.php.
const VV_UI_SECTION_SURFACES = [
[
'match' => 'ai',
'tab' => 'AI',
'route' => 'AI tab → Settings → Configuration',
],
// Subject pages first, so a setting named after the page you are already on is found there
// rather than at the bottom of a list of a hundred and twenty. The catch-all below still
// carries all of them, and the map lists both routes because both are true.
['match' => 'watchdog', 'tab' => 'Watchdog',
'route' => 'Watchdog tab → Watchdog settings'],
['match' => 'rsync', 'tab' => 'Rsync',
'route' => 'Rsync tab → Rsync settings'],
['match' => 'fallback|tier', 'tab' => 'Fallback',
'route' => 'Fallback tab → Fallback settings'],
// The tab covers the whole media pipeline now, not only acquisition: the arrs, the jobs that
// run over the same files (play state, permissions, the cleaner), and the servers that play
// them. These sections were reachable only through the Settings catch-all, which is a long way
// to go for a setting named after the page you are already looking at.
['match' => 'arr|sonarr|radarr|lidarr|media|play state|emby|jellyfin|plex', 'tab' => 'Media Stack',
'route' => 'Media Stack tab → Media settings'],
['match' => 'docker', 'tab' => 'Docker',
'route' => 'Docker tab → Docker settings'],
// NPM, lldap and Authelia are the three services the Auth tab drives, and Certificate Monitor
// is what its Certs panel reports. The credentials in particular belong on the page that fails
// without them: a blank NPM_USER surfaces there as a refused login, and the fix is two panels
// away rather than in a hundred-and-twenty-field catch-all.
['match' => 'Auth Stack|NginxProxyManager|lldap|Authelia|Certificate Monitor', 'tab' => 'Auth',
'route' => 'Auth tab → Auth settings'],
// Everything that is not structurally excluded. The catch-all exists so no ordinary setting
// is reachable only by editing a file — a settings page whose answer to a third of the conf
// is "open it over SSH" is not a settings page.
[
'match' => '*',
'tab' => 'Settings',
'route' => 'Settings tab → All settings',
],
[
'match' => 'partnership',
'tab' => 'Partnership',
// Array fields only — the card is one collapsible block per list, because these are
// container lists dozens of lines long and a flat form would be unreadable.
'route' => 'Partnership tab → Array Settings',
],
];
// Sections deliberately kept out of every settings surface, and why. Not a security boundary —
// vv_conf_edit() still guards the write and the raw editor can still reach them. This is about
// what belongs in a form: these hold the machine's identity and the roots everything else is
// derived from, and a text box next to a Save button is the wrong shape for a value that decides
// whether the server recognises itself on next boot.
//
// The reason travels with the exclusion because the UI map prints it. "No route" invites someone
// to add one; "no route, because changing this moves the entire data tree" does not.
const VV_UI_SECTION_EXCLUDE = [
'HOST IDENTITIES' =>
'HOST1 and HOST2 are what detect_hosts() matches the local hostname against. Editing one '
. 'here would change which machine this believes it is.',
'SHARED HOST CONFIGURATION' =>
'DATA_DIR is the single on-disk root — move it and the whole tree follows, including the '
. 'state the running scripts are holding open.',
'Cache Roots' =>
'The tmpfs half. These must never point at flash, and nothing in a form conveys that.',
'State Files' =>
'Paths to live state databases, derived from STATE_DIR. Repointing one orphans the state '
. 'a watchdog is mid-way through writing.',
'GIT / REPO' =>
'Where the plugin updates itself from. Wrong here means the next pull is the last one.',
];
// Pages that edit named keys rather than whole sections — a purpose-built control for one
// setting, not a form over a conf section. Only the route is declared: the keys themselves are
// read out of the page source by Tools/ui_map_build.php, so a control added or removed changes
// the map without anyone remembering to update a list.
//
// A page missing from here is not broken; its settings simply appear as conf-only in the map,
// which is the honest answer until someone gives it a route.
// `also` lists keys the page assembles at runtime, which no amount of reading the source will
// reveal — settings.php writes HOST1_DISCORD_WEBHOOK through a PHP variable holding the host
// slot. Written with HOSTN, substituted per machine, the same convention conf_upgrade uses. The
// generator checks each one exists and complains if it does not, so a stale entry is loud rather
// than a route to a control that was removed.
const VV_UI_PAGE_ROUTES = [
'settings.php' => ['route' => 'Settings tab',
'also' => ['HOSTN_DISCORD_WEBHOOK', 'HOSTN_STORAGE_MODE_INTERNAL']],
'fallback.php' => ['route' => 'Fallback tab'],
'rsync.php' => ['route' => 'Rsync tab'],
'arrs.php' => ['route' => 'Media Stack tab'],
'watchdog.php' => ['route' => 'Watchdog tab'],
'docker.php' => ['route' => 'Docker tab'],
];
// Map: script relative id → subsection names (must match # ━━━ Name ━━━ or # ── Name ── headers).
const VV_SCRIPT_CONF_SECTIONS = [
// Orchestrators
'Orchestrators/array_started.sh' => ['Array Start'],
'Orchestrators/array_stopping.sh' => ['Array Stop'],
'Orchestrators/watchdog_orchestrator.sh' => ['Watchdog Orchestrator', 'System Watchdog'],
'Orchestrators/critical_sync_maintenance.sh' => ['Critical Sync Maintenance', 'Critical Sync Shares'],
'Orchestrators/intermediate_sync_maintenance.sh' => ['Intermediate Sync Maintenance', 'Intermediate Sync Shares'],
'Orchestrators/daily_sync_maintenance.sh' => ['Daily Sync Maintenance', 'Daily Sync Shares'],
'Orchestrators/weekly_sync_maintenance.sh' => ['Weekly Sync Maintenance', 'Weekly Sync Shares'],
'Orchestrators/monthly_maintenance.sh' => ['Monthly Maintenance'],
'Orchestrators/transcode_management.sh' => ['Transcode Management', 'Transcode Manager', 'Transcode Server Array'],
// Docker Essentials
'Docker_Essentials/docker_daily_restart.sh' => ['Docker Daily Restart'],
'Docker_Essentials/docker_weekly_restart.sh' => ['Docker Weekly Restart'],
'Docker_Essentials/docker_network_connect.sh' => ['Docker Network Connect'],
'Docker_Essentials/downloaders_reset.sh' => ['Downloaders Reset', 'Downloaders'],
// Watchdogs
'Watchdogs/docker_watchdog.sh' => ['Docker Watchdog'],
'Watchdogs/resource_watchdog.sh' => ['Pressure Thresholds', 'Downloaders', 'SABnzbd Throttle', 'qBittorrent Throttle'],
'Watchdogs/System/network_watchdog.sh' => ['Network Watchdog'],
'Plugin/unraid/Watchdogs/System/webgui_watchdog.sh' => ['WebGUI Watchdog'],
// Media
'Media/media_cleaner.sh' => ['Media Cleaner'],
'Media/media_shares_permissions.sh' => ['Media Permissions'],
// Arrs Stack
'Arrs_Stack/radarr_cleanup.sh' => ['Arr Cleanup', 'Radarr'],
'Arrs_Stack/lidarr_cleanup.sh' => ['Arr Cleanup', 'Lidarr'],
'Arrs_Stack/sonarr_cleanup.sh' => ['Arr Cleanup', 'Sonarr'],
// Monitors
'Monitors/cert_monitor.sh' => ['Certificate Monitor'],
'Monitors/backup_verify.sh' => ['Backup Verify'],
'Monitors/smart_health.sh' => ['SMART Health'],
'Monitors/bandwidth_monitor.sh' => ['Bandwidth Monitor'],
'Monitors/emby_session_report.sh' => ['Emby Session Report', 'Emby'],
'Monitors/zfs_memory_snapshot.sh' => ['ZFS Report', 'ZFS Memory Snapshot'],
'Monitors/weekly_health_digest.sh' => ['Health Digest', 'Certificate Monitor'],
'Monitors/system_tuning_monitor.sh' => ['System Tuning Monitor'],
// ── Added after auditing the conf against this map ────────────────────────────────────────
// 99 named subsections existed; 35 were reachable. The rest were configured and documented in
// the script headers, but the Info & Settings view rendered no Config block at all — which
// reads as "this script has no settings" rather than "nobody mapped it". Only sections that
// unambiguously belong to one script are listed: a wrong mapping puts someone else's settings
// under a script and is worse than the gap it closes.
'Arrs_Stack/arr_download_orphan_cleaner.sh' => ['Download Orphan Cleaner (arr_download_orphan_cleaner.sh)'],
'Arrs_Stack/radarr_classification_scan.sh' => ['Arr Content Classification (radarr/sonarr_classification_scan.sh)', 'Radarr'],
'Arrs_Stack/sonarr_classification_scan.sh' => ['Arr Content Classification (radarr/sonarr_classification_scan.sh)', 'Sonarr'],
'Arrs_Stack/arr_full_rescan.sh' => ['Arr Full Library Rescan'],
'Arrs_Stack/arr_corruption_scan.sh' => ['Corruption Scan'],
'Arrs_Stack/arrs_failed_stalled_recovery.sh' => ['Arr Failed/Stalled Recovery', 'Arr Recovery Toggles'],
'Media/play_state_sync.sh' => ['Play State Sync', 'Play State Sync — Handback'],
'Orchestrators/sunday_morning_coffee_report.sh' => ['Sunday Morning Coffee Report'],
'Fallback/fallback_test.sh' => ['Failover Test'],
'Rsync/rsync.sh' => ['Rsync Enable/Disable', 'Rsync Defaults',
'Rsync Profile System', 'Rsync Merge Auto-Promote'],
'System_Essentials/server_reboot.sh' => ['Reboot', 'Emby'],
'System_Essentials/clear_logs.sh' => ['Clear Logs'],
'System_Essentials/inotify_tuning.sh' => ['inotify Tuning'],
'System_Essentials/docker_syslog_filter.sh' => ['Syslog Filter'],
'Plugin/unraid/System_Essentials/mover_stop.sh' => ['Mover'],
'Plugin/unraid/System_Essentials/php_fpm_max_children.sh' => ['PHP-FPM'],
'Watchdogs/stability_watchdog.sh' => ['Strike and Reboot Loop Settings',
'RAM Reboot Threshold', 'OOM Bypass Settings'],
'AI/ai_index.sh' => ['AI Retrieval Index', 'AI Master Switch', 'Ollama'],
'AI/ai_query.sh' => ['AI Retrieval Index', 'AI Master Switch', 'Ollama'],
'AI/ai_token_sync.sh' => ['AI Feature Toggles'],
'Plugin/unraid/Tools/ai_repair_sweep.sh' => ['AI Repair', 'AI Repair Findings',
'AI Master Switch'],
// ── Shared host sections ──────────────────────────────────────────────────────────────────
// A script's settings are not only the ones named after it. Anything talking to Lidarr reads
// the host's Lidarr block; anything reading playback reads Emby. Those blocks are where the
// URL and API key actually live, so a script that could not work without them was showing a
// Config form that omitted the very settings most likely to be wrong.
//
// Several scripts pointing at one section is intended, not duplication — the arr cleanups
// already shared 'Arr Cleanup' this way. Writes are confined to the parsed line range, so a
// section edited from two places still edits the same lines.
'Arrs_Stack/lidarr_missing_art.sh' => ['Lidarr'],
'Arrs_Stack/lidarr_release_fixer.sh' => ['Lidarr'],
'Arrs_Stack/lidarr_duplicate_artist_cleanup.sh' => ['Lidarr'],
'Arrs_Stack/playback_aware_lidarr_discovery.sh' => ['Lidarr', 'Emby'],
'Arrs_Stack/playback_aware_radarr_discovery.sh' => ['Radarr', 'Emby'],
'Arrs_Stack/playback_aware_sonarr_discovery.sh' => ['Sonarr', 'Emby'],
'Arrs_Stack/radarr_tmdb_removed.sh' => ['Radarr'],
'Arrs_Stack/sonarr_tvdb_removed.sh' => ['Sonarr'],
'Tools/emby_to_lidarr_sync.sh' => ['Emby', 'Lidarr'],
'Tools/emby_to_radarr_sync.sh' => ['Emby', 'Radarr'],
'Tools/emby_to_sonarr_sync.sh' => ['Emby', 'Sonarr'],
'Tools/emby_database_repair.sh' => ['Emby'],
'Tools/bulk_permissions_repair.sh' => ['Media Permissions'],
'Tools/trailer_folder_migration.sh' => ['Media Permissions'],
'Transcodes/transcode_manager.sh' => ['Transcode Manager', 'Transcode Server Array'],
// fallback.sh owns the whole failover surface: which tiers it covers, how long it waits, what
// it does to DDNS, and what it hands back. All four sections are its configuration.
'Fallback/fallback.sh' => ['Fallback Tiers — What HOSTN Wants Covered When Down',
'Tier Delays — HOSTN Outage Timers',
'Internet Loss', 'DDNS',
'Play State Sync — Handback'],
];
function vv_conf_has_sections(string $id): bool {
return !empty(VV_SCRIPT_CONF_SECTIONS[$id] ?? []);
}
// Parse fields from a named subsection in raw conf content.
// Headers accepted: # ━━━ Name ━━━ OR # ── Name ── (any mix of ━ ─ chars).
// Returns array of field defs, or null if subsection not found.
function vv_conf_parse_subsection(string $raw, string $subName, string $filename): ?array {
$lines = explode("\n", $raw);
$n = count($lines);
// Host slot normalised on both sides. Section titles carry the host they belong to — the
// template ships "Tier Delays — HOSTN Outage Timers" and conf_upgrade substitutes it to HOST1
// or HOST2 per machine — so a literal name in the map can only ever match one host, and on
// the other it silently finds nothing. Folding HOST<n> back to HOSTN makes one entry correct
// everywhere, which matters because this map is shared code and the conf files are not.
$slot = fn(string $s): string
=> preg_replace('/\bhost\d+\b/', 'hostn', mb_strtolower(trim(preg_replace('/\s+/', ' ', $s))));
$needle = $slot($subName);
$start = -1;
// /u is load-bearing, not tidiness. Without it the character class matches BYTES, and an
// em-dash (E2 80 94) is built entirely from bytes that also appear in ━ and ─. So a name
// containing one terminated early: "Tier Delays — HOSTN Outage Timers" captured as "Tier
// Delays", matched nothing, and every section with a dash in its title was unreachable —
// silently, because an unfound section is defined to return no fields.
for ($i = 0; $i < $n; $i++) {
if (!preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/u', $lines[$i], $m)) continue;
if ($slot($m[1]) === $needle) { $start = $i + 1; break; }
}
if ($start === -1) return null;
// End at next section/subsection line (3+ consecutive divider chars after #)
$end = $n;
for ($i = $start; $i < $n; $i++) {
if (preg_match('/^#\s*[━─═=]{3,}/', $lines[$i])) { $end = $i; break; }
}
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 {
$fields = [];
$pendingDesc = [];
for ($i = $start; $i < $end; $i++) {
$line = rtrim($lines[$i]);
if ($line === '' || $line === '#') { $pendingDesc = []; continue; }
// Pure comment line
if (preg_match('/^#\s*(.*)$/', $line, $cm)) {
$inner = trim($cm[1]);
if ($inner !== '' && !preg_match('/^[━─═=\-\s]+$/', $inner)) {
$pendingDesc[] = $inner;
}
continue;
}
$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=(
if (preg_match('/^(\s*)declare\s+-A\s+([A-Z_][A-Z0-9_]*)\s*=\s*\(/', $line, $m)) {
$indent = $m[1]; $key = $m[2];
$blockLines = [];
$j = $i + 1;
while ($j < $end && !preg_match('/^\s*\)\s*$/', $lines[$j])) {
$blockLines[] = rtrim($lines[$j]);
$j++;
}
$fields[] = ['key' => $key, 'value' => implode("\n", $blockLines),
'type' => 'assoc_array', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
$i = $j;
continue;
}
// KEY=( (array)
if (preg_match('/^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*\(/', $line, $m)) {
$indent = $m[1]; $key = $m[2];
// Single-line: KEY=( ... )
if (preg_match('/^[^(]*\(([^)]*)\)/', $line, $sm)) {
$fields[] = ['key' => $key, 'value' => $sm[1],
'type' => 'array_single', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
continue;
}
// Multi-line
$blockLines = [];
$j = $i + 1;
while ($j < $end && !preg_match('/^\s*\)\s*$/', $lines[$j])) {
$blockLines[] = rtrim($lines[$j]);
$j++;
}
$fields[] = ['key' => $key, 'value' => implode("\n", $blockLines),
'type' => 'array', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
$i = $j;
continue;
}
// Scalar: KEY="value", KEY='value' or KEY=value.
//
// One branch, and it unquotes rather than capturing between quotes. Two regexes did this
// — one for the quoted form, one for the bare form — and neither understood an escape, so
// a value containing \" fell through the first (the capture ended at the escaped quote and
// the tail no longer matched) into the second, which handed the form the value with its
// surrounding quotes still attached. Saving that back wrapped it in another pair.
//
// A secret now carries \$ and \` for exactly the reason vv_conf_quote_scalar() explains,
// which made the same class of bug reachable by typing an ordinary password: the form
// would have shown the backslashes and re-escaped them on every save.
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/', $line, $m)) {
$rest = $m[2];
// No value at all, or nothing but a comment. \s* above already ate the leading space,
// so a # in first position cannot be part of the value — unlike one further in, which
// is an ordinary character to bash and to the unquote below.
if ($rest === '' || $rest[0] === '#') continue;
$val = vv_conf_unquote($rest, $off);
// Whatever the value did not consume. The comment is found here rather than by a
// second pattern over the whole line, so a # inside a quoted password can never be
// mistaken for the start of one.
$cmt = preg_match('/^\s+#\s*(.+)$/', substr($rest, $off), $cm) ? trim($cm[1]) : '';
$fields[] = ['key' => $m[1], 'value' => $val,
'type' => 'scalar', 'desc' => $cmt ?: $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;
}
// Return ALL config groups (every named section + its fields) for a conf file.
// Enumerates header lines (# ━━━ Name ━━━ or # ── Name ──); each group runs from its
// header to the next named header so major sections (sandwiched in ===) capture their
// settings too. Empty groups (divider-only headers) are dropped.
function vv_conf_all_groups(string $filename): array {
$raw = vv_read_conf_raw($filename);
if ($raw === '') return [];
$lines = explode("\n", $raw);
$n = count($lines);
$headers = [];
for ($i = 0; $i < $n; $i++) {
// /u for the same reason as the locator above: without it a name containing an em-dash is
// truncated at the dash, and the full-settings view labels the group with half its title.
if (preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/u', $lines[$i], $m)) {
$headers[] = ['name' => trim(preg_replace('/\s+/', ' ', $m[1])), 'line' => $i];
}
}
$groups = [];
foreach ($headers as $idx => $h) {
$start = $h['line'] + 1;
$end = $headers[$idx + 1]['line'] ?? $n;
$fields = _vv_conf_parse_field_range($lines, $start, $end, $filename);
if ($fields) {
$groups[] = ['subsection' => $h['name'], 'file' => $filename, 'fields' => $fields];
}
}
return $groups;
}
// Return all conf groups (subsection + fields) for a script on the current host.
function vv_conf_fields_for_script(string $id): array {
$sectionNames = VV_SCRIPT_CONF_SECTIONS[$id] ?? [];
if (!$sectionNames) return [];
$groups = [];
foreach ($sectionNames as $name) {
foreach (vv_get_conf_files() as $filename) {
$fields = vv_conf_parse_subsection(vv_read_conf_raw($filename), $name, $filename);
if ($fields !== null) {
$groups[] = ['subsection' => $name, 'file' => $filename, 'fields' => $fields];
}
}
}
return $groups;
}
// A conf key must be a plain shell identifier. Every downstream use — the replacement regex,
// the source-verification subshell, the audit line — treats the key as trusted text, so it is
// validated once here rather than escaped differently in three places.
function vv_conf_key_valid(string $key): bool {
return (bool) preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key);
}
// 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. 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);
}
// Scalar values are written inside double quotes and array values are spliced in verbatim, so
// a value is shell source code the moment any script reads the conf. Command substitution in a
// value therefore executes on every load — in every script, as root. No conf in this repo uses
// it, so rejecting it costs nothing and closes the path.
//
// $VAR and ${VAR} stay legal on purpose: AI_DATA_DIR="${DATA_DIR}/ai" is the established idiom
// here, and a reference resolves to a value where a substitution runs a program.
function vv_conf_value_safe(string $value): bool {
return !preg_match('/\$\(|`|<\(|>\(/', $value);
}
// ── Path values ──────────────────────────────────────────────────────────────────────────────
// A conf path is not just a string: several scripts delete inside one. arr_download_orphan_cleaner
// runs rm -rf on entries under a download dir, rsync.sh runs --delete against a profile's
// destination, and the transcode ramdisk tears down its own mount point. Those are correct
// against /mnt/user/Movies and catastrophic against /mnt/user, and nothing between the form and
// the file could previously tell those apart — bash -n proves the file parses, and the read-back
// proves the value arrived, but both are perfectly happy with a value that will erase a share.
//
// Depth is not the test. /tv, /movies and "/kids movies" are all real values in host1.conf: they
// are paths inside the arr containers, and a rule requiring two segments would refuse the conf
// this host already runs. What separates a safe path edit from a dangerous one is not how deep
// the new value is but which direction it moved — widening a path to its own ancestor is the
// edit that turns a targeted cleanup into a sweep, and it is meaningful at every depth.
//
// Scalars only. Arrays are spliced in verbatim by a different branch of the writer, and the arr
// root-folder maps that live in them deserve their own pass; what matters here is that every
// value the repair subsystem writes is a scalar, so its whole surface is covered.
//
// Returns null when the write is allowed, or the reason it is not.
function vv_conf_path_write_ok(string $key, string $new, ?string $old): ?string {
$isPath = fn(?string $v): bool => $v !== null && $v !== '' && $v[0] === '/';
// A ${VAR} reference is the established idiom for composing paths here and cannot be
// resolved statically, so it is left to the read-back the way it always was.
if ($new !== '' && $new[0] === '$') return null;
// Not a path write at all unless one side of it is a path.
if (!$isPath($new) && !$isPath($old)) return null;
// A newline inside double quotes is legal bash, so this is one of the few malformations
// bash -n cannot catch. A path carrying one arrives at rsync or rm as two arguments.
if (preg_match('/[\r\n\x00]/', $new)) return 'newline-in-path';
// Blanking a path is the classic: rm -rf "$DIR"/* with an empty DIR is rm -rf /*. The raw
// conf editor is the way to empty one deliberately — this writer is the automated path.
if ($isPath($old) && $new === '') return 'path-cleared';
// Was a path, is now something that resolves against whatever directory the script happens
// to be in. There is no safe answer to "delete inside Movies" when Movies is relative.
if ($isPath($old) && !$isPath($new)) return 'path-became-relative';
if (!$isPath($new)) return null;
// '..' as a whole segment. Not a substring match — a share legitimately named "..old" is not
// traversal, and a rule that cannot tell them apart teaches people to work around it.
foreach (explode('/', $new) as $seg) {
if ($seg === '..') return 'path-traversal';
}
$norm = fn(string $p): string => rtrim(preg_replace('#/+#', '/', $p), '/');
$n = $norm($new);
if ($n === '') return 'path-is-root';
// The rule this whole function exists for. An ancestor is matched at the segment boundary so
// /mnt/user/Movies2 is not read as a parent of /mnt/user/Movies.
if ($isPath($old)) {
$o = $norm((string)$old);
if ($o !== $n && str_starts_with($o . '/', $n . '/')) return 'path-broadened';
}
return null;
}
// Under DATA_DIR, not beside the confs. data/ is the one on-disk root and is gitignored whole,
// so a backup here cannot become a tracked file the way Configurations/*.bak did. 0700 because
// these are verbatim copies of files holding every credential on the host.
function vv_conf_backup_dir(): string {
if (!is_dir(CONF_BACKUP_DIR)) @mkdir(CONF_BACKUP_DIR, 0700, true);
return CONF_BACKUP_DIR;
}
// Copy the current conf aside before it is touched. Returns the backup path, or null when no
// backup could be taken — which the caller treats as a reason not to write, because the whole
// recovery story for these files is this directory.
function vv_conf_backup(string $filename): ?string {
$src = CONF_DIR . '/' . $filename;
if (!is_file($src)) return null;
$dest = vv_conf_backup_dir() . '/' . $filename . '.' . date('Y-m-d\THis');
// Same second, second change: keep both rather than silently overwrite the older one.
if (file_exists($dest)) {
$n = 1;
while (file_exists($dest . '.' . $n)) $n++;
$dest .= '.' . $n;
}
if (!@copy($src, $dest)) return null;
@chmod($dest, 0600);
vv_conf_prune_backups($filename);
return $dest;
}
// Retain the newest CONF_BACKUP_RETAIN backups per conf file. Pruned by filename, which sorts
// chronologically because the stamp is ISO-8601 — no stat() per candidate.
function vv_conf_prune_backups(string $filename): void {
$keep = (int) (vv_conf_vars()['CONF_BACKUP_RETAIN'] ?? 30);
if ($keep < 1) $keep = 30;
$found = glob(vv_conf_backup_dir() . '/' . $filename . '.*') ?: [];
if (count($found) <= $keep) return;
sort($found);
foreach (array_slice($found, 0, count($found) - $keep) as $old) @unlink($old);
}
// Source a candidate conf in a subshell and read back the keys that were just written.
// bash -n proves the file parses; this proves the values survived quoting and arrived as
// intended. Returns null when the file could not be sourced at all.
function vv_conf_read_back(string $path, array $keys): ?array {
$script = 'source ' . escapeshellarg($path) . ' >/dev/null 2>&1 || exit 90; ';
foreach ($keys as $k) {
if (!vv_conf_key_valid($k)) continue;
// \001 terminates the value. PHP's exec() strips trailing whitespace from every line it
// returns, so an empty value produced a line with nothing after the tab, explode() found
// one part instead of two, and the key vanished from the result entirely — which the
// caller reads as "not equal to what I wrote" and rolls the write back. Clearing any
// field was therefore impossible, and a value ending in a space came back trimmed and
// failed the same way. A non-whitespace sentinel survives the trim; it is stripped below.
$script .= 'printf "%s\t%s\001\n" ' . escapeshellarg($k) . ' "${' . $k . '-}"; ';
}
$out = []; $rc = 0;
exec('bash -c ' . escapeshellarg($script) . ' 2>/dev/null', $out, $rc);
if ($rc !== 0) return null;
$vals = [];
foreach ($out as $line) {
$parts = explode("\t", $line, 2);
if (count($parts) !== 2) continue;
$v = $parts[1];
if (str_ends_with($v, "\001")) $v = substr($v, 0, -1);
$vals[$parts[0]] = $v;
}
return $vals;
}
// Why the last vv_conf_edit() gave up, for a caller that has only a bool to report with. The
// audit log always held the reason — value-mismatch, syntax, backup, lock — while the operator
// got "save failed" and no way to tell a rolled-back write from a locked file. A save that does
// nothing and will not say why is the hardest kind of bug to be handed.
function vv_conf_last_error(?string $set = null): string {
static $last = '';
if ($set !== null) $last = $set;
return $last;
}
// One line per conf change, best-effort and never able to block the write itself.
function vv_conf_audit(string $file, string $key, string $outcome, string $detail = ''): void {
$line = date('Y-m-d H:i:s')
. " file={$file} key={$key} outcome={$outcome}"
. ($detail !== '' ? " {$detail}" : '')
. ' ip=' . ($_SERVER['REMOTE_ADDR'] ?? 'cli')
. "\n";
@file_put_contents(LOG_DIR . '/conf_changes.log', $line, FILE_APPEND | LOCK_EX);
}
// Write a batch of field changes back to their respective conf files.
// Each change: {file, key, value, type}
//
// $rejected collects what was refused before any file was touched. A rejected change is dropped
// from the batch, so a request whose only change is refused produces no results at all — and
// "no results" and "nothing went wrong" were indistinguishable to a caller checking for false.
// The settings form reported a save that had silently not happened. Callers that care pass this
// and say so; the two that write assoc arrays are unaffected and do not.
function vv_conf_write_changes(array $changes, array &$rejected = []): array {
$rejected = [];
$refuse = function (array $c, string $reason) use (&$rejected): void {
vv_conf_audit((string) $c['file'], (string) $c['key'], 'rejected', 'reason=' . $reason);
$rejected[] = ['file' => (string) $c['file'], 'key' => (string) $c['key'], 'reason' => $reason];
};
$byFile = [];
foreach ($changes as $c) {
if (empty($c['file']) || empty($c['key'])) continue;
if (!vv_conf_key_valid($c['key'])) {
$refuse($c, 'malformed-key');
continue;
}
// The command-substitution guard exists because a conf value is shell source the moment a
// script sources the file. A scalar secret has its $ and ` escaped by
// vv_conf_quote_scalar(), so $( and ` are literal characters there and cannot execute —
// the guard has nothing left to guard and would only be refusing legitimate passwords.
// Narrow on purpose: scalar and secret both, because the exemption is sound only for the
// escaping that function gives exactly that combination.
$literal = ($c['type'] ?? 'scalar') === 'scalar' && vv_conf_key_is_secret((string) $c['key']);
if (!$literal && !vv_conf_value_safe((string) ($c['value'] ?? ''))) {
$refuse($c, 'command-substitution');
continue;
}
// Compared against the value the conf holds now, which is what makes "broadened" a
// question this can answer at all. Scalars only — see vv_conf_path_write_ok().
if (($c['type'] ?? 'scalar') === 'scalar') {
$bad = vv_conf_path_write_ok((string) $c['key'], (string) ($c['value'] ?? ''),
vv_conf_vars()[$c['key']] ?? null);
if ($bad !== null) { $refuse($c, $bad); continue; }
}
$byFile[$c['file']][] = $c;
}
$results = [];
foreach ($byFile as $file => $fileChanges) {
$results[$file] = vv_conf_write_file($file, $fileChanges);
if ($results[$file] === false)
$rejected[] = ['file' => $file, 'key' => implode(', ', array_column($fileChanges, 'key')),
'reason' => vv_conf_last_error() ?: 'write failed'];
}
return $results;
}
// How a scalar is written back, and it is not one rule for everything.
//
// Everything is double-quoted, including secrets. The difference is whether $ and ` are escaped.
//
// A conf value is shell source the moment a script sources the file, so bash expands any bare $.
// That is deliberate for most keys — AI_DATA_DIR="${DATA_DIR}/ai" is the idiom here, and a
// password is the exact opposite: never a reference, always the literal characters typed.
//
// A password containing a $ was silently unsavable. It was written as typed, bash expanded $ign
// to nothing when the read-back sourced the file, the value no longer matched what was intended,
// and the guard correctly rolled the whole write back — leaving a Save button that did nothing
// and an audit line nobody had reason to look at.
//
// Escaping rather than single-quoting, deliberately. Single quotes would also work and read more
// clearly, but the conf is parsed by bash tooling as well as sourced by it, and some of that
// tooling matches KEY="..." literally — unraid_api_key_renew.sh reads and rewrites
// HOST*_UNRAID_API_KEY with exactly that pattern, every fifteen minutes, and would have found no
// match, taken the update branch anyway and reported success. Staying inside double quotes keeps
// every one of those consumers working and confines this change to the two characters that
// actually needed it.
function vv_conf_quote_scalar(string $key, string $value): string {
// Backslashes first. Escaping quotes first meant the backslash just inserted was itself
// doubled on the next pass — " became \\" — which closed the string early and stored a
// truncated value that still parsed cleanly.
$from = ['\\', '"'];
$to = ['\\\\', '\\"'];
if (vv_conf_key_is_secret($key)) {
// \$ and \` are literal inside double quotes. vv_conf_unquote() undoes both, so PHP and
// bash read the same password back.
$from[] = '$'; $to[] = '\\$';
$from[] = '`'; $to[] = '\\`';
}
return '"' . str_replace($from, $to, $value) . '"';
}
// Read-modify-write for one conf file. The surgical replacement lives here; the guards that
// make installing it safe live in vv_conf_install(), which every conf writer shares.
function vv_conf_write_file(string $file, array $fileChanges): bool {
return vv_conf_edit($file, function (string $raw) use ($fileChanges): ?string {
foreach ($fileChanges as $c) {
$qKey = preg_quote($c['key'], '/');
$value = $c['value'];
$type = $c['type'] ?? 'scalar';
if ($type === 'scalar') {
$quoted = vv_conf_quote_scalar($c['key'], $value);
$raw = preg_replace_callback(
'/^(\s*' . $qKey . '\s*=\s*)("(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|[^#\n]*?)(\s*(?:#[^\n]*)?)$/m',
fn($m) => $m[1] . $quoted . $m[3],
$raw
) ?? $raw;
} elseif ($type === 'array_single') {
$raw = preg_replace_callback(
'/^(\s*' . $qKey . '\s*=\s*\()([^)]*)(\)(?:\s*(?:#[^\n]*)?)?)$/m',
fn($m) => $m[1] . $value . $m[3],
$raw
) ?? $raw;
// VV_CONF_ARRAY_CLOSE, not [^)]*\) — see the constant for what the greedy-to-first-paren
// version did to a list whose comments contain one.
} elseif ($type === 'array') {
$raw = preg_replace_callback(
'/^(\s*)(' . $qKey . '\s*=\s*\()' . VV_CONF_ARRAY_CLOSE . '/ms',
fn($m) => $m[1] . $m[2] . "\n" . $value . "\n" . $m[1] . ")",
$raw
) ?? $raw;
} elseif ($type === 'assoc_array') {
$raw = preg_replace_callback(
'/^(\s*)(declare\s+-A\s+' . $qKey . '\s*=\s*\()' . VV_CONF_ARRAY_CLOSE . '/ms',
fn($m) => $m[1] . $m[2] . "\n" . $value . "\n" . $m[1] . ")",
$raw
) ?? $raw;
}
}
return $raw;
// Scalars are verified because their intended value is known exactly. The array types splice
// caller-supplied text whose sourced form is legitimately not equal to what was written, so
// for those a clean source is the whole assertion.
}, vv_conf_expected_scalars($fileChanges), array_column($fileChanges, 'key'));
}
// The intended end state for the keys that can be checked against one.
function vv_conf_expected_scalars(array $fileChanges): array {
$expect = [];
foreach ($fileChanges as $c) {
if (($c['type'] ?? 'scalar') === 'scalar') $expect[$c['key']] = (string) $c['value'];
}
return $expect;
}
// The one guarded way to install a changed conf. $mutate receives the current contents and
// returns the rewritten ones, or null to abort without touching anything. Every conf writer in
// the plugin goes through here, so the lock, the backup, the validation and the audit trail are
// written once and cannot be forgotten by a new caller.
//
// $expect key => intended value, verified by sourcing the installed file
// $subjects names for the audit line, when there is no key to assert (a toggled member)
// $allowCreate write a conf that does not exist yet. Off by default: for every caller except
// first-run setup, a missing target means the filename is wrong, and creating it
// would leave a stray conf that shadows nothing and is sourced by nobody.
function vv_conf_edit(string $file, callable $mutate, array $expect = [], array $subjects = [],
bool $allowCreate = false): bool {
$path = CONF_DIR . '/' . $file;
$subjects = $subjects ?: (array_keys($expect) ?: ['-']);
$audit = function (string $outcome, string $detail = '') use ($file, $subjects): void {
foreach ($subjects as $s) vv_conf_audit($file, (string) $s, $outcome, $detail);
vv_conf_last_error($outcome . ($detail !== '' ? ' ' . $detail : ''));
};
vv_conf_last_error('');
// Held across the whole read-modify-write. Two concurrent savers would otherwise each read
// the same original, and the second rename would silently discard the first one's change.
$lockFh = @fopen(CONF_DIR . '/.conf-write.lock', 'c');
if ($lockFh === false || !flock($lockFh, LOCK_EX)) {
if ($lockFh) fclose($lockFh);
$audit('failed', 'reason=lock');
return false;
}
try {
// A conf that does not exist yet is a create, not an edit — first-run setup writes
// host*.conf before there is anything to read. There is no prior content to lose, so
// there is nothing to back up, and undoing a failed create means removing the file.
$exists = is_file($path);
if (!$exists && !$allowCreate) { $audit('failed', 'reason=unreadable'); return false; }
$raw = $exists ? vv_read_conf_raw($file) : '';
if ($exists && $raw === '') { $audit('failed', 'reason=unreadable'); return false; }
$before = $exists ? (vv_conf_read_back($path, $subjects) ?? []) : [];
$new = $mutate($raw);
if ($new === null) { $audit('failed', 'reason=no-match'); return false; }
if ($exists && $new === $raw) { $audit('no-change'); return true; }
$backup = null;
if ($exists) {
$backup = vv_conf_backup($file);
if ($backup === null) {
// No recovery path for this write means the write does not happen. These files
// are gitignored, so a backup not taken cannot be reconstructed afterwards.
$audit('failed', 'reason=backup');
return false;
}
}
$undo = function () use ($backup, $path): void {
if ($backup !== null) @copy($backup, $path); else @unlink($path);
};
if (!vv_conf_syntax_ok($new)) { $audit('rejected', 'reason=syntax'); return false; }
if (!vv_write_conf_raw($file, $new)) { $audit('failed', 'reason=write'); return false; }
// bash -n proved the candidate parses. This proves the installed file still sources and
// that each value arrived intact — a quoting bug produces a file that parses perfectly
// and holds the wrong string, which is the failure the syntax check cannot see.
$after = vv_conf_read_back($path, $subjects);
if ($after === null) {
$undo();
$audit('rolled-back', 'reason=source-failed');
return false;
}
foreach ($expect as $key => $want) {
if (($after[$key] ?? null) !== $want) {
$undo();
vv_conf_audit($file, $key, 'rolled-back', 'reason=value-mismatch');
vv_conf_last_error("rolled-back reason=value-mismatch key=$key");
return false;
}
}
foreach ($subjects as $s) {
$s = (string) $s;
// A subject that is not a conf key — a script id, or a marker for a whole-file save
// — has no value to read back, so there is no before and after to report.
if (!vv_conf_key_valid($s)) { vv_conf_audit($file, $s, 'applied'); continue; }
if (vv_conf_key_is_secret($s)) { vv_conf_audit($file, $s, 'applied', 'value=<redacted>'); continue; }
vv_conf_audit($file, $s, 'applied',
'from=' . vv_conf_audit_val($before[$s] ?? '') . ' to=' . vv_conf_audit_val($after[$s] ?? ''));
}
return true;
} finally {
flock($lockFh, LOCK_UN);
fclose($lockFh);
}
}
// Audit values are single-line and bounded. A conf value can be a 40-line array; the log is a
// record of what changed, and an unbounded splat of it makes the log unreadable at the moment
// it is actually needed.
function vv_conf_audit_val(string $v): string {
$v = preg_replace('/\s+/', ' ', trim($v));
if (strlen($v) > 120) $v = substr($v, 0, 117) . '...';
return '"' . $v . '"';
}
// bash -n against a private temp copy. Returns true when the content parses as a sourceable
// conf, false otherwise — never writes anything itself.
function vv_conf_syntax_ok(string $content): bool {
return vv_conf_syntax_error($content) === null;
}
// The same check, with bash's own complaint when it fails — the raw editors show it to whoever
// is typing, where "conf does not parse" alone would mean hunting the line by hand. $label
// replaces the temp path in the message so the reader sees their own filename.
//
// Fails closed. This used to pass when the temp file could not be created, which was defensible
// while every write came from a human clicking Save on a form. The assistant writes through here
// too, so an unverified conf is not installed — a refused write is recoverable, a conf that no
// script can source is a system-wide outage.
function vv_conf_syntax_error(string $content, string $label = 'conf'): ?string {
$tmp = tempnam(sys_get_temp_dir(), 'vvconf');
if ($tmp === false) return 'cannot verify: no writable temp directory';
file_put_contents($tmp, $content);
$out = []; $rc = 0;
exec('bash -n ' . escapeshellarg($tmp) . ' 2>&1', $out, $rc);
@unlink($tmp);
if ($rc === 0) return null;
$msg = implode(' ', array_filter(array_map('trim', $out)));
return str_replace($tmp, $label, $msg ?: 'conf does not parse');
}