audit
This commit is contained in:
@@ -7,60 +7,153 @@ const ENV_PATH = process.env.ENV_FILE
|
||||
? path.resolve(process.env.ENV_FILE)
|
||||
: path.resolve(process.cwd(), '.env');
|
||||
|
||||
/**
|
||||
* Serialize a runtime value for .env storage.
|
||||
*
|
||||
* Default container: backticks. Under dotenv v17, backtick-wrapped values are
|
||||
* preserved verbatim (literal newlines and inner quotes/backslashes all round-trip
|
||||
* without escape processing), which is the only container that survives quotes
|
||||
* AND newlines cleanly. Double-quoted values only decode `\n` / `\r`; `\"` and
|
||||
* `\\` stay literal, so quoted-with-escapes doesn't round-trip.
|
||||
*
|
||||
* Fallback for values that themselves contain a backtick (vanishingly rare in
|
||||
* env-style config): double-quote with escape-encoded `\\`, `\"`, `\n`, `\r`,
|
||||
* `\t`. Caveat — CONFIG will receive the still-escaped `\"` / `\\` after boot
|
||||
* because dotenv v17 won't reverse those. Flagged in the key-count verification
|
||||
* at the bottom of writeEnvFile; the combination is unreachable via the UI.
|
||||
*/
|
||||
function encodeEnvValue(v) {
|
||||
const s = String(v == null ? '' : v);
|
||||
if (!s.includes('`')) return '`' + s + '`';
|
||||
const escaped = s
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\t/g, '\\t');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a raw .env value. Backtick and double-quote containers supported;
|
||||
* unquoted values pass through (hand-edited .env back-compat).
|
||||
*/
|
||||
function decodeEnvValue(raw) {
|
||||
if (raw.length >= 2 && raw.startsWith('`') && raw.endsWith('`')) {
|
||||
return raw.slice(1, -1);
|
||||
}
|
||||
if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
|
||||
const inner = raw.slice(1, -1);
|
||||
let out = '';
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
if (inner[i] === '\\' && i + 1 < inner.length) {
|
||||
const next = inner[i + 1];
|
||||
if (next === 'n') out += '\n';
|
||||
else if (next === 'r') out += '\r';
|
||||
else if (next === 't') out += '\t';
|
||||
else if (next === '"') out += '"';
|
||||
else if (next === '\\') out += '\\';
|
||||
else out += inner[i] + next;
|
||||
i++;
|
||||
} else {
|
||||
out += inner[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current .env file and parse into a key->value Map.
|
||||
* Backtick-wrapped values may span multiple physical lines (dotenv behavior);
|
||||
* this reader joins continuation lines until the closing backtick is found.
|
||||
* Double-quoted values are decoded (`\n`/`\r` escapes processed);
|
||||
* unquoted values pass through.
|
||||
*/
|
||||
function readEnvFile() {
|
||||
if (!fs.existsSync(ENV_PATH)) return new Map();
|
||||
const lines = fs.readFileSync(ENV_PATH, 'utf8').split('\n');
|
||||
const map = new Map();
|
||||
for (const line of lines) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = line.indexOf('=');
|
||||
if (idx === -1) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
map.set(key, value);
|
||||
let value = line.slice(idx + 1);
|
||||
if (value.trimStart().startsWith('`')) {
|
||||
let btCount = (value.match(/`/g) || []).length;
|
||||
while (btCount < 2 && i + 1 < lines.length) {
|
||||
i++;
|
||||
value += '\n' + lines[i];
|
||||
btCount = (value.match(/`/g) || []).length;
|
||||
}
|
||||
}
|
||||
map.set(key, decodeEnvValue(value.trim()));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a Map of key->value back to the .env file,
|
||||
* preserving comments and blank lines.
|
||||
* preserving comments and blank lines. Values are encoded via encodeEnvValue.
|
||||
*
|
||||
* After write, re-reads the file and throws if the key count doesn't match the
|
||||
* expected count — catches truncation or corrupted-quote escaping.
|
||||
*/
|
||||
function writeEnvFile(updates) {
|
||||
const expected = updates.size;
|
||||
|
||||
if (!fs.existsSync(ENV_PATH)) {
|
||||
const lines = [];
|
||||
for (const [k, v] of updates) lines.push(`${k}=${v}`);
|
||||
for (const [k, v] of updates) lines.push(`${k}=${encodeEnvValue(v)}`);
|
||||
fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const raw = fs.readFileSync(ENV_PATH, 'utf8');
|
||||
const lines = raw.split('\n');
|
||||
const written = new Set();
|
||||
const result = [];
|
||||
|
||||
const raw = fs.readFileSync(ENV_PATH, 'utf8');
|
||||
const lines = raw.split('\n');
|
||||
const written = new Set();
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) { result.push(line); continue; }
|
||||
const idx = line.indexOf('=');
|
||||
if (idx === -1) { result.push(line); continue; }
|
||||
const key = line.slice(0, idx).trim();
|
||||
|
||||
const result = lines.map(line => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||
const idx = line.indexOf('=');
|
||||
if (idx === -1) return line;
|
||||
const key = line.slice(0, idx).trim();
|
||||
if (updates.has(key)) {
|
||||
written.add(key);
|
||||
return `${key}=${updates.get(key)}`;
|
||||
const spanStart = i;
|
||||
let valueSoFar = line.slice(idx + 1);
|
||||
if (valueSoFar.trimStart().startsWith('`')) {
|
||||
let btCount = (valueSoFar.match(/`/g) || []).length;
|
||||
while (btCount < 2 && i + 1 < lines.length) {
|
||||
i++;
|
||||
valueSoFar += '\n' + lines[i];
|
||||
btCount = (valueSoFar.match(/`/g) || []).length;
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.has(key)) {
|
||||
written.add(key);
|
||||
result.push(`${key}=${encodeEnvValue(updates.get(key))}`);
|
||||
} else {
|
||||
for (let j = spanStart; j <= i; j++) result.push(lines[j]);
|
||||
}
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
// Append any new keys not already in the file
|
||||
for (const [k, v] of updates) {
|
||||
if (!written.has(k)) result.push(`${k}=${v}`);
|
||||
for (const [k, v] of updates) {
|
||||
if (!written.has(k)) result.push(`${k}=${encodeEnvValue(v)}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8');
|
||||
}
|
||||
|
||||
fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8');
|
||||
const roundtrip = readEnvFile();
|
||||
if (roundtrip.size !== expected) {
|
||||
throw new Error(`writeEnvFile: key count mismatch after write (expected ${expected}, got ${roundtrip.size})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user