///| A minimal `.env` reader and writer.
///
/// Only the shape the tool needs is supported: `KEY=VALUE` lines, optional
/// `export ` prefixes, `#` comments and single or double quotes around a
/// value. Everything else in the file is preserved when it is rewritten.
///|
pub struct EnvFile {
path : String
keys : Array[String]
values : Array[String]
}
///|
pub fn EnvFile::get(self : EnvFile, name : String) -> String? {
let mut found : String? = None
let mut index = 0
while index < self.keys.length() {
if self.keys[index] == name {
found = Some(self.values[index])
}
index = index + 1
}
found
}
///|
fn unquote_env_value(raw : String) -> String {
let text = trim(raw)
if text.length() >= 2 {
let first = text[0:1]
let last = text[text.length() - 1:]
if (first == "\"" && last == "\"") || (first == "'" && last == "'") {
return text[1:text.length() - 1].to_owned()
}
}
// Inline comments: `KEY=value # note` keeps `value`.
match text.find(" #") {
Some(index) => trim(text[0:index].to_owned())
None => text
}
}
///|
fn parse_env_text(path : String, source : String) -> EnvFile {
let keys : Array[String] = []
let values : Array[String] = []
for raw_line in source.split("\n") {
let mut line = trim(raw_line)
if line == "" || line.has_prefix("#") {
continue
}
if line.has_prefix("export ") {
line = trim(line[7:].to_owned())
}
match line.find("=") {
Some(index) => {
let key = trim(line[0:index].to_owned())
if key != "" {
keys.push(key)
values.push(unquote_env_value(line[index + 1:].to_owned()))
}
}
None => ()
}
}
{ path, keys, values, }
}
///|
/// Read a `.env` file. A missing file yields an empty environment.
pub async fn load_env_file(path : String) -> EnvFile raise MailsError {
let exists = @fs.exists(path) catch { _ => false }
if !exists {
return { path, keys: [], values: [], }
}
let data = @fs.read_file(path) catch {
_ => raise MailsError("cannot read the env file " + path)
}
let source = data.text() catch {
_ => raise MailsError("the env file " + path + " is not valid UTF-8")
}
parse_env_text(path, source)
}
///|
fn env_quote_value(value : String) -> String {
let mut needs_quotes = value == ""
for c in value {
if c == ' ' || c == '\t' || c == '"' || c == '\'' || c == '#' || c == '\\' {
needs_quotes = true
}
}
if !needs_quotes {
return value
}
let out = StringBuilder()
out.write_char('"')
for c in value {
if c == '"' || c == '\\' {
out.write_char('\\')
}
out.write_char(c)
}
out.write_char('"')
out.to_string()
}
///|
/// Set (or add) a variable in a `.env` file, preserving every other line.
///
/// The file is created with mode `0600` and existing permissions are tightened,
/// because the encryption key must never be group- or world-readable.
pub async fn set_env_var(
path : String,
name : String,
value : String,
) -> Unit raise MailsError {
let lines : Array[String] = []
let exists = @fs.exists(path) catch { _ => false }
if exists {
let data = @fs.read_file(path) catch {
_ => raise MailsError("cannot read the env file " + path)
}
let source = data.text() catch {
_ => raise MailsError("the env file " + path + " is not valid UTF-8")
}
if source != "" {
for line in source.split("\n") {
lines.push(line.to_owned())
}
if lines.length() > 0 && lines[lines.length() - 1] == "" {
let _ = lines.pop()
}
}
}
let rendered = name + "=" + env_quote_value(value)
let mut replaced = false
let out : Array[String] = []
for line in lines {
let stripped = trim(line)
let bare = if stripped.has_prefix("export ") {
trim(stripped[7:].to_owned())
} else {
stripped
}
let matches_key = match bare.find("=") {
Some(index) => trim(bare[0:index].to_owned()) == name
None => false
}
if matches_key {
if !replaced {
out.push(rendered)
replaced = true
}
} else {
out.push(line)
}
}
if !replaced {
out.push(rendered)
}
let text = StringBuilder()
for line in out {
text.write_string(line)
text.write_char('\n')
}
@fs.write_file(
path,
text.to_string(),
create_mode=CreateOrTruncate,
permission=0o600,
) catch {
_ => raise MailsError("cannot write the env file " + path)
}
@fs.chmod(path, 0o600) catch {
_ => ()
}
}
///|
/// Whether an environment variable name looks like it holds a secret.
fn is_secret_name(name : String) -> Bool {
let upper = name.to_upper()
upper.contains("KEY") ||
upper.contains("SECRET") ||
upper.contains("TOKEN") ||
upper.contains("PASSWORD")
}
///|
/// Render the contents of an env file for `mails env --show` style output.
pub fn render_env_for_display(env : EnvFile, reveal : Bool) -> String {
let out = StringBuilder()
let mut index = 0
while index < env.keys.length() {
out.write_string(env.keys[index])
out.write_char('=')
if reveal || !is_secret_name(env.keys[index]) {
out.write_string(env.values[index])
} else {
out.write_string("***")
}
out.write_char('\n')
index = index + 1
}
out.to_string()
}