diff --git a/Plugin/unraid/api/conf_toggle.php b/Plugin/unraid/api/conf_toggle.php index 562813d..d246570 100644 --- a/Plugin/unraid/api/conf_toggle.php +++ b/Plugin/unraid/api/conf_toggle.php @@ -43,10 +43,17 @@ // outside one, so a matching string in a comment or an unrelated variable cannot be // rewritten. // -// The write is atomic. -// vv_conf_toggle_script() writes through vv_write_conf_raw() (tmp + rename). Every -// script sources master.conf; a truncated write here would be a system-wide outage -// rather than a lost toggle. +// The write is atomic, backed up, verified and logged. +// vv_conf_toggle_script() goes through vv_conf_edit(), the one guarded conf write path: +// an exclusive lock, a timestamped copy into CONF_BACKUP_DIR, bash -n on the candidate, +// tmp + rename to install it, then the installed file is sourced to prove it still loads. +// There is no single key to read back for a commented array member, so a clean source is +// the whole assertion. Every script sources master.conf; a truncated or unparseable write +// here would be a system-wide outage rather than a lost toggle. +// +// A script in no array writes nothing at all. +// The rewrite returns the contents unchanged, which reports success without taking a +// backup or touching the file. "Already in the requested state" is not a write. // // REQUEST // POST id= enabled=0|1 @@ -56,7 +63,7 @@ // {"ok":false,"error":"POST only"|"Invalid id"|"Failed to write master.conf"} // // DEPENDS ON -// include/scheduler.php vv_conf_toggle_script() → vv_write_conf_raw() +// include/scheduler.php vv_conf_toggle_script() → vv_conf_edit() → vv_write_conf_raw() // Configurations/master.conf the *_SCRIPTS arrays // ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); diff --git a/Plugin/unraid/api/config.php b/Plugin/unraid/api/config.php index 41f8829..07a72fb 100644 --- a/Plugin/unraid/api/config.php +++ b/Plugin/unraid/api/config.php @@ -38,13 +38,17 @@ // is the difference between a rejected save and a silent, total outage, so a file that // does not parse is refused and the previous version is left untouched. // -// The temp copy is created with tempnam() and always removed. -// The candidate is never written next to the real conf and never under a predictable -// name, so a failed validation cannot leave a stray file for a script to source. +// Checked here via vv_conf_syntax_error() only so the editor can show bash's own +// complaint with a line number. vv_conf_edit() checks again before installing; this one +// is for the message, not the decision. // -// The real write is atomic. -// vv_write_conf_raw() writes .vv.tmp and rename()s, so a script sourcing the conf -// during the save reads either the old file or the new one, never a half-written one. +// The write goes through the one guarded conf path. +// vv_conf_edit() takes an exclusive lock, copies the previous file into CONF_BACKUP_DIR, +// re-checks the syntax, installs via .vv.tmp + rename() so a concurrent reader sees the +// old file or the new one but never a half-written one, then sources the installed file +// to prove it still loads and restores the backup if it does not. The whole-file nature +// of this endpoint is why that matters most here: there is no key to verify, so a clean +// source is the only assertion available. // // REQUEST // POST file= content= @@ -54,10 +58,12 @@ // {"ok":false,"error":"POST only"|"File not permitted"|"Syntax error: …"|"Failed to write file"} // // DEPENDS ON -// include/config.php vv_get_conf_files(), vv_write_conf_raw(), CONF_DIR +// include/config.php vv_get_conf_files(), CONF_DIR +// include/confform.php vv_conf_syntax_error(), vv_conf_edit() // ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; +require_once dirname(__DIR__) . '/include/confform.php'; if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['ok' => false, 'error' => 'POST only']); @@ -75,22 +81,14 @@ if (!$file || !in_array($file, $allowed)) { } // Every script sources these. A syntax error here takes the whole system down, so the -// candidate is parsed before it is allowed to replace a working file. -$check = tempnam(sys_get_temp_dir(), 'vvconf'); -if ($check !== false) { - file_put_contents($check, $content); - $out = []; $rc = 0; - exec('bash -n ' . escapeshellarg($check) . ' 2>&1', $out, $rc); - @unlink($check); - if ($rc !== 0) { - $msg = implode(' ', array_filter(array_map('trim', $out))); - echo json_encode([ - 'ok' => false, - 'error' => 'Syntax error: ' . str_replace($check, $file, $msg ?: 'conf does not parse'), - ]); - exit; - } +// candidate is parsed before it is allowed to replace a working file. Checked here as well as +// inside vv_conf_edit() so the editor can show bash's own complaint; the write path only knows +// whether to proceed, not what to tell the person typing. +$syntax = vv_conf_syntax_error($content, $file); +if ($syntax !== null) { + echo json_encode(['ok' => false, 'error' => 'Syntax error: ' . $syntax]); + exit; } -$ok = vv_write_conf_raw($file, $content); +$ok = vv_conf_edit($file, fn() => $content, [], ['whole-file']); echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write file']); diff --git a/Plugin/unraid/api/flag_toggle.php b/Plugin/unraid/api/flag_toggle.php index 77d2067..e2905ef 100644 --- a/Plugin/unraid/api/flag_toggle.php +++ b/Plugin/unraid/api/flag_toggle.php @@ -48,9 +48,12 @@ // missing, malformed, or unexpected parameter disables rather than enables. Failing // toward off is the safe direction for a flag that starts data movement. // -// The conf write is atomic. -// vv_conf_flag_set() writes through vv_write_conf_raw() (tmp + rename). Every script -// sources master.conf, so a truncated write would be a system-wide outage. +// The conf write is atomic, backed up, verified and logged. +// vv_conf_flag_set() goes through vv_conf_edit(), the one guarded conf write path: an +// exclusive lock, a timestamped copy into CONF_BACKUP_DIR, bash -n on the candidate, +// tmp + rename to install it, then the file is sourced and the flag read back — a value +// that does not come back as asked restores the backup. Every script sources master.conf, +// so a truncated or unparseable write would be a system-wide outage. // // The push only happens after a confirmed local write. // Guarded on $ok, so a failed edit cannot distribute a stale or partly-written conf to @@ -69,7 +72,7 @@ // "push":[]} // // DEPENDS ON -// include/scheduler.php vv_conf_flag_set() → vv_write_conf_raw() +// include/scheduler.php vv_conf_flag_set() → vv_conf_edit() → vv_write_conf_raw() // include/config.php vv_push_master_conf(), vv_push_setup_state() // ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); diff --git a/Plugin/unraid/api/movescript.php b/Plugin/unraid/api/movescript.php index 463f6ad..951a4ba 100644 --- a/Plugin/unraid/api/movescript.php +++ b/Plugin/unraid/api/movescript.php @@ -81,6 +81,7 @@ // ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; +require_once dirname(__DIR__) . '/include/confform.php'; if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['ok' => false, 'error' => 'POST only']); @@ -106,6 +107,9 @@ if (!file_exists($confPath)) { } $lines = file($confPath, FILE_KEEP_BLANK_LINES); +// Captured before anything is rebuilt from it — this is what the write compares against to +// prove master.conf did not change while the move was being worked out. +$origConf = implode('', $lines ?: []); if (!$lines) { echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']); exit; @@ -148,7 +152,14 @@ if ($toArray) { $newLines = $resultLines; } -if (!vv_write_conf_raw('master.conf', implode('', $newLines))) { +// The array was rebuilt from a copy read before the lock was taken. Handing vv_conf_edit() a +// closure that compares against the current contents turns that into an optimistic check: if +// anything changed master.conf in between, the move is abandoned rather than written over the +// top of it. Everything else — backup, bash -n, read-back, audit — comes with the shared path. +$ok = vv_conf_edit('master.conf', fn(string $cur): ?string => + $cur === $origConf ? implode('', $newLines) : null, [], [$script]); + +if (!$ok) { echo json_encode(['ok' => false, 'error' => 'Write failed']); exit; } diff --git a/Plugin/unraid/api/rawconf.php b/Plugin/unraid/api/rawconf.php index 44a11fc..0277e46 100644 --- a/Plugin/unraid/api/rawconf.php +++ b/Plugin/unraid/api/rawconf.php @@ -45,9 +45,13 @@ // The temp copy is created with tempnam() and always removed, so a rejected save cannot // leave a stray file beside the real conf for a script to source. // -// The real write is atomic. -// vv_write_conf_raw() writes .vv.tmp and rename()s, so a script sourcing the conf during -// the save reads either the old file or the new one, never a half-written one. +// The write goes through the one guarded conf path. +// vv_conf_edit() takes an exclusive lock, copies the previous file into CONF_BACKUP_DIR, +// re-checks the syntax, installs via .vv.tmp + rename() so a concurrent reader sees the +// old file or the new one but never a half-written one, then sources the installed file +// to prove it still loads and restores the backup if it does not. That backup matters +// more here than anywhere else: this endpoint replaces a whole hand-edited file, and the +// confs are gitignored, so before it existed a bad paste had nothing to go back to. // // The push only happens after a confirmed write. // Guarded on $written, so a failed save cannot distribute a stale or partly-written @@ -68,11 +72,13 @@ // {"ok":false,"error":"Not allowed"|"Syntax error: …"|"Method not allowed"} // // DEPENDS ON -// include/config.php vv_get_conf_files(), vv_read_conf_raw(), vv_write_conf_raw(), -// vv_push_master_conf(), vv_push_setup_state() +// include/config.php vv_get_conf_files(), vv_read_conf_raw(), +// vv_push_master_conf(), vv_push_setup_state() +// include/confform.php vv_conf_syntax_error(), vv_conf_edit() // ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; +require_once dirname(__DIR__) . '/include/confform.php'; $allowed = vv_get_conf_files(); @@ -95,24 +101,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } // Every script sources these, and master.conf is pushed to every partner from here — a // syntax error saved through this endpoint would propagate the outage across the mesh. - $check = tempnam(sys_get_temp_dir(), 'vvconf'); - if ($check !== false) { - file_put_contents($check, $content); - $out = []; $rc = 0; - exec('bash -n ' . escapeshellarg($check) . ' 2>&1', $out, $rc); - @unlink($check); - if ($rc !== 0) { - $msg = implode(' ', array_filter(array_map('trim', $out))); - echo json_encode([ - 'ok' => false, - 'error' => 'Syntax error: ' . str_replace($check, $file, $msg ?: 'conf does not parse'), - 'push' => [], - ]); - exit; - } + // Checked here as well as inside vv_conf_edit() so the editor can show bash's own complaint; + // the write path only knows whether to proceed, not what to tell the person typing. + $syntax = vv_conf_syntax_error($content, $file); + if ($syntax !== null) { + echo json_encode(['ok' => false, 'error' => 'Syntax error: ' . $syntax, 'push' => []]); + exit; } - $written = vv_write_conf_raw($file, $content); + $written = vv_conf_edit($file, fn() => $content, [], ['whole-file']); $push = []; if ($written && $file === 'master.conf') { $push = vv_push_master_conf(); diff --git a/Plugin/unraid/api/reorderarray.php b/Plugin/unraid/api/reorderarray.php index 61f6af5..e7e2031 100644 --- a/Plugin/unraid/api/reorderarray.php +++ b/Plugin/unraid/api/reorderarray.php @@ -82,6 +82,7 @@ // ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; +require_once dirname(__DIR__) . '/include/confform.php'; if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['ok' => false, 'error' => 'POST only']); @@ -122,6 +123,9 @@ if (!file_exists($confPath)) { } $lines = file($confPath, FILE_KEEP_BLANK_LINES); +// Captured before array_splice rewrites the block in place — this is what the write compares +// against to prove master.conf did not change while the new order was being assembled. +$origConf = implode('', $lines ?: []); if (!$lines) { echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']); exit; @@ -183,7 +187,13 @@ $newBlockLines[] = $lines[$blockEnd]; // Replace the original block in $lines array_splice($lines, $blockStart, $blockEnd - $blockStart + 1, $newBlockLines); -if (!vv_write_conf_raw('master.conf', implode('', $lines))) { +// See movescript.php: the block was rebuilt from a copy read before the lock, so the closure +// compares against the current contents and abandons the reorder if anything changed in +// between. Backup, bash -n, read-back and audit come with the shared path. +$ok = vv_conf_edit('master.conf', fn(string $cur): ?string => + $cur === $origConf ? implode('', $lines) : null, [], [$arrayName]); + +if (!$ok) { echo json_encode(['ok' => false, 'error' => 'Write failed']); exit; } diff --git a/Plugin/unraid/api/rsync_win_arrays.php b/Plugin/unraid/api/rsync_win_arrays.php index 54c0a9a..425dbf8 100644 --- a/Plugin/unraid/api/rsync_win_arrays.php +++ b/Plugin/unraid/api/rsync_win_arrays.php @@ -167,6 +167,9 @@ if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') { if (is_array($scripts)) { $confPath = CONF_DIR . '/master.conf'; $lines = file($confPath, FILE_KEEP_BLANK_LINES) ?: []; + // Captured before array_splice rewrites the block in place — the write compares against + // it to prove master.conf did not change while the new block was being assembled. + $origConf = implode('', $lines); $esc = preg_quote($scriptsVar, '/'); $blockStart = $blockEnd = null; $depth = 0; @@ -213,9 +216,13 @@ if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') { } $newBlock[] = $lines[$blockEnd]; array_splice($lines, $blockStart, $blockEnd - $blockStart + 1, $newBlock); - // tmp+rename — every script sources master.conf, so a truncated write here is a - // system-wide outage, not a lost edit. - if (!vv_write_conf_raw('master.conf', implode('', $lines))) { + // Shared guarded path: lock, backup, bash -n, atomic install, read-back, audit. The + // closure compares against the current contents first, so a master.conf that changed + // while this block was being assembled abandons the write instead of clobbering it. + $wrote = vv_conf_edit('master.conf', fn(string $cur): ?string => + $cur === $origConf ? implode('', $lines) : null, [], [$scriptsVar]); + + if (!$wrote) { $errors[] = 'scripts write failed'; } else { // master.conf is shared — mirrors reorderarray/movescript/rawconf. diff --git a/Plugin/unraid/api/setup.php b/Plugin/unraid/api/setup.php index 9bcebb9..c95c72c 100644 --- a/Plugin/unraid/api/setup.php +++ b/Plugin/unraid/api/setup.php @@ -120,6 +120,7 @@ // ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; +require_once dirname(__DIR__) . '/include/confform.php'; $action = ($_SERVER['REQUEST_METHOD'] === 'GET') ? trim($_GET['action'] ?? '') @@ -277,7 +278,10 @@ if ($action === 'pull') { '${1}"' . $sshKey . '"', $conf); $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m', '${1}' . $storageInternal2, $conf); - vv_write_conf_raw($confFile, $conf); + // allowCreate: this is first-run setup, so the host conf does not exist yet. There + // is no prior content to back up, and a candidate that fails bash -n is removed + // rather than restored. + vv_conf_edit($confFile, fn(): string => $conf, [], ["{$hostId}_SSH_KEY"], true); } } @@ -313,6 +317,9 @@ if (!preg_match('/^host\d+$/', $mySlot)) { // Write HOST1 / HOST2 into master.conf $master = vv_read_conf_raw('master.conf'); +// Captured before the substitutions below — the write compares against it so a master.conf that +// changed while setup was being filled in is not silently overwritten. +$origMaster = $master; if ($master === '') { echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']); exit; @@ -333,7 +340,8 @@ if ($slotNum > 2 && !empty($myHostname)) { } } -if (!vv_write_conf_raw('master.conf', $master)) { +if (!vv_conf_edit('master.conf', fn(string $cur): ?string => $cur === $origMaster ? $master : null, + [], ['HOST1', 'HOST2'])) { echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']); exit; } @@ -368,7 +376,8 @@ if (!file_exists(CONF_DIR . '/' . $confFile)) { '${1}"' . $sshKeyPath . '"', $conf); $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m', '${1}' . $storageInternal, $conf); - if (!vv_write_conf_raw($confFile, $conf)) { + // allowCreate — see the sibling write above; this is the same first-run create. + if (!vv_conf_edit($confFile, fn(): string => $conf, [], ["{$hostId}_SSH_KEY"], true)) { echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]); exit; } diff --git a/Plugin/unraid/include/docker.php b/Plugin/unraid/include/docker.php index 0e2b1de..1235d83 100644 --- a/Plugin/unraid/include/docker.php +++ b/Plugin/unraid/include/docker.php @@ -51,6 +51,7 @@ // Reads/writes HOST*_DOCKER_FOLDER_MAP in host*.conf (used by onboard scripts) require_once __DIR__ . '/config.php'; +require_once __DIR__ . '/confform.php'; define('VV_DOCKER_JSON', SCRIPTS_DIR . '/docker_folders.json'); define('VV_FV3_JSON', '/boot/config/plugins/folder.view3/docker.json'); @@ -344,17 +345,19 @@ function vv_dk_rename_folder(string $folderId, string $newName): array { $data[$folderId]['name'] = $newName; if (!vv_dk_write_json($data)) return ['ok' => false, 'error' => 'JSON write failed']; - // Update conf: replace old folder name with new name in the map + // Update conf: replace old folder name with new name in the map. + // The rebuild is a pure function of the current contents, so it runs inside vv_conf_edit()'s + // lock rather than against a copy read beforehand — there is no window to lose an edit in. $currentHost = vv_detect_host(); $myId = strtoupper($currentHost); - $raw = vv_read_conf_raw($currentHost . '.conf'); - $map = vv_dk_read_conf_map($raw, $myId); - foreach ($map as &$v) { - if ($v === $oldName) $v = $newName; - } - unset($v); - $updated = vv_dk_write_conf_map($raw, $myId, $map); - vv_write_conf_raw($currentHost . '.conf', $updated); + vv_conf_edit($currentHost . '.conf', function (string $raw) use ($myId, $oldName, $newName): string { + $map = vv_dk_read_conf_map($raw, $myId); + foreach ($map as &$v) { + if ($v === $oldName) $v = $newName; + } + unset($v); + return vv_dk_write_conf_map($raw, $myId, $map); + }, [], ["{$myId}_DOCKER_FOLDER_MAP"]); return ['ok' => true]; } @@ -367,14 +370,14 @@ function vv_dk_delete_folder(string $folderId): array { unset($data[$folderId]); if (!vv_dk_write_json($data)) return ['ok' => false, 'error' => 'JSON write failed']; - // Remove from conf map + // Remove from conf map — rebuilt inside the lock, see vv_dk_rename_folder() above. $currentHost = vv_detect_host(); $myId = strtoupper($currentHost); - $raw = vv_read_conf_raw($currentHost . '.conf'); - $map = vv_dk_read_conf_map($raw, $myId); - $map = array_filter($map, fn($v) => $v !== $folderName); - $updated = vv_dk_write_conf_map($raw, $myId, $map); - vv_write_conf_raw($currentHost . '.conf', $updated); + vv_conf_edit($currentHost . '.conf', function (string $raw) use ($myId, $folderName): string { + $map = vv_dk_read_conf_map($raw, $myId); + $map = array_filter($map, fn($v) => $v !== $folderName); + return vv_dk_write_conf_map($raw, $myId, $map); + }, [], ["{$myId}_DOCKER_FOLDER_MAP"]); return ['ok' => true]; } @@ -421,22 +424,24 @@ function vv_dk_sync_json_to_conf(): array { $data = vv_dk_read_json(); $currentHost = vv_detect_host(); $myId = strtoupper($currentHost); - $raw = vv_read_conf_raw($currentHost . '.conf'); - _vv_dk_sync_conf_from_json($data, $raw, $currentHost, $myId); + _vv_dk_sync_conf_from_json($data, $currentHost, $myId); return ['ok' => true]; } -// Internal: rebuild conf map from current json state and write it -function _vv_dk_sync_conf_from_json(array $data, string $raw = '', string $host = '', string $id = ''): void { +// Internal: rebuild conf map from current json state and write it. +// The contents to splice into are read inside vv_conf_edit()'s lock. The caller used to be able +// to hand in a copy it had already read; that was only ever an optimisation, and passing a stale +// copy would have written the rest of the conf back as it looked before the lock was taken. +function _vv_dk_sync_conf_from_json(array $data, string $host = '', string $id = ''): void { if (!$host) $host = vv_detect_host(); if (!$id) $id = strtoupper($host); - if (!$raw) $raw = vv_read_conf_raw($host . '.conf'); $map = []; foreach ($data as $f) { $name = $f['name'] ?? ''; foreach ($f['containers'] ?? [] as $c) $map[$c] = $name; } - $updated = vv_dk_write_conf_map($raw, $id, $map); - vv_write_conf_raw($host . '.conf', $updated); + + vv_conf_edit($host . '.conf', fn(string $raw): string => vv_dk_write_conf_map($raw, $id, $map), + [], ["{$id}_DOCKER_FOLDER_MAP"]); } diff --git a/Plugin/unraid/include/scheduler.php b/Plugin/unraid/include/scheduler.php index a3b7f31..64250a9 100644 --- a/Plugin/unraid/include/scheduler.php +++ b/Plugin/unraid/include/scheduler.php @@ -598,50 +598,56 @@ function vv_conf_flag_value(string $name): bool { } // Write a boolean flag value to master.conf. +// Goes through vv_conf_edit() for the lock, the pre-write backup, the syntax check and the audit +// line. This used to call vv_write_conf_raw() directly, which gave it tmp+rename atomicity and +// nothing else — no backup, and no check that the file still sourced afterwards. function vv_conf_flag_set(string $name, bool $value): bool { - $confPath = CONF_DIR . '/master.conf'; - $content = file_get_contents($confPath); - if ($content === false) return false; - $val = $value ? 'true' : 'false'; - $new = preg_replace( - '/^(\s*' . preg_quote($name, '/') . '\s*=\s*)(true|false)(\s*(?:#.*)?)$/m', - '${1}' . $val . '${3}', - $content, -1, $count - ); - if (!$count) return false; - // tmp+rename — every script sources master.conf, so a truncated write here is a - // system-wide outage, not a lost toggle. - return vv_write_conf_raw('master.conf', $new); + if (!vv_conf_key_valid($name)) return false; + $val = $value ? 'true' : 'false'; + + return vv_conf_edit('master.conf', function (string $content) use ($name, $val): ?string { + $new = preg_replace( + '/^(\s*' . preg_quote($name, '/') . '\s*=\s*)(true|false)(\s*(?:#.*)?)$/m', + '${1}' . $val . '${3}', + $content, -1, $count + ); + // A name that matches no true/false line is a caller error, not an already-correct + // state — unlike the membership toggle below, where absence genuinely means nothing + // to do. Returning null keeps the write from happening and logs reason=no-match. + return $count ? $new : null; + }, [$name => $val]); } // Comment or uncomment a script's line in the first master.conf array that contains it. +// Goes through vv_conf_edit() for the lock, the pre-write backup, the syntax check and the audit +// line — see vv_conf_flag_set() above for what that replaced. There is no key to verify here, +// so the audit subject is the script id and a clean source is the whole assertion. function vv_conf_toggle_script(string $rel, bool $enable): bool { - $confPath = CONF_DIR . '/master.conf'; - $lines = file($confPath, FILE_KEEP_BLANK_LINES); - if (!$lines) return false; - $changed = false; - $inArray = false; - $relEsc = preg_quote($rel, '/'); - foreach ($lines as &$line) { - if (preg_match('/^\s*[A-Z_]+_SCRIPTS\s*=\s*\(/', $line)) $inArray = true; - if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false; - if (!$inArray) continue; - if (!preg_match('/^\s*(?:#\s*)?"' . $relEsc . '(?:\s[^"]*)?"/', $line)) continue; - $isCommented = (bool)preg_match('/^\s*#/', $line); - if ($enable && $isCommented) { - $line = preg_replace('/^(\s*)#\s*("' . $relEsc . ')/', '$1$2', $line); - $changed = true; - } elseif (!$enable && !$isCommented) { - $line = preg_replace('/^(\s*)("' . $relEsc . ')/', '$1# $2', $line); - $changed = true; + return vv_conf_edit('master.conf', function (string $content) use ($rel, $enable): ?string { + $lines = preg_split('/(?<=\n)/', $content) ?: []; + $changed = false; + $inArray = false; + $relEsc = preg_quote($rel, '/'); + foreach ($lines as &$line) { + if (preg_match('/^\s*[A-Z_]+_SCRIPTS\s*=\s*\(/', $line)) $inArray = true; + if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false; + if (!$inArray) continue; + if (!preg_match('/^\s*(?:#\s*)?"' . $relEsc . '(?:\s[^"]*)?"/', $line)) continue; + $isCommented = (bool)preg_match('/^\s*#/', $line); + if ($enable && $isCommented) { + $line = preg_replace('/^(\s*)#\s*("' . $relEsc . ')/', '$1$2', $line); + $changed = true; + } elseif (!$enable && !$isCommented) { + $line = preg_replace('/^(\s*)("' . $relEsc . ')/', '$1# $2', $line); + $changed = true; + } + break; } - break; - } - unset($line); - if (!$changed) return true; - // tmp+rename — every script sources master.conf, so a truncated write here is a - // system-wide outage, not a lost toggle. - return vv_write_conf_raw('master.conf', implode('', $lines)); + unset($line); + // A script in no array has nothing to toggle and the conf already reads the way the + // caller asked. Returning the content unchanged reports success without a write. + return $changed ? implode('', $lines) : $content; + }, [], [$rel]); } // Parse an orchestrator script to find which child scripts it calls.