diff --git a/.env.example b/.env.example index c2a416d2ae..5e36a6871c 100644 --- a/.env.example +++ b/.env.example @@ -2355,7 +2355,8 @@ INSPECTOR_INTERNAL_INGEST_TOKEN= # unset): path to a file whose trimmed content is the token. # OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE= # Quota Sharing (Group B — planos 16+22) -QUOTA_STORE_DRIVER=sqlite # sqlite | redis +# sqlite | redis +QUOTA_STORE_DRIVER=sqlite # QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis) # QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo) # QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index c5b280ba64..bfec91fead 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -93,6 +93,28 @@ function migrateElectronServerEnv(dataDir) { } } +/** + * Parse a `.env` value with dotenv-compatible comment handling. + * + * Without this, `KEY=value # note` stored the comment text as part of the + * value. The shipped .env ships exactly such a line for QUOTA_STORE_DRIVER, and + * consumers compare it with `===`, so annotating a variable inline silently + * disabled it (#10100). + * + * Quoted values are returned verbatim — a `#` inside quotes is data. For + * unquoted values a `#` *preceded by whitespace* starts a comment, so + * `pass#word` is preserved. + */ +function parseEnvValue(raw) { + const value = String(raw).trim(); + + const quoted = value.match(/^(['"])([\s\S]*)\1\s*(?:#.*)?$/); + if (quoted) return quoted[2]; + + const commentIdx = value.search(/\s#/); + return (commentIdx === -1 ? value : value.slice(0, commentIdx)).trim(); +} + function loadEnvFile() { const envPaths = []; const loadedEnvPaths = []; @@ -128,9 +150,8 @@ function loadEnvFile() { const eqIdx = trimmed.indexOf("="); if (eqIdx > 0) { const key = trimmed.slice(0, eqIdx).trim(); - const value = trimmed.slice(eqIdx + 1).trim(); if (process.env[key] === undefined) { - process.env[key] = value.replace(/^["']|["']$/g, ""); + process.env[key] = parseEnvValue(trimmed.slice(eqIdx + 1)); } } } diff --git a/tests/unit/cli-env-inline-comment-10100.test.ts b/tests/unit/cli-env-inline-comment-10100.test.ts new file mode 100644 index 0000000000..2e3b41a725 --- /dev/null +++ b/tests/unit/cli-env-inline-comment-10100.test.ts @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +// #10100 — the .env loader kept inline comments inside values, so the shipped +// `QUOTA_STORE_DRIVER=sqlite # sqlite | redis` line produced the literal value +// "sqlite # sqlite | redis". Consumers compare with `===`, so a +// user annotating `QUOTA_STORE_DRIVER=redis # ...` silently got SQLite with no +// warning (the existing warning lives inside the `redis` branch). + +const LOADER = path.resolve("bin/omniroute.mjs"); + +/** + * The loader is a CLI entrypoint with side effects on import, so exercise the + * pure helper by extracting it from source rather than importing the module. + */ +function loadParseEnvValue(): (raw: string) => string { + const source = fs.readFileSync(LOADER, "utf8"); + const start = source.indexOf("function parseEnvValue("); + assert.ok(start > -1, "parseEnvValue should exist in bin/omniroute.mjs"); + // Walk to the end of the function body. + let depth = 0; + let end = start; + for (let i = source.indexOf("{", start); i < source.length; i++) { + if (source[i] === "{") depth++; + else if (source[i] === "}") { + depth--; + if (depth === 0) { + end = i + 1; + break; + } + } + } + return new Function(`${source.slice(start, end)}; return parseEnvValue;`)() as ( + raw: string + ) => string; +} + +const parseEnvValue = loadParseEnvValue(); + +test("an unquoted inline comment is stripped", () => { + assert.equal(parseEnvValue("sqlite # sqlite | redis"), "sqlite"); + assert.equal(parseEnvValue("redis # sqlite | redis"), "redis"); + assert.equal(parseEnvValue("value\t# tab-separated comment"), "value"); +}); + +test("a '#' with no preceding whitespace is part of the value", () => { + // dotenv semantics — passwords and fragments must survive. + assert.equal(parseEnvValue("pass#word"), "pass#word"); + assert.equal( + parseEnvValue("https://example.com/page#section"), + "https://example.com/page#section" + ); +}); + +test("quoted values are returned verbatim, including '#'", () => { + assert.equal(parseEnvValue('"sqlite # not a comment"'), "sqlite # not a comment"); + assert.equal(parseEnvValue("'a # b'"), "a # b"); + // A comment may still follow a closing quote. + assert.equal(parseEnvValue('"sqlite" # sqlite | redis'), "sqlite"); +}); + +test("plain values are unchanged", () => { + assert.equal(parseEnvValue("sqlite"), "sqlite"); + assert.equal(parseEnvValue(" spaced "), "spaced"); + assert.equal(parseEnvValue(""), ""); +}); + +test(".env.example no longer annotates QUOTA_STORE_DRIVER inline", () => { + const example = fs.readFileSync(path.resolve(".env.example"), "utf8"); + const line = example.split("\n").find((l) => l.startsWith("QUOTA_STORE_DRIVER=")); + assert.ok(line, "QUOTA_STORE_DRIVER should still be documented"); + assert.equal(line, "QUOTA_STORE_DRIVER=sqlite"); + // Guard the whole file against reintroducing the pattern on unquoted values. + const offenders = example + .split("\n") + .filter((l) => /^[A-Z0-9_]+=[^"'#\n]*\s#/.test(l)) + .slice(0, 5); + assert.deepEqual(offenders, [], `unquoted inline comments would land in the value: ${offenders}`); +});