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.
This commit is contained in:
Gmer4Lfe
2026-08-14 23:39:12 -04:00
parent 9eef5b50e6
commit 0101aef51a
3 changed files with 167 additions and 27 deletions
+64 -8
View File
@@ -818,16 +818,72 @@ const VV_CONF_ARRAY_BODY = '(?:([^)\n]*)\)|\n(.*?)^[ \t]*\)[ \t]*$)';
// one, so a commented-out-to-off switch read as on. Roughly half of master.conf's toggles carry
// an inline comment.
//
// The quoted forms are extracted before that, because inside quotes a # is data, not a comment —
// a password or a colour would otherwise be truncated at the first hash. Unquoted, the comment
// must be introduced by whitespace, matching bash: FOO=#fff and FOO=bar#baz both assign literally,
// since # only opens a comment at the start of a word.
// Quotes are honoured before that, because inside them a # is data, not a comment — a password
// or a colour would otherwise be truncated at the first hash. Unquoted, the comment must be
// introduced by whitespace, matching bash: FOO=#fff and FOO=bar#baz both assign literally, since
// # only opens a comment at the start of a word.
//
// The three regexes that did this are now one pass in vv_conf_unquote(), which handles escapes
// and adjacent quoted runs as well. See there for why that stopped being optional.
function vv_parse_conf_scalar(string $raw, string $key): string {
if (!preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*(.*)$/m', $raw, $m)) return '';
$v = ltrim($m[1]);
if (preg_match('/^"([^"\n]*)"/', $v, $q)) return $q[1];
if (preg_match("/^'([^'\n]*)'/", $v, $q)) return $q[1];
return trim(preg_replace('/\s+#.*$/', '', $v));
return vv_conf_unquote(ltrim($m[1]));
}
// Unquote one bash word the way bash does, because the regexes this replaced did not and the
// conf is read by both. Three separate regexes each handled one quoting style in isolation and
// none of them handled an escape or two quoted runs in a row, so a value carrying a quote or a
// backslash parsed differently here than it did when a script sourced the same line. Nothing in
// the conf held one, which is the only reason it never showed.
//
// It matters now because a secret is written with its $ and ` escaped, so a password containing
// either is stored as "a\$b". The old regex captured everything between the quotes verbatim and
// would have shown a\$b — a backslash the operator never typed, in a field they are about to
// copy a credential out of, while every bash script that sourced the same line held a$b.
//
// Not a shell. No expansion of any kind, so ${DATA_DIR}/ai stays the literal text it is today —
// vv_conf_vars() owns resolving references, and doing it here would turn a display value into a
// different string than the one on disk.
// $end receives the offset where the word stopped, so a caller that also wants the trailing
// comment knows where the value ended without re-deriving it with a second regex that would
// disagree about quoting — which is exactly how the form came to show a # from inside a password
// as the start of a comment.
function vv_conf_unquote(string $v, ?int &$end = null): string {
$out = ''; $i = 0; $n = strlen($v);
while ($i < $n) {
$ch = $v[$i];
if ($ch === "'") {
// No escapes inside single quotes — the closing quote is the next one, always.
$j = strpos($v, "'", $i + 1);
if ($j === false) { $out .= substr($v, $i + 1); $i = $n; break; }
$out .= substr($v, $i + 1, $j - $i - 1);
$i = $j + 1;
} elseif ($ch === '"') {
$i++;
while ($i < $n && $v[$i] !== '"') {
// Only these four are escapes inside double quotes. A backslash before anything
// else is a literal backslash, which is why \d in a regex value survives.
if ($v[$i] === '\\' && $i + 1 < $n && strpos('\\"$`', $v[$i + 1]) !== false) {
$out .= $v[$i + 1]; $i += 2;
} else {
$out .= $v[$i]; $i++;
}
}
$i++;
} elseif ($ch === '\\' && $i + 1 < $n) {
$out .= $v[$i + 1]; $i += 2;
} elseif ($ch === ' ' || $ch === "\t" || $ch === "\r" || $ch === "\n") {
// End of the word. Everything after it is another word or a comment, and an
// unquoted conf value is one word by construction. \r is in the set because a conf
// saved with CRLF endings would otherwise carry one into every unquoted value.
break;
} else {
$out .= $ch; $i++;
}
}
// Clamped: an unterminated double quote runs $i one past the end.
$end = min($i, $n);
return $out;
}
// Parse a key=value state file (e.g. fallback_state.db, partnership_state.db).