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 = {};