diff --git a/CHANGELOG.md b/CHANGELOG.md index 6637a64e..11863557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,11 @@ All notable changes to this project will be documented in this file. See [standa ### Added * Add cli `dotenv run --` ([#1022](https://github.com/motdotla/dotenv/pull/1022)) -* CLI supports `--debug`, `--override`, and the same `DOTENV_CONFIG_*` environment variables formerly used by preload (`PATH`, `ENCODING`, `QUIET`, `DEBUG`, `OVERRIDE`) +* CLI supports `--debug`, `--override`, `--secure`, `--fast`, and the same `DOTENV_CONFIG_*` environment variables formerly used by preload (`PATH`, `ENCODING`, `QUIET`, `DEBUG`, `OVERRIDE`, `SECURE`, `FAST`) +* `--secure` / `config({ secure: true })` / `DOTENV_CONFIG_SECURE=true` hands off to dotenvx for decryption +* CLI resolves dotenvx from local `@dotenvx/dotenvx` then `PATH`; `config({ secure: true })` requires local `@dotenvx/dotenvx` +* Warn when `encrypted:` values are present without `--secure` / `secure: true` +* `--fast` / `config({ fast: true })` / `parse(src, { fast: true })` / `DOTENV_CONFIG_FAST=true` opts into the ~2x character-scanner parser ([#1010](https://github.com/motdotla/dotenv/pull/1010)). Benchmark with `node scripts/parse-perf.js`. ### Changed diff --git a/README.md b/README.md index 7c3de316..5dd97775 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Import with [ES6](#how-do-i-use-dotenv-with-import): import 'dotenv/config' ``` -`DOTENV_CONFIG_ENCODING`, `DOTENV_CONFIG_PATH`, `DOTENV_CONFIG_QUIET`, `DOTENV_CONFIG_DEBUG`, and `DOTENV_CONFIG_OVERRIDE` provide defaults for `config()` and `dotenv run`. Options/flags passed directly take precedence. +`DOTENV_CONFIG_ENCODING`, `DOTENV_CONFIG_PATH`, `DOTENV_CONFIG_QUIET`, `DOTENV_CONFIG_DEBUG`, `DOTENV_CONFIG_OVERRIDE`, `DOTENV_CONFIG_SECURE`, and `DOTENV_CONFIG_FAST` provide defaults for `config()` and `dotenv run`. Options/flags passed directly take precedence.
bun
@@ -169,13 +169,45 @@ Use `--override` to overwrite existing environment variables, and `--debug` for $ dotenv run --override --debug -- node index.js ``` +Use `--secure` or `config({ secure: true })` to decrypt via [dotenvx](https://dotenvx.com). + +```bash +$ npm i @dotenvx/dotenvx +$ dotenv run --secure -- node index.js +``` + +```js +require('dotenv').config({ secure: true }) +``` + +Or with an environment variable: + +```bash +$ DOTENV_CONFIG_SECURE=true dotenv run -- node index.js +$ DOTENV_CONFIG_SECURE=true node -e "require('dotenv').config()" +``` + +`dotenv run --secure` resolves local `@dotenvx/dotenvx` then `dotenvx` on your `PATH`. `config({ secure: true })` requires a local `@dotenvx/dotenvx` install. + +If your `.env` contains `encrypted:` values and you run without `--secure` / `secure: true`, dotenv warns and leaves them encrypted. + The same `DOTENV_CONFIG_*` environment variables formerly used by preload still work with the CLI. CLI flags take precedence. ```bash $ DOTENV_CONFIG_PATH=./.env.local DOTENV_CONFIG_QUIET=true dotenv run -- node index.js ``` -Supported: `DOTENV_CONFIG_PATH`, `DOTENV_CONFIG_ENCODING`, `DOTENV_CONFIG_QUIET`, `DOTENV_CONFIG_DEBUG`, `DOTENV_CONFIG_OVERRIDE`. +Use `--fast` (or `config({ fast: true })`) for the faster character-scanner parser (~2x). Default remains the classic regex parser. + +```bash +$ dotenv run --fast -- node index.js +``` + +```js +require('dotenv').config({ fast: true }) +``` + +Supported: `DOTENV_CONFIG_PATH`, `DOTENV_CONFIG_ENCODING`, `DOTENV_CONFIG_QUIET`, `DOTENV_CONFIG_DEBUG`, `DOTENV_CONFIG_OVERRIDE`, `DOTENV_CONFIG_SECURE`, `DOTENV_CONFIG_FAST`.
Variable Expansion
@@ -655,6 +687,26 @@ Override any environment variables that have already been set on your machine wi require('dotenv').config({ override: true }) ``` +##### secure + +Default: `false` + +Decrypt via [dotenvx](https://dotenvx.com). Requires a local `@dotenvx/dotenvx` install. + +```js +require('dotenv').config({ secure: true }) +``` + +##### fast + +Default: `false` + +Use the faster character-scanner parser (~2x). Default remains the classic regex parser. + +```js +require('dotenv').config({ fast: true }) +``` + ##### processEnv Default: `process.env` diff --git a/cli.js b/cli.js index a336267a..aa5e6d64 100755 --- a/cli.js +++ b/cli.js @@ -16,7 +16,7 @@ function parseBoolean (value) { function printHelp () { console.log([ - 'Usage: dotenv run [--help] [--quiet] [--debug] [--override] [-f ] -- ', + 'Usage: dotenv run [--help] [--quiet] [--debug] [--override] [--secure] [--fast] [-f ] -- ', '', 'Run a command with environment variables from a .env file.', '', @@ -25,10 +25,13 @@ function printHelp () { ' --quiet suppress the injected env message', ' --debug enable debug logging', ' --override override existing environment variables', + ' --secure decrypt via dotenvx (requires dotenvx)', + ' --fast use the faster character-scanner parser', '', 'Environment variables (same as former preload):', ' DOTENV_CONFIG_PATH, DOTENV_CONFIG_ENCODING, DOTENV_CONFIG_QUIET,', - ' DOTENV_CONFIG_DEBUG, DOTENV_CONFIG_OVERRIDE' + ' DOTENV_CONFIG_DEBUG, DOTENV_CONFIG_OVERRIDE, DOTENV_CONFIG_SECURE,', + ' DOTENV_CONFIG_FAST' ].join('\n')) } @@ -38,6 +41,8 @@ function parseRunArgs (args) { let quiet let debug let override + let secure + let fast let commandIndex = -1 for (let i = 0; i < args.length; i++) { @@ -67,6 +72,16 @@ function parseRunArgs (args) { continue } + if (arg === '--secure') { + secure = true + continue + } + + if (arg === '--fast') { + fast = true + continue + } + if (arg === '-f') { const filepath = args[i + 1] if (!filepath || filepath === '--') { @@ -100,6 +115,8 @@ function parseRunArgs (args) { quiet, debug, override, + secure, + fast, command } } @@ -126,6 +143,12 @@ function optionsFromEnv () { if (process.env.DOTENV_CONFIG_OVERRIDE != null) { options.override = parseBoolean(process.env.DOTENV_CONFIG_OVERRIDE) } + if (process.env.DOTENV_CONFIG_SECURE != null) { + options.secure = parseBoolean(process.env.DOTENV_CONFIG_SECURE) + } + if (process.env.DOTENV_CONFIG_FAST != null) { + options.fast = parseBoolean(process.env.DOTENV_CONFIG_FAST) + } return options } @@ -137,6 +160,8 @@ function resolveRunOptions (parsed) { quiet: envOptions.quiet === true, debug: envOptions.debug === true, override: envOptions.override === true, + secure: envOptions.secure === true, + fast: envOptions.fast === true, paths: ['.env'], defaultPath: true } @@ -153,10 +178,100 @@ function resolveRunOptions (parsed) { if (parsed.quiet != null) options.quiet = parsed.quiet if (parsed.debug != null) options.debug = parsed.debug if (parsed.override != null) options.override = parsed.override + if (parsed.secure != null) options.secure = parsed.secure + if (parsed.fast != null) options.fast = parsed.fast return options } +function resolveDotenvx () { + try { + const pkgPath = require.resolve('@dotenvx/dotenvx/package.json', { paths: [process.cwd()] }) + const pkg = JSON.parse(fs.readFileSync(pkgPath, { encoding: 'utf8' })) + const bin = typeof pkg.bin === 'string' ? pkg.bin : (pkg.bin && pkg.bin.dotenvx) + if (bin) { + return { + command: process.execPath, + args: [path.resolve(path.dirname(pkgPath), bin)] + } + } + } catch (_) {} + + const which = process.platform === 'win32' ? 'where' : 'which' + const result = cp.spawnSync(which, ['dotenvx'], { encoding: 'utf8' }) + if (result.status === 0) { + const binPath = result.stdout.split(/\r?\n/).filter(Boolean)[0] + if (binPath) { + return { + command: binPath, + args: [] + } + } + } + + return null +} + +function buildDotenvxArgs (options, command) { + const args = ['run'] + + for (const filepath of options.paths) { + args.push('-f', filepath) + } + if (options.quiet) args.push('--quiet') + if (options.debug) args.push('--debug') + if (options.override) args.push('--overload') + args.push('--') + for (const part of command) { + args.push(part) + } + + return args +} + +function printSecureMissingError () { + console.error('dotenv: --secure requires dotenvx') + console.error(' npm i @dotenvx/dotenvx') + console.error(' # or: curl -sfS https://dotenvx.sh | sh') +} + +function runSecure (options, command) { + const resolved = resolveDotenvx() + if (!resolved) { + printSecureMissingError() + process.exitCode = 1 + return + } + + const child = cp.spawn(resolved.command, resolved.args.concat(buildDotenvxArgs(options, command)), { + stdio: 'inherit', + shell: process.platform === 'win32' + }) + + child.on('error', function (e) { + console.error(`dotenv: ${e.message}`) + process.exitCode = 1 + }) + + child.on('exit', function (exitCode, signal) { + if (typeof exitCode === 'number') { + process.exit(exitCode) + } else { + process.kill(process.pid, signal) + } + }) +} + +function hasEncryptedValues (parsed) { + for (const key of Object.keys(parsed)) { + const value = parsed[key] + if (typeof value === 'string' && value.indexOf('encrypted:') === 0) { + return true + } + } + return false +} + function loadEnvFiles (options) { const parsedAll = {} const loadedPaths = [] @@ -168,7 +283,7 @@ function loadEnvFiles (options) { for (const filepath of options.paths) { const resolvedPath = path.resolve(process.cwd(), resolveHome(filepath)) try { - const parsed = dotenv.parse(fs.readFileSync(resolvedPath, { encoding: options.encoding })) + const parsed = dotenv.parse(fs.readFileSync(resolvedPath, { encoding: options.encoding }), { fast: options.fast }) dotenv.populate(parsedAll, parsed, populateOptions) loadedPaths.push(filepath) } catch (e) { @@ -181,8 +296,9 @@ function loadEnvFiles (options) { } } + const encrypted = hasEncryptedValues(parsedAll) const injected = dotenv.populate(process.env, parsedAll, populateOptions) - return { injected, loadedPaths } + return { injected, loadedPaths, encrypted } } function run (argv) { @@ -220,6 +336,11 @@ function run (argv) { const options = resolveRunOptions(parsed) + if (options.secure) { + runSecure(options, parsed.command) + return + } + try { const result = loadEnvFiles(options) if (!options.quiet) { @@ -229,6 +350,9 @@ function run (argv) { } console.error(message) } + if (result.encrypted) { + console.error('┆ encrypted values detected — use: dotenv run --secure -- ') + } } catch (e) { console.error(`dotenv: ${e.message}`) process.exitCode = 1 diff --git a/lib/main.d.ts b/lib/main.d.ts index 0f761ddd..7e29af8c 100644 --- a/lib/main.d.ts +++ b/lib/main.d.ts @@ -10,16 +10,29 @@ export interface DotenvPopulateOutput { [name: string]: string; } +export interface DotenvParseOptions { + /** + * Default: `false` + * + * Use the faster character-scanner parser (from PR #1010). + * + * example: `require('dotenv').parse(src, { fast: true })` + */ + fast?: boolean; +} + /** * Parses a string or buffer in the .env file format into an object. * * See https://dotenvx.com/docs * * @param src - contents to be parsed. example: `'DB_HOST=localhost'` + * @param options - parse options. example: `{ fast: true }` * @returns an object with keys and values based on `src`. example: `{ DB_HOST : 'localhost' }` */ export function parse( - src: string | Buffer + src: string | Buffer, + options?: DotenvParseOptions ): T; export interface DotenvConfigOptions { @@ -70,6 +83,24 @@ export interface DotenvConfigOptions { */ override?: boolean; + /** + * Default: `false` + * + * Decrypt via dotenvx. Requires a local `@dotenvx/dotenvx` install. + * + * example: `require('dotenv').config({ secure: true })` + */ + secure?: boolean; + + /** + * Default: `false` + * + * Use the faster character-scanner parser. + * + * example: `require('dotenv').config({ fast: true })` + */ + fast?: boolean; + /** * Default: `process.env` * @@ -87,7 +118,7 @@ export interface DotenvConfigOutput { } type DotenvError = Error & { - code: 'OBJECT_REQUIRED'; + code: 'OBJECT_REQUIRED' | 'SECURE_REQUIRES_DOTENVX'; } export interface DotenvPopulateOptions { diff --git a/lib/main.js b/lib/main.js index 0bc2d618..b5446e14 100644 --- a/lib/main.js +++ b/lib/main.js @@ -11,8 +11,17 @@ function parseBoolean (value) { const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg -// Parse src into an Object -function parse (src) { +// From #1010 (homanp) — hand-written character scanner +const KEY_CHAR = new Uint8Array(256) +for (let _i = 48; _i <= 57; _i++) KEY_CHAR[_i] = 1 +for (let _i = 65; _i <= 90; _i++) KEY_CHAR[_i] = 1 +for (let _i = 97; _i <= 122; _i++) KEY_CHAR[_i] = 1 +KEY_CHAR[45] = 1 // - +KEY_CHAR[46] = 1 // . +KEY_CHAR[95] = 1 // _ + +// Classic regex parser (default) +function parseRegex (src) { const obj = {} // Convert buffer to string @@ -50,6 +59,162 @@ function parse (src) { return obj } +// Parse src into an Object — hand-written character scanner (no regex in hot path) +// Via https://github.com/motdotla/dotenv/pull/1010 — opt-in via { fast: true } +function parseFast (src) { + const obj = {} + let str = typeof src === 'string' ? src : src.toString() + if (str.indexOf('\r') !== -1) { + str = str.replace(/\r\n?/g, '\n') + } + const len = str.length + let i = 0 + + while (i < len) { + let c = str.charCodeAt(i) + + // skip whitespace / blank lines (\r already normalized out) + while (i < len && (c === 32 || c === 9 || c === 10)) { + i++ + c = str.charCodeAt(i) + } + if (i >= len) break + + // comment line + if (c === 35 /* # */) { + while (i < len && str.charCodeAt(i) !== 10) i++ + continue + } + + // optional 'export' prefix: 'export' followed by space/tab + if (c === 101 /* e */ && i + 6 < len && + str.charCodeAt(i + 1) === 120 && + str.charCodeAt(i + 2) === 112 && + str.charCodeAt(i + 3) === 111 && + str.charCodeAt(i + 4) === 114 && + str.charCodeAt(i + 5) === 116) { + const nc = str.charCodeAt(i + 6) + if (nc === 32 || nc === 9) { + i += 7 + while (i < len && ((c = str.charCodeAt(i)) === 32 || c === 9)) i++ + } else { + c = str.charCodeAt(i) + } + } + + // key: [A-Za-z0-9_.-]+ via lookup + const keyStart = i + let stop = 0 + while (i < len) { + stop = str.charCodeAt(i) + if (KEY_CHAR[stop]) i++ + else break + } + if (i === keyStart) { + while (i < len && str.charCodeAt(i) !== 10) i++ + continue + } + const key = str.slice(keyStart, i) + if (i >= len) stop = 0 + + // skip spaces/tabs before separator + if (stop === 32 || stop === 9) { + do { i++; stop = i < len ? str.charCodeAt(i) : 0 } while (stop === 32 || stop === 9) + } + + if (stop === 61 /* = */) { + i++ + } else if (stop === 58 /* : */ && i + 1 < len && (str.charCodeAt(i + 1) === 32 || str.charCodeAt(i + 1) === 9)) { + i++ + } else { + // invalid line — skip + while (i < len && str.charCodeAt(i) !== 10) i++ + continue + } + + // skip spaces/tabs after separator + while (i < len && ((c = str.charCodeAt(i)) === 32 || c === 9)) i++ + + let value + c = i < len ? str.charCodeAt(i) : 0 + + if (c === 39 /* ' */ || c === 34 /* " */ || c === 96 /* ` */) { + const quote = c + const vStart = i + 1 + let j = vStart + while (j < len) { + const cc = str.charCodeAt(j) + if (cc === 92 /* \ */ && j + 1 < len && str.charCodeAt(j + 1) === quote) { + j += 2 + } else if (cc === quote) { + break + } else { + j++ + } + } + if (j >= len) { + // unterminated quote — fall back to unquoted-from-here semantics + const uStart = i + let k = i + while (k < len) { + const cc = str.charCodeAt(k) + if (cc === 35 || cc === 10) break + k++ + } + let end = k + while (end > uStart) { + const cc = str.charCodeAt(end - 1) + if (cc === 32 || cc === 9) end-- + else break + } + value = str.slice(uStart, end) + i = k + if (i < len && str.charCodeAt(i) === 35) { + while (i < len && str.charCodeAt(i) !== 10) i++ + } + } else { + value = str.slice(vStart, j) + i = j + 1 + if (quote === 34 && value.indexOf('\\') !== -1) { + value = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r') + } + // trailing ws + optional comment + while (i < len && ((c = str.charCodeAt(i)) === 32 || c === 9)) i++ + if (i < len && str.charCodeAt(i) === 35) { + while (i < len && str.charCodeAt(i) !== 10) i++ + } + } + } else { + // unquoted: up to # \n. indexOf for fast \n seek. + const vStart = i + let nl = str.indexOf('\n', i) + if (nl === -1) nl = len + let hash = str.indexOf('#', i) + if (hash === -1 || hash > nl) hash = nl + let end = hash + while (end > vStart) { + const cc = str.charCodeAt(end - 1) + if (cc === 32 || cc === 9) end-- + else break + } + value = vStart === end ? '' : str.slice(vStart, end) + i = hash === nl ? hash : nl + } + + obj[key] = value + } + + return obj +} + +// Parse src into an Object +function parse (src, options) { + if (options && parseBoolean(options.fast)) { + return parseFast(src) + } + return parseRegex(src) +} + function _debug (message) { console.log(`┆ ${message}`) } @@ -70,10 +235,55 @@ function _configOptions (options = {}) { if (process.env.DOTENV_CONFIG_QUIET != null) defaults.quiet = parseBoolean(process.env.DOTENV_CONFIG_QUIET) if (process.env.DOTENV_CONFIG_DEBUG != null) defaults.debug = parseBoolean(process.env.DOTENV_CONFIG_DEBUG) if (process.env.DOTENV_CONFIG_OVERRIDE != null) defaults.override = parseBoolean(process.env.DOTENV_CONFIG_OVERRIDE) + if (process.env.DOTENV_CONFIG_SECURE != null) defaults.secure = parseBoolean(process.env.DOTENV_CONFIG_SECURE) + if (process.env.DOTENV_CONFIG_FAST != null) defaults.fast = parseBoolean(process.env.DOTENV_CONFIG_FAST) return { ...defaults, ...options } } +function _hasEncryptedValues (parsed) { + for (const key of Object.keys(parsed)) { + const value = parsed[key] + if (typeof value === 'string' && value.indexOf('encrypted:') === 0) { + return true + } + } + return false +} + +function _requireDotenvx () { + try { + return require(require.resolve('@dotenvx/dotenvx', { paths: [process.cwd()] })) + } catch (_) { + return null + } +} + +function _secureRequiresDotenvxError () { + const err = new Error('SECURE_REQUIRES_DOTENVX: config({ secure: true }) requires @dotenvx/dotenvx. Install with: npm i @dotenvx/dotenvx') + err.code = 'SECURE_REQUIRES_DOTENVX' + return err +} + +function configSecure (options) { + const dotenvx = _requireDotenvx() + if (!dotenvx || typeof dotenvx.config !== 'function') { + console.error('dotenv: secure requires dotenvx') + console.error(' npm i @dotenvx/dotenvx') + console.error(' # or: curl -sfS https://dotenvx.sh | sh') + return { error: _secureRequiresDotenvxError() } + } + + return dotenvx.config({ + path: options.path, + encoding: options.encoding, + quiet: options.quiet, + debug: options.debug, + override: options.override, + processEnv: options.processEnv + }) +} + function configDotenv (options) { options = _configOptions(options) const dotenvPath = path.resolve(process.cwd(), '.env') @@ -109,10 +319,11 @@ function configDotenv (options) { // parsed data, we will combine it with process.env (or options.processEnv if provided). let lastError const parsedAll = {} + const parseOptions = { fast: options.fast } for (const path of optionPaths) { try { // Specifying an encoding returns a string instead of a buffer - const parsed = DotenvModule.parse(fs.readFileSync(path, { encoding })) + const parsed = DotenvModule.parse(fs.readFileSync(path, { encoding }), parseOptions) DotenvModule.populate(parsedAll, parsed, options) } catch (e) { @@ -123,6 +334,7 @@ function configDotenv (options) { } } + const encrypted = _hasEncryptedValues(parsedAll) const populated = DotenvModule.populate(processEnv, parsedAll, options) if (debug || !quiet) { @@ -143,6 +355,10 @@ function configDotenv (options) { _log(`injected env (${keysCount}) from ${shortPaths.join(',')}`) } + if (encrypted) { + console.error('┆ encrypted values detected — use: require(\'dotenv\').config({ secure: true })') + } + if (lastError) { return { parsed: parsedAll, error: lastError } } else { @@ -152,6 +368,12 @@ function configDotenv (options) { // Populates process.env from .env file function config (options) { + options = _configOptions(options) + + if (parseBoolean(options.secure)) { + return configSecure(options) + } + return DotenvModule.configDotenv(options) } diff --git a/scripts/parse-perf.js b/scripts/parse-perf.js new file mode 100644 index 00000000..d3dd1af7 --- /dev/null +++ b/scripts/parse-perf.js @@ -0,0 +1,54 @@ +'use strict' +// Performance check for `dotenv.parse()`. +// Not run as part of `npm test` (no TAP assertions); invoke directly: +// node scripts/parse-perf.js +// Reports median ms over 7 runs of 5000 parse() calls on a representative .env. + +const dotenv = require('../lib/main.js') + +const sample = [ + '# Database', + 'DATABASE_URL=postgresql://user:password@localhost:5432/mydb?schema=public', + 'REDIS_URL=redis://default:password@localhost:6379', + '', + '# Auth', + 'JWT_SECRET=verylongrandomstringthatlookslikeasecretsharedacrossservices', + 'OAUTH_GOOGLE_CLIENT_ID=1234567890-abcdefg.apps.googleusercontent.com', + 'OAUTH_GITHUB_CLIENT_SECRET=ghp_abcdefghijklmnopqrstuvwxyz', + '', + '# AWS', + 'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE', + 'AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + 'S3_BUCKET=my-app-uploads-prod', + '', + '# Quoted / multiline', + 'EMAIL_FROM="MyApp "', + 'ALLOWED_ORIGINS="https://myapp.com,https://www.myapp.com"', + 'MULTILINE_KEY="line one\\nline two\\nline three"', + '', + '# Misc', + 'NODE_ENV=production', + 'PORT=3000', + 'LOG_LEVEL=info', + 'FEATURE_FLAG_A=true', + '' +].join('\n').repeat(8) + +const buf = Buffer.from(sample) +const N = 5000 + +function bench (label, options) { + for (let i = 0; i < 200; i++) dotenv.parse(buf, options) + + const runs = [] + for (let r = 0; r < 7; r++) { + const t = process.hrtime.bigint() + for (let i = 0; i < N; i++) dotenv.parse(buf, options) + runs.push(Number(process.hrtime.bigint() - t) / 1e6) + } + runs.sort(function (a, b) { return a - b }) + console.log(label + ' x ' + N + ': median ' + runs[Math.floor(runs.length / 2)].toFixed(2) + ' ms') +} + +bench('parse()', undefined) +bench('parse({ fast: true })', { fast: true }) diff --git a/tests/test-config.js b/tests/test-config.js index 8227137f..154c1e3f 100644 --- a/tests/test-config.js +++ b/tests/test-config.js @@ -23,6 +23,8 @@ t.afterEach(() => { delete process.env.DOTENV_CONFIG_QUIET delete process.env.DOTENV_CONFIG_DEBUG delete process.env.DOTENV_CONFIG_OVERRIDE + delete process.env.DOTENV_CONFIG_SECURE + delete process.env.DOTENV_CONFIG_FAST }) t.test('uses DOTENV_CONFIG_* values as config defaults', ct => { @@ -406,3 +408,40 @@ t.test('logs if debug set', ct => { dotenv.config({ path: testPath, debug: true }) ct.ok(logStub.called) }) + +t.test('config({ secure: true }) errors when dotenvx is not installed', ct => { + errorStub = sinon.stub(console, 'error') + + const result = dotenv.config({ secure: true, quiet: true }) + + ct.equal(result.error.code, 'SECURE_REQUIRES_DOTENVX') + ct.match(errorStub.firstCall.args[0], /secure requires dotenvx/) + ct.end() +}) + +t.test('DOTENV_CONFIG_SECURE=true errors when dotenvx is not installed', ct => { + process.env.DOTENV_CONFIG_SECURE = 'true' + errorStub = sinon.stub(console, 'error') + + const result = dotenv.config({ quiet: true }) + + ct.equal(result.error.code, 'SECURE_REQUIRES_DOTENVX') + ct.end() +}) + +t.test('config warns when encrypted values are present without secure', ct => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dotenv-secure-')) + const envPath = path.join(dir, '.env') + fs.writeFileSync(envPath, 'HELLO="encrypted:abc123"\n') + errorStub = sinon.stub(console, 'error') + + const processEnv = {} + const result = dotenv.config({ path: envPath, processEnv }) + + ct.equal(processEnv.HELLO, 'encrypted:abc123') + ct.equal(result.parsed.HELLO, 'encrypted:abc123') + ct.ok(errorStub.calledWith('┆ encrypted values detected — use: require(\'dotenv\').config({ secure: true })')) + + fs.rmSync(dir, { recursive: true, force: true }) + ct.end() +}) diff --git a/tests/test-parse-fast.js b/tests/test-parse-fast.js new file mode 100644 index 00000000..fe69c80a --- /dev/null +++ b/tests/test-parse-fast.js @@ -0,0 +1,62 @@ +const fs = require('fs') +const t = require('tap') + +const dotenv = require('../lib/main') + +function assertSameParse (ct, src, label) { + const classic = dotenv.parse(src) + const fast = dotenv.parse(src, { fast: true }) + ct.same(fast, classic, label || 'fast parse matches classic parse') +} + +t.test('fast parse matches classic parse for tests/.env', ct => { + const src = fs.readFileSync('tests/.env', { encoding: 'utf8' }) + assertSameParse(ct, src) + ct.end() +}) + +t.test('fast parse matches classic parse for multiline fixture', ct => { + const src = fs.readFileSync('tests/.env.multiline', { encoding: 'utf8' }) + assertSameParse(ct, src) + ct.end() +}) + +t.test('fast parse matches classic parse for edge cases', ct => { + const cases = [ + 'BASIC=basic', + 'export KEY=value', + 'KEY: value', + 'EMPTY=', + "SINGLE='single'", + 'DOUBLE="double"', + 'BACKTICK=`backtick`', + 'DOUBLE="line one\\nline two"', + 'INLINE=value # comment', + 'HASH="value#notcomment"', + 'EQUALS==value', + '# comment only\n', + '', + 'KEY=val\r\nOTHER=ok\r', + 'MULTI="one\ntwo"', + 'ESCAPED="say \\"hi\\""' + ] + + for (const src of cases) { + assertSameParse(ct, src, JSON.stringify(src)) + } + ct.end() +}) + +t.test('config({ fast: true }) loads with fast parser', ct => { + const processEnv = {} + const result = dotenv.config({ + path: 'tests/.env', + quiet: true, + fast: true, + processEnv + }) + + ct.equal(processEnv.BASIC, 'basic') + ct.equal(result.parsed.BASIC, 'basic') + ct.end() +}) diff --git a/tests/test-parse-perf.js b/tests/test-parse-perf.js new file mode 100644 index 00000000..f947e251 --- /dev/null +++ b/tests/test-parse-perf.js @@ -0,0 +1,49 @@ +'use strict' +// Performance check for `dotenv.parse()`. +// Not run as part of `npm test` (no TAP assertions); invoke directly: +// node tests/test-parse-perf.js +// Reports median ms over 7 runs of 5000 parse() calls on a representative .env. + +const dotenv = require('../lib/main.js') + +const sample = [ + '# Database', + 'DATABASE_URL=postgresql://user:password@localhost:5432/mydb?schema=public', + 'REDIS_URL=redis://default:password@localhost:6379', + '', + '# Auth', + 'JWT_SECRET=verylongrandomstringthatlookslikeasecretsharedacrossservices', + 'OAUTH_GOOGLE_CLIENT_ID=1234567890-abcdefg.apps.googleusercontent.com', + 'OAUTH_GITHUB_CLIENT_SECRET=ghp_abcdefghijklmnopqrstuvwxyz', + '', + '# AWS', + 'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE', + 'AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + 'S3_BUCKET=my-app-uploads-prod', + '', + '# Quoted / multiline', + 'EMAIL_FROM="MyApp "', + 'ALLOWED_ORIGINS="https://myapp.com,https://www.myapp.com"', + 'MULTILINE_KEY="line one\\nline two\\nline three"', + '', + '# Misc', + 'NODE_ENV=production', + 'PORT=3000', + 'LOG_LEVEL=info', + 'FEATURE_FLAG_A=true', + '' +].join('\n').repeat(8) + +const buf = Buffer.from(sample) +const N = 5000 + +for (let i = 0; i < 200; i++) dotenv.parse(buf) + +const runs = [] +for (let r = 0; r < 7; r++) { + const t = process.hrtime.bigint() + for (let i = 0; i < N; i++) dotenv.parse(buf) + runs.push(Number(process.hrtime.bigint() - t) / 1e6) +} +runs.sort(function (a, b) { return a - b }) +console.log('parse() x ' + N + ': median ' + runs[Math.floor(runs.length / 2)].toFixed(2) + ' ms') diff --git a/tests/types/test.ts b/tests/types/test.ts index b652b211..5dbae01f 100644 --- a/tests/types/test.ts +++ b/tests/types/test.ts @@ -11,6 +11,7 @@ config({ }); parse("test"); +parse("test", { fast: true }); const parsed = parse("NODE_ENV=production\nDB_HOST=a.b.c"); const dbHost: string = parsed["DB_HOST"]; @@ -23,6 +24,11 @@ config({ processEnv: process.env, }); +config({ + secure: true, + fast: true, +}); + // populate() should accept DotenvPopulateOptions (debug + override only), // not the broader DotenvConfigOptions const target: DotenvPopulateInput = {};