use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
pub const FILENAME: &str = ".env";
pub const DOTENV_VAR: &str = "TW_DOTENV";
pub const KEYS_VAR: &str = "TW_DOTENV_KEYS";
#[derive(Debug, Default)]
pub struct DotEnv {
path: PathBuf,
entries: Vec<(String, String)>,
}
impl DotEnv {
pub fn load(root: &Path) -> Result<Option<DotEnv>, String> {
let path = root.join(FILENAME);
match std::fs::metadata(&path) {
Ok(m) if m.is_dir() => {
return Err(format!(
"{} is a directory. tw reads a writer's tokens from a file of that \
name at the corpus root; rename the directory (a Python virtual \
environment, say, to .venv).",
path.display()
));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if std::fs::symlink_metadata(&path).is_ok() {
return Err(format!(
"{} is a link to a file that is not there. Create that file or \
remove the link.",
path.display()
));
}
return Ok(None);
}
Err(e) => return Err(format!("{}: {e}", path.display())),
}
ensure_ignored(&path)?;
ensure_one_name(&path)?;
let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let text = decode(&bytes, &path)?;
let mut env = DotEnv::parse(&text);
env.path = path;
Ok(Some(env))
}
pub fn parse(text: &str) -> DotEnv {
DotEnv {
path: PathBuf::from(FILENAME),
entries: text.lines().filter_map(parse_line).collect(),
}
}
pub fn file_value(&self, key: &str) -> Option<&str> {
self.entries
.iter()
.rev()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
pub fn for_children(&self) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
for (key, _) in &self.entries {
if out.iter().any(|(k, _)| k == key) || self.in_environment(key) {
continue;
}
if let Some(value) = self.file_value(key) {
out.push((key.clone(), value.to_string()));
}
}
out
}
pub fn in_environment(&self, key: &str) -> bool {
in_environment(key) && !from_this_file(&self.path, key)
}
}
pub fn decode(bytes: &[u8], path: &Path) -> Result<String, String> {
let utf16 = bytes.starts_with(b"\xFF\xFE") || bytes.starts_with(b"\xFE\xFF");
let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
let what = if utf16 {
"saved as UTF-16".to_string()
} else if bytes.contains(&0) {
"not plain text (it holds NUL bytes)".to_string()
} else {
match std::str::from_utf8(text) {
Ok(text) => return Ok(text.to_string()),
Err(e) => format!(
"not UTF-8 text (byte {} is not UTF-8; Windows PowerShell 5.1's \
Set-Content writes the ANSI code page)",
bytes.len() - text.len() + e.valid_up_to() + 1
),
}
};
Err(format!(
"{} is {what}, and tw reads UTF-8 only. Open it in an editor and save it as \
UTF-8 (in VS Code: \"Save with Encoding\", UTF-8; in PowerShell 7: \
Set-Content -Encoding utf8).",
path.display(),
))
}
pub fn in_environment(key: &str) -> bool {
std::env::var_os(key).is_some_and(|v| !v.is_empty())
}
fn from_this_file(path: &Path, key: &str) -> bool {
let (Some(file), Ok(keys)) = (std::env::var_os(DOTENV_VAR), std::env::var(KEYS_VAR)) else {
return false;
};
let same = Path::new(&file) == path
|| matches!(
(Path::new(&file).canonicalize(), path.canonicalize()),
(Ok(a), Ok(b)) if a == b
);
same && keys.split(',').any(|k| k == key)
}
static LOADED: OnceLock<Option<DotEnv>> = OnceLock::new();
pub fn init(start: &Path) -> Result<(), String> {
if LOADED.get().is_some() {
return Ok(());
}
let loaded = for_run(start)?;
let _ = LOADED.set(loaded);
Ok(())
}
pub fn for_run(start: &Path) -> Result<Option<DotEnv>, String> {
let root = crate::tools::corpus(start);
if !root.join(crate::tools::TOOLING_DIR).is_dir() {
return Ok(None);
}
DotEnv::load(&root)
}
pub fn var(key: &str) -> Option<String> {
let loaded = LOADED.get().and_then(Option::as_ref);
let real = match loaded {
Some(env) => env.in_environment(key),
None => in_environment(key),
};
if real {
return std::env::var(key).ok();
}
loaded
.and_then(|env| env.file_value(key))
.map(str::to_string)
.or_else(|| std::env::var(key).ok().filter(|v| !v.is_empty()))
}
pub fn apply(command: &mut Command) {
hand_down(command, LOADED.get().and_then(Option::as_ref));
}
fn hand_down(command: &mut Command, loaded: Option<&DotEnv>) {
match loaded {
Some(env) => {
let children = env.for_children();
let keys: Vec<&str> = children.iter().map(|(k, _)| k.as_str()).collect();
let keys = keys.join(",");
command.envs(children);
command.env(DOTENV_VAR, env.handed_path());
command.env(KEYS_VAR, keys);
}
None => {
command.env_remove(DOTENV_VAR);
command.env_remove(KEYS_VAR);
}
}
}
impl DotEnv {
pub fn handed_path(&self) -> PathBuf {
std::path::absolute(&self.path).unwrap_or_else(|_| self.path.clone())
}
}
pub fn parse_line(line: &str) -> Option<(String, String)> {
let t = line.trim_start();
if t.is_empty() || t.starts_with('#') {
return None;
}
let t = t.strip_prefix("export ").map(str::trim_start).unwrap_or(t);
let (key, value) = t.split_once('=')?;
let key = key.trim();
let mut chars = key.chars();
let first_ok = chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
if !first_ok || !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') {
return None;
}
let v = value.trim_start();
let value = if let Some(rest) = v.strip_prefix('"') {
let mut s = String::new();
let mut it = rest.chars();
while let Some(c) = it.next() {
match c {
'"' => break,
'\\' => match it.next() {
Some('n') => s.push('\n'),
Some('r') => s.push('\r'),
Some('t') => s.push('\t'),
Some(other) => s.push(other),
None => break,
},
c => s.push(c),
}
}
s
} else if let Some(rest) = v.strip_prefix('\'') {
rest.split('\'').next().unwrap_or_default().to_string()
} else {
let cut = v
.char_indices()
.find(|&(i, c)| c == '#' && i > 0 && v[..i].ends_with([' ', '\t']))
.map_or(v.len(), |(i, _)| i);
v[..cut].trim_end().to_string()
};
Some((key.to_string(), value))
}
pub fn ensure_ignored(file: &Path) -> Result<(), String> {
let dir = holder(file);
let name = file.file_name().map_or_else(
|| FILENAME.to_string(),
|n| n.to_string_lossy().into_owned(),
);
ensure_name_ignored(&dir, &name, file)?;
let linked = std::fs::symlink_metadata(file).is_ok_and(|m| m.file_type().is_symlink());
if !linked {
return Ok(());
}
let target = std::fs::canonicalize(file).map_err(|e| {
format!(
"{} is a link to a file that cannot be opened ({e}). Create that file or \
remove the link.",
file.display()
)
})?;
let top = work_tree(&dir, file)?;
match target.strip_prefix(&top) {
Ok(inside) => {
let name = inside.to_string_lossy().replace('\\', "/");
ensure_name_ignored(&top, &name, &target)
.map_err(|e| format!("{} is a link to {}: {e}", file.display(), target.display()))
}
Err(_) => Ok(()),
}
}
pub fn ensure_one_name(file: &Path) -> Result<(), String> {
let names = names_on_disk(file).map_err(|e| format!("{}: {e}", file.display()))?;
if names <= 1 {
return Ok(());
}
let linked = std::fs::symlink_metadata(file).is_ok_and(|m| m.file_type().is_symlink());
if !linked {
return Err(hard_linked(file, names));
}
let target = std::fs::canonicalize(file).map_err(|e| format!("{}: {e}", file.display()))?;
Err(format!(
"{} is a link to {}: {}",
file.display(),
target.display(),
hard_linked(&target, names)
))
}
fn hard_linked(file: &Path, names: u64) -> String {
format!(
"{} has {names} names on disk (it is hard-linked), and git could commit another \
of them with the tokens in it, so it is not read. Copy it to a new file and \
replace it with the copy, which has one name.",
file.display()
)
}
#[cfg(unix)]
fn names_on_disk(path: &Path) -> std::io::Result<u64> {
use std::os::unix::fs::MetadataExt;
Ok(std::fs::metadata(path)?.nlink())
}
#[cfg(windows)]
fn names_on_disk(path: &Path) -> std::io::Result<u64> {
let file = std::fs::File::open(path)?;
Ok(winapi_util::file::information(&file)?.number_of_links())
}
#[cfg(not(any(unix, windows)))]
fn names_on_disk(_: &Path) -> std::io::Result<u64> {
Ok(1)
}
fn holder(file: &Path) -> PathBuf {
match file.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(),
_ => PathBuf::from("."),
}
}
fn work_tree(dir: &Path, file: &Path) -> Result<PathBuf, String> {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(|e| {
format!(
"could not run git to find the work tree of {} ({e})",
file.display()
)
})?;
if !out.status.success() {
return Err(format!(
"could not ask git which work tree holds {}: {}. tw reads tokens from \
that file only inside a git work tree that ignores it.",
file.display(),
String::from_utf8_lossy(&out.stderr).trim()
));
}
let top = path_of(&out.stdout);
top.canonicalize()
.map_err(|e| format!("{}: {e}", top.display()))
}
fn path_of(stdout: &[u8]) -> PathBuf {
let line = stdout.strip_suffix(b"\n").unwrap_or(stdout);
let line = line.strip_suffix(b"\r").unwrap_or(line);
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
PathBuf::from(std::ffi::OsStr::from_bytes(line))
}
#[cfg(not(unix))]
{
PathBuf::from(String::from_utf8_lossy(line).into_owned())
}
}
fn ensure_name_ignored(root: &Path, name: &str, file: &Path) -> Result<(), String> {
let shown = file.display();
let check = Command::new("git")
.arg("-C")
.arg(root)
.args(["check-ignore", "-q", "--", name])
.output()
.map_err(|e| {
format!(
"could not run git to check that {shown} is ignored ({e}). tw reads \
tokens from that file only when git ignores it; until then, set \
them in the environment instead."
)
})?;
match check.status.code() {
Some(0) => Ok(()),
Some(1) => {
let tracked = Command::new("git")
.arg("-C")
.arg(root)
.args(["ls-files", "--error-unmatch", "--", name])
.output()
.is_ok_and(|o| o.status.success());
if tracked {
Err(format!(
"git tracks {shown}, so the tokens in it are committed or about to \
be. tw will not read it. Run `git rm --cached {name}`, make sure \
.gitignore has a line `{name}`, and if that file ever held a \
secret in a commit, treat the secret as leaked."
))
} else {
Err(format!(
"git does not ignore {shown}, so the tokens in it could be \
committed. tw will not read it. Add a line `{name}` to {}, \
then run this again.",
root.join(".gitignore").display()
))
}
}
_ => Err(format!(
"could not ask git whether {shown} is ignored: {}. tw reads tokens from \
that file only inside a git work tree that ignores it; outside one, set \
them in the environment instead.",
String::from_utf8_lossy(&check.stderr).trim()
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn the_shared_syntax_is_read() {
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let p = |l: &str| parse_line(l).map(|(k, v)| format!("{k}={v}"));
assert_eq!(p("A=1"), Some("A=1".into()));
assert_eq!(p(" export B = two "), Some("B=two".into()));
assert_eq!(
p("C=\"quoted # not a comment\" # a comment"),
Some("C=quoted # not a comment".into())
);
assert_eq!(p("D='single $HOME'"), Some("D=single $HOME".into()));
assert_eq!(p("E=bare # comment"), Some("E=bare".into()));
assert_eq!(p("F=a#b"), Some("F=a#b".into()));
assert_eq!(
p("G=\"line\\nbreak \\\"q\\\"\""),
Some("G=line\nbreak \"q\"".into())
);
assert_eq!(p("H="), Some("H=".into()));
assert_eq!(p("# X=1"), None);
assert_eq!(p(""), None);
assert_eq!(p("not an assignment"), None);
assert_eq!(p("1BAD=x"), None);
let env = DotEnv::parse("K=v\r\nQ=\"x\"\r\n");
assert_eq!(env.file_value("K"), Some("v"));
assert_eq!(env.file_value("Q"), Some("x"));
}
#[test]
fn the_last_assignment_wins_and_children_get_each_key_once() {
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let env =
DotEnv::parse("TW_DOTENV_TEST_K=first\nTW_DOTENV_TEST_K=second\nTW_DOTENV_TEST_J=j\n");
assert_eq!(env.file_value("TW_DOTENV_TEST_K"), Some("second"));
assert_eq!(
env.for_children(),
[
("TW_DOTENV_TEST_K".to_string(), "second".to_string()),
("TW_DOTENV_TEST_J".to_string(), "j".to_string())
]
);
}
#[test]
fn the_real_environment_wins() {
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let env = DotEnv::parse("PATH=/from/dotenv\nTW_DOTENV_UNSET_X=1\n");
let children = env.for_children();
assert!(!children.iter().any(|(k, _)| k == "PATH"), "{children:?}");
assert!(children.iter().any(|(k, _)| k == "TW_DOTENV_UNSET_X"));
}
fn git(dir: &Path, args: &[&str]) {
let ok = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", dir.join(".git").join("no-config"))
.output()
.expect("git runs")
.status
.success();
assert!(ok, "git {args:?}");
}
fn repo(dir: &Path) {
git(dir, &["init", "-q"]);
let git_dir = dir.join(".git");
std::fs::create_dir_all(git_dir.join("info")).unwrap();
std::fs::write(git_dir.join("info").join("exclude"), "").unwrap();
std::fs::write(git_dir.join("no-config"), "").unwrap();
let none = git_dir.join("no-excludes");
std::fs::write(&none, "").unwrap();
git(
dir,
&["config", "core.excludesFile", &none.to_string_lossy()],
);
}
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("tw-dotenv-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_file_git_does_not_ignore_is_refused_and_its_values_never_shown() {
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let d = scratch("unignored");
repo(&d);
std::fs::write(d.join(".env"), "FORGEJO_TOKEN=s3cret-value\n").unwrap();
let err = DotEnv::load(&d).unwrap_err();
assert!(err.contains("Add a line `.env`"), "{err}");
assert!(!err.contains("s3cret"), "{err}");
std::fs::write(d.join(".gitignore"), "node_modules/\n.env\n").unwrap();
let env = DotEnv::load(&d).unwrap().unwrap();
assert_eq!(env.file_value("FORGEJO_TOKEN"), Some("s3cret-value"));
git(&d, &["add", "-f", ".env"]);
let err = DotEnv::load(&d).unwrap_err();
assert!(err.contains("git rm --cached .env"), "{err}");
assert!(!err.contains("s3cret"), "{err}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn no_file_is_no_env_and_needs_no_git() {
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let d = scratch("none");
assert!(DotEnv::load(&d).unwrap().is_none());
std::fs::write(d.join(".env"), "A=1\n").unwrap();
let err = DotEnv::load(&d).unwrap_err();
assert!(err.contains("could not ask git"), "{err}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_byte_order_mark_is_dropped_and_utf16_or_nul_is_refused() {
let p = Path::new("/c/.env");
let text = decode(b"\xEF\xBB\xBFFORGEJO_TOKEN=t\r\n", p).unwrap();
assert_eq!(DotEnv::parse(&text).file_value("FORGEJO_TOKEN"), Some("t"));
let mut utf16 = vec![0xFF, 0xFE];
for u in "A=1\r\n".encode_utf16() {
utf16.extend_from_slice(&u.to_le_bytes());
}
let err = decode(&utf16, p).unwrap_err();
assert!(
err.contains("UTF-16") && err.contains("save it as UTF-8"),
"{err}"
);
let err = decode(b"A=SEK\0RET\n", p).unwrap_err();
assert!(
err.contains("NUL") && err.contains("save it as UTF-8"),
"{err}"
);
assert!(!err.contains("SEK"), "{err}");
}
#[test]
fn a_file_that_is_not_utf8_is_refused_whole_not_patched() {
let p = Path::new("/c/.env");
let err = decode(b"A=1\nT=s3cret-\xE9tok\n", p).unwrap_err();
assert!(
err.contains("not UTF-8 text")
&& err.contains("byte 14")
&& err.contains("save it as UTF-8"),
"{err}"
);
assert!(
!err.contains("s3cret") && !err.contains('\u{fffd}'),
"{err}"
);
let err = decode(b"\xEF\xBB\xBFT=\xE9\n", p).unwrap_err();
assert!(err.contains("byte 6"), "{err}");
let text = decode("T=s3cret-\u{e9}tok\n".as_bytes(), p).unwrap();
assert_eq!(
DotEnv::parse(&text).file_value("T"),
Some("s3cret-\u{e9}tok")
);
}
#[test]
fn a_no_break_space_around_a_bare_value_is_trimmed() {
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let env = DotEnv::parse("T=abc\u{a0}\nU=\u{a0}def \nQ=\"x\u{a0}\"\n");
assert_eq!(env.file_value("T"), Some("abc"));
assert_eq!(env.file_value("U"), Some("def"));
assert_eq!(env.file_value("Q"), Some("x\u{a0}"));
}
#[test]
fn a_directory_named_env_is_refused() {
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let d = scratch("dir");
repo(&d);
std::fs::create_dir(d.join(".env")).unwrap();
let err = DotEnv::load(&d).unwrap_err();
assert!(err.contains("is a directory"), "{err}");
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn a_linked_env_is_judged_by_its_target() {
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let d = scratch("link");
repo(&d);
std::fs::write(d.join(".gitignore"), ".env\n").unwrap();
std::fs::write(d.join("tokens.txt"), "FORGEJO_TOKEN=s3cret\n").unwrap();
std::os::unix::fs::symlink("tokens.txt", d.join(".env")).unwrap();
let err = DotEnv::load(&d).unwrap_err();
assert!(
err.contains("tokens.txt") && err.contains("Add a line `tokens.txt`"),
"{err}"
);
assert!(!err.contains("s3cret"), "{err}");
git(&d, &["add", "tokens.txt"]);
let err = DotEnv::load(&d).unwrap_err();
assert!(err.contains("git rm --cached tokens.txt"), "{err}");
git(&d, &["rm", "-q", "--cached", "tokens.txt"]);
std::fs::write(d.join(".gitignore"), ".env\ntokens.txt\n").unwrap();
let env = DotEnv::load(&d).unwrap().unwrap();
assert_eq!(env.file_value("FORGEJO_TOKEN"), Some("s3cret"));
let outside = scratch("link-outside");
std::fs::write(outside.join("shared.env"), "A=1\n").unwrap();
std::fs::remove_file(d.join(".env")).unwrap();
std::os::unix::fs::symlink(outside.join("shared.env"), d.join(".env")).unwrap();
let env = DotEnv::load(&d).unwrap().unwrap();
assert_eq!(env.file_value("A"), Some("1"));
assert_eq!(env.handed_path(), d.join(".env"));
std::fs::remove_file(d.join(".env")).unwrap();
std::os::unix::fs::symlink(d.join("missing"), d.join(".env")).unwrap();
assert!(DotEnv::load(&d).unwrap_err().contains("not there"));
let _ = std::fs::remove_dir_all(&d);
let _ = std::fs::remove_dir_all(&outside);
}
#[test]
fn a_hard_linked_env_is_refused() {
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let d = scratch("hardlink");
repo(&d);
std::fs::write(d.join(".gitignore"), ".env\nother.env\n").unwrap();
std::fs::write(d.join("other.env"), "FORGEJO_TOKEN=s3cret\n").unwrap();
std::fs::hard_link(d.join("other.env"), d.join(".env")).unwrap();
let err = DotEnv::load(&d).unwrap_err();
assert!(
err.contains("has 2 names on disk")
&& err.contains("git could commit another of them with the tokens in it")
&& err.contains("replace it with the copy"),
"{err}"
);
assert!(!err.contains("s3cret"), "{err}");
let outside = scratch("hardlink-outside");
std::fs::hard_link(d.join(".env"), outside.join("again")).unwrap();
let err = DotEnv::load(&d).unwrap_err();
assert!(err.contains("has 3 names on disk"), "{err}");
#[cfg(unix)]
{
std::fs::remove_file(d.join(".env")).unwrap();
std::os::unix::fs::symlink(outside.join("again"), d.join(".env")).unwrap();
let err = DotEnv::load(&d).unwrap_err();
assert!(
err.contains("is a link to") && err.contains("has 2 names on disk"),
"{err}"
);
assert!(!err.contains("s3cret"), "{err}");
}
std::fs::remove_file(d.join(".env")).unwrap();
std::fs::copy(d.join("other.env"), d.join(".env")).unwrap();
let env = DotEnv::load(&d).unwrap().unwrap();
assert_eq!(env.file_value("FORGEJO_TOKEN"), Some("s3cret"));
let _ = std::fs::remove_dir_all(&d);
let _ = std::fs::remove_dir_all(&outside);
}
#[cfg(unix)]
#[test]
fn a_link_to_the_rest_of_the_work_tree_is_judged_there() {
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let d = scratch("subcorpus");
repo(&d);
let corpus = d.join("docs");
std::fs::create_dir_all(corpus.join(".tooling")).unwrap();
std::fs::write(corpus.join(".gitignore"), ".env\n").unwrap();
std::fs::write(d.join("shared.env"), "FORGEJO_TOKEN=s3cret\n").unwrap();
std::os::unix::fs::symlink("../shared.env", corpus.join(".env")).unwrap();
let err = DotEnv::load(&corpus).unwrap_err();
assert!(
err.contains("shared.env") && err.contains("Add a line `shared.env`"),
"{err}"
);
assert!(!err.contains("s3cret"), "{err}");
git(&d, &["add", "-f", "shared.env"]);
let err = DotEnv::load(&corpus).unwrap_err();
assert!(err.contains("git rm --cached shared.env"), "{err}");
assert!(!err.contains("s3cret"), "{err}");
git(&d, &["rm", "-q", "--cached", "shared.env"]);
std::fs::write(d.join(".gitignore"), "shared.env\n").unwrap();
let env = DotEnv::load(&corpus).unwrap().unwrap();
assert_eq!(env.file_value("FORGEJO_TOKEN"), Some("s3cret"));
let _ = std::fs::remove_dir_all(&d);
}
fn corpus(name: &str) -> PathBuf {
let d = scratch(name);
repo(&d);
std::fs::create_dir_all(d.join(".tooling")).unwrap();
std::fs::write(d.join(".gitignore"), ".env\n").unwrap();
d
}
fn inheriting<T>(inherited: &Path, body: impl FnOnce() -> T) -> T {
let saved = [DOTENV_VAR, KEYS_VAR].map(std::env::var_os);
unsafe {
std::env::set_var(DOTENV_VAR, inherited);
std::env::set_var(KEYS_VAR, "TW_DOTENV_TEST_TOKEN");
}
let out = body();
for (name, value) in [DOTENV_VAR, KEYS_VAR].into_iter().zip(saved) {
unsafe {
match value {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
}
}
out
}
type Handed = (Option<Option<String>>, Option<Option<String>>);
fn handed(command: &Command) -> Handed {
let get = |name: &str| {
command
.get_envs()
.find(|(k, _)| *k == std::ffi::OsStr::new(name))
.map(|(_, v)| v.map(|v| v.to_string_lossy().into_owned()))
};
(get(DOTENV_VAR), get(KEYS_VAR))
}
fn run_inheriting(
inherited: &Path,
load: impl FnOnce() -> Option<DotEnv>,
) -> (Option<PathBuf>, Handed, String) {
inheriting(inherited, || {
let loaded = load();
let mut command = Command::new("sh");
command.args([
"-c",
r#"printf '%s|%s' "${TW_DOTENV-unset}" "${TW_DOTENV_KEYS-unset}""#,
]);
hand_down(&mut command, loaded.as_ref());
let seen = if cfg!(unix) {
let out = command.output().expect("sh runs");
String::from_utf8_lossy(&out.stdout).into_owned()
} else {
String::new()
};
(loaded.map(|e| e.handed_path()), handed(&command), seen)
})
}
#[test]
fn an_inherited_tw_dotenv_is_removed_in_a_corpus_with_no_env() {
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let a = corpus("inherit-a");
let b = corpus("inherit-b");
std::fs::write(b.join(".env"), "TW_DOTENV_TEST_TOKEN=b-s3cret\n").unwrap();
let (read, handed, seen) = run_inheriting(&b.join(".env"), || for_run(&a).unwrap());
assert_eq!(read, None, "tw takes no .env from TW_DOTENV");
assert_eq!(handed, (Some(None), Some(None)), "both removed");
if cfg!(unix) {
assert_eq!(seen, "unset|unset");
}
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
}
#[test]
fn an_inherited_tw_dotenv_is_removed_outside_a_corpus() {
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let outside = scratch("inherit-outside");
repo(&outside);
std::fs::write(outside.join(".gitignore"), ".env\n").unwrap();
std::fs::write(outside.join(".env"), "TW_DOTENV_TEST_TOKEN=o-s3cret\n").unwrap();
let b = corpus("inherit-outside-b");
std::fs::write(b.join(".env"), "TW_DOTENV_TEST_TOKEN=b-s3cret\n").unwrap();
let (read, handed, seen) = run_inheriting(&b.join(".env"), || for_run(&outside).unwrap());
assert_eq!(read, None, "no .env outside a corpus");
assert_eq!(handed, (Some(None), Some(None)), "both removed");
if cfg!(unix) {
assert_eq!(seen, "unset|unset");
}
let _ = std::fs::remove_dir_all(&outside);
let _ = std::fs::remove_dir_all(&b);
}
#[test]
fn with_its_own_env_tw_names_that_one_and_not_the_inherited_one() {
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let a = corpus("own-a");
std::fs::write(a.join(".env"), "TW_DOTENV_TEST_OWN=a-s3cret\n").unwrap();
let b = corpus("own-b");
std::fs::write(b.join(".env"), "TW_DOTENV_TEST_TOKEN=b-s3cret\n").unwrap();
let (read, handed, seen) = run_inheriting(&b.join(".env"), || DotEnv::load(&a).unwrap());
let own = a.join(".env").display().to_string();
assert_eq!(
read.map(|p| p.display().to_string()).as_deref(),
Some(own.as_str())
);
assert_eq!(
handed,
(
Some(Some(own.clone())),
Some(Some("TW_DOTENV_TEST_OWN".to_string()))
)
);
if cfg!(unix) {
assert_eq!(seen, format!("{own}|TW_DOTENV_TEST_OWN"));
}
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
}
}