///|
/// dotenv parsing, env file layout and key management.
///
/// Scope layout:
///
/// - project (default): `./.env`, with `./.mbenv` also loaded when present;
/// - global (`--global`): `$HOME/.mbenv`, which additionally stores the
/// encryption key on a `# mb-env-key:` directive line.
///
/// Values may be plain (`KEY=value`) or encrypted (`KEY=ENC[...]`), so a file
/// can be listed without the key and a plaintext `.env` keeps working.
///|
/// The directive line that carries the encryption key in `$HOME/.mbenv`.
const ENV_KEY_DIRECTIVE : String = "# mb-env-key:"
///|
/// The global env file: `$HOME/.mbenv`.
fn global_env_file() -> String raise MbError {
path_join(home_dir(), ".mbenv")
}
///|
/// Parse a single dotenv line into a key/value pair. Blank lines, comments and
/// lines without `=` are skipped. Returns the raw value (still `ENC[...]` when
/// encrypted).
fn parse_dotenv_line(line : String) -> (String, String)? {
let s = line.trim()
if s == "" || s.has_prefix("#") {
return None
}
let body = if s.has_prefix("export ") {
s[7:].trim().to_owned()
} else {
s.to_owned()
}
match body.find("=") {
None => None
Some(i) => {
let key = body[:i].trim().to_owned()
if key == "" {
return None
}
Some((key, parse_dotenv_value(body[i + 1:].trim().to_owned())))
}
}
}
///|
/// Unquote and unescape a raw dotenv value.
fn parse_dotenv_value(raw : String) -> String {
let chars = raw.to_array()
if chars.length() >= 2 {
let first = chars[0]
let last = chars[chars.length() - 1]
if first == '"' && last == '"' {
return unescape_double(raw[1:raw.length() - 1].to_owned())
}
if first == '\'' && last == '\'' {
return raw[1:raw.length() - 1].to_owned()
}
}
// An unquoted `#` starts a trailing comment.
match raw.find(" #") {
Some(i) => raw[:i].trim().to_owned()
None => raw
}
}
///|
fn unescape_double(s : String) -> String {
let chars = s.to_array()
let out : Array[String] = []
let mut i = 0
while i < chars.length() {
if chars[i] == '\\' && i + 1 < chars.length() {
let c = chars[i + 1]
if c == 'n' {
out.push("\n")
} else if c == 't' {
out.push("\t")
} else {
out.push(c.to_string())
}
i = i + 2
} else {
out.push(chars[i].to_string())
i = i + 1
}
}
out.join("")
}
///|
/// Quote a plain value so it round-trips through the parser.
fn format_dotenv_value(value : String) -> String {
if value == "" {
return "\"\""
}
let needs_quotes = value.contains_char(' ') ||
value.contains_char('\t') ||
value.contains_char('#') ||
value.contains_char('"') ||
value.contains_char('\'') ||
value.contains_char('\n') ||
value.contains_char('\\')
if !needs_quotes {
return value
}
let escaped = value
.replace(old="\\", new="\\\\")
.replace(old="\"", new="\\\"")
.replace(old="\n", new="\\n")
.replace(old="\t", new="\\t")
"\"" + escaped + "\""
}
///|
/// Render one entry as a dotenv line. Encrypted values are written verbatim.
fn format_dotenv_line(key : String, value : String) -> String {
if is_encrypted_value(value) {
key + "=" + value
} else {
key + "=" + format_dotenv_value(value)
}
}
///|
/// Parse the text of a dotenv file.
fn parse_dotenv(text : String) -> Array[(String, String)] {
let out : Array[(String, String)] = []
for line in text.split("\n") {
match parse_dotenv_line(line.to_owned()) {
Some(pair) => out.push(pair)
None => ()
}
}
out
}
///|
/// Read and parse a dotenv file, returning an empty list when it is absent.
fn read_env_file(path : String) -> Array[(String, String)] {
if !file_exists(path) {
return []
}
let text = read_text(path) catch { _ => return [] }
parse_dotenv(text)
}
///|
/// Write entries to a dotenv file, replacing its contents.
fn write_env_file(
path : String,
entries : Array[(String, String)],
) -> Unit raise MbError {
let lines : Array[String] = []
for entry in entries {
let (key, value) = entry
lines.push(format_dotenv_line(key, value))
}
let body = if lines.is_empty() { "" } else { lines.join("\n") + "\n" }
write_text(path, body)
}
///|
/// Read `$HOME/.mbenv`, splitting out the key directive from the entries.
fn read_global_env() -> (String?, Array[(String, String)]) raise MbError {
let path = global_env_file()
if !file_exists(path) {
return (None, [])
}
let text = read_text(path) catch { _ => return (None, []) }
let mut key : String? = None
let entries : Array[(String, String)] = []
for line in text.split("\n") {
let l = line.trim()
if l.has_prefix(ENV_KEY_DIRECTIVE) {
let k = l[ENV_KEY_DIRECTIVE.length():].trim().to_owned()
if k != "" {
key = Some(k)
}
} else {
match parse_dotenv_line(line.to_owned()) {
Some(pair) => entries.push(pair)
None => ()
}
}
}
(key, entries)
}
///|
/// Write `$HOME/.mbenv`, preserving the key directive.
fn write_global_env(
key : String?,
entries : Array[(String, String)],
) -> Unit raise MbError {
let lines : Array[String] = []
match key {
Some(k) => lines.push(ENV_KEY_DIRECTIVE + " " + k)
None => ()
}
for entry in entries {
let (k, v) = entry
lines.push(format_dotenv_line(k, v))
}
let body = if lines.is_empty() { "" } else { lines.join("\n") + "\n" }
write_text(global_env_file(), body)
}
///|
/// The encryption key: `MB_ENV_KEY` when set, otherwise the directive stored
/// in `$HOME/.mbenv`.
fn read_env_key() -> String? raise MbError {
let from_env = env_or("MB_ENV_KEY", "")
if from_env != "" {
return Some(from_env)
}
match read_global_env() {
(Some(k), _) => Some(k)
(None, _) => None
}
}
///|
/// Read one dotenv file, decrypting `ENC[...]` values with the available key.
/// Plain values are returned unchanged.
fn load_env_file(path : String) -> Array[(String, String)] raise MbError {
let entries = read_env_file(path)
let mut key : String? = None
let out : Array[(String, String)] = []
for entry in entries {
let (k, v) = entry
if is_encrypted_value(v) {
let master = match key {
Some(m) => m
None => {
let m = match read_env_key() {
Some(m) => m
None =>
raise MbError(
"encrypted values in " +
path +
" but no key; set MB_ENV_KEY or run `mb env set`",
)
}
key = Some(m)
m
}
}
out.push((k, decrypt_value(v, master)))
} else {
out.push((k, v))
}
}
out
}
///|
/// Load the dotenv files that apply to the current project and export their
/// values into the process environment, so child processes inherit them.
///
/// Files are read in this order, later ones overriding earlier ones:
/// `$HOME/.mbenv`, `./.mbenv`, `./.env`. Values written as `ENC[...]` are
/// decrypted with the key from `MB_ENV_KEY` or `$HOME/.mbenv`.
pub fn load_dotenv() -> Unit raise MbError {
let paths : Array[String] = []
let global = global_env_file() catch { _ => "" }
if global != "" {
paths.push(global)
}
paths.push(".mbenv")
paths.push(".env")
for path in paths {
for entry in load_env_file(path) {
let (k, v) = entry
@env.set_env_var(k, v)
}
}
}