dotenv-verbatim 0.3.1

A .env loader that takes the value verbatim: no expansion, no escapes, no inline comments; one malformed line is skipped, not the rest of the file
Documentation
  • Coverage
  • 100%
    15 out of 15 items documented0 out of 2 items with examples
  • Size
  • Source code size: 35.77 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 342.13 kB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 3s Average build duration of successful builds.
  • all releases: 3s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • MarketKernel/dotenv-verbatim
    0 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • MarketKernel

dotenv-verbatim

A .env loader that takes the value verbatim: everything after the first =, with no expansion, no escape processing, and no inline-comment stripping. One malformed line is skipped, never the rest of the file. A variable already present in the process environment always wins, and load reports which of the file's declarations that leaves out of force.

// At startup, before any thread or task is spawned.
let loaded = dotenv_verbatim::load(Path::new(".env"));

Why this exists

It was written because the established crates corrupt or drop real configuration files. Every example below was run against dotenvy 0.15.7 and dotenvs 0.2.2. The dotenv 0.15 crate is unmaintained since 2019. dotenv-parser 0.1.3 also fails these files, but differently: it rejects examples 1 and 4 outright and truncates at #, while keeping $ verbatim.

1. An unquoted value containing a space aborts the whole file.

BASE_ADDRESSES=http://a http://b application
AFTER=1
dotenvy::from_path(".env")  ->  Err(LineParse("http://a http://b application", 9))
std::env::var("AFTER")      ->  Err(NotPresent)

The parser rejects the line, and from_path stops there, so nothing at all is loaded, including the lines that were fine. A=b c is enough to trigger it. Space-separated lists are ordinary in deployed .env files, and quoting them everywhere is a migration across every machine that has one.

2. $ in a value is silently rewritten.

PLAIN=abc
SECRET=p$word${PLAIN}x
dotenvy  ->  ("SECRET", "pabcx")
dotenvs  ->  ("SECRET", "pabcx")

$word is an undefined variable, so it expands to nothing; ${PLAIN} expands to its value. A password, a token, or a hash containing $ reaches the application quietly mangled, and no error says so. Related: dotenvy rejects Q="q$PLAIN" outright with LineParse.

3. # truncates a value.

HASH=a#b
dotenvs  ->  ("HASH", "a")

4. A malformed line silently discards everything after it.

A=1
nosep
B=2
dotenvs::from_path(".env")  ->  Ok, but the iterator yields only ("A", "1")

No error is returned. Half the configuration is gone, and the process starts with defaults for the missing half.

This crate makes the opposite choices. The value is whatever follows the first =, trimmed, with at most one pair of matching surrounding quotes removed. $, #, spaces, and = inside a value are data. A line that cannot be a key/value pair is skipped and reported by line number, and parsing continues. A key or value containing a NUL byte is skipped the same way, because set_var panics on one.

What it does support

  • KEY=value, with an optional export prefix followed by whitespace.
  • Blank lines and whole-line # comments.
  • Surrounding " or ' removed as one pair; anything inside is untouched.
  • Leading and trailing whitespace around key and value trimmed.
  • KEY=a=b=c keeps a=b=c.
  • CRLF line endings, and a leading UTF-8 BOM.
  • A key repeated in one file: the first occurrence wins, as in dotenvy and dotenvs without override.
  • A missing file is not an error: the deployed environment is expected to provide real variables.
  • A variable the process already carries keeps its value; the file's line is reported as overridden rather than applied.

What it deliberately does not support

  • Variable expansion ($VAR, ${VAR}).
  • Escape sequences inside quotes (\n stays two characters).
  • Inline comments after a value.
  • Multi-line quoted values.

Each one exists to keep a value byte-identical to what the file says.

What the load reports

load returns a Loaded, so a caller can answer "which line of the file is not in force, and why" without reading the file a second time and reimplementing the priority rule. The loader is the only code that knows what actually got set.

let loaded = dotenv_verbatim::load(Path::new(".env"));
for key in &loaded.overridden { println!("{key}: from the process environment, not from .env"); }
for key in &loaded.repeated { println!("{key}: declared twice in .env, the first line is in force"); }
for line in &loaded.skipped { eprintln!(".env:{line}: not a KEY=VALUE line, skipped"); }
  • found — the file was read. False means absent or unreadable, which is not an error; every list is then empty.
  • applied — the variables this load set, in file order.
  • overridden — declared by the file, not set: the process already carried the variable.
  • repeated — declared more than once in the file; the later line lost to the earlier one.
  • skipped — line numbers, from 1, that were neither a pair nor a comment or blank.

overridden and repeated are separate because they are different facts for whoever reads the diagnostics: one says the deployment supplies the value, the other says the file contradicts itself. An empty value stays a set empty string — treating "empty" as "unset" is the consumer's call, not the loader's.

Parsing without touching the environment

parse is pure and is where the tests live. It returns the entries in file order plus the line numbers that were skipped, so a caller can log them.

let parsed = dotenv_verbatim::parse(&content);
for entry in &parsed.entries { /* ... */ }
for line in &parsed.skipped { eprintln!(".env:{line}: not a KEY=VALUE line, skipped"); }

A note on set_var

load calls std::env::set_var, which is unsound while another thread reads the environment. Call it once at startup, before spawning threads or tasks. This crate is Rust edition 2021, where set_var is a safe function, so a caller on edition 2024 gets no unsafe block of its own; that is a lint difference, not a soundness one, and the rule above still holds. Every loader in this space has the same constraint.

License

MIT OR Apache-2.0