Files
Varaverk/Plugin/unraid/include/confform.php
T
Gmer4Lfe 0101aef51a Let a password containing a $ actually save, and say why when a save is refused
The value was written as typed, bash expanded it when the read-back sourced the file, and
the guard rolled the whole write back with nothing on screen but "save failed" — which is
also what an empty value, a trailing space, and a stale API-key check had been doing.
2026-08-14 23:39:12 -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' => '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');
}