/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
libs/dotenv/lib.rs
1 209 строк
35 KB
Bartek Iwańczuk
fix(dotenv): include underscore in unbraced variable names (#34797)
05 июн 2026, 19:50
Не верифицирован
05 июн 2026, 19:50
9f63ce5
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. use std::borrow::Cow; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; use sys_traits::BaseEnvVar; use sys_traits::FsRead; use thiserror::Error; const CHAR_NL: u8 = b'\n'; const CHAR_CR: u8 = b'\r'; const CHAR_TAB: u8 = b'\t'; const CHAR_SPACE: u8 = b' '; const CHAR_HASH: u8 = b'#'; const CHAR_EQ: u8 = b'='; const CHAR_DQUOTE: u8 = b'"'; const CHAR_SQUOTE: u8 = b'\''; const CHAR_BQUOTE: u8 = b'`'; const CHAR_BSLASH: u8 = b'\\'; const CHAR_N: u8 = b'n'; const CHAR_DOLLAR: u8 = b'$'; const CHAR_LBRACE: u8 = b'{'; #[derive(Debug, Error)] #[error("Failed reading '{}'", path.display())] pub struct FindPathAndContentError { pub path: PathBuf, pub source: std::io::Error, } /// Returns an iterator of candidate paths for the given env file specifier. /// /// If the specifier is absolute, yields just that path. Otherwise, walks /// ancestor directories starting from `cwd`, joining the specifier to each. pub fn candidate_paths<'a>( cwd: &'a Path, specifier: &'a str, ) -> Box<dyn Iterator<Item = Cow<'a, Path>> + 'a> { let specifier_path = Path::new(specifier); if specifier_path.is_absolute() { Box::new(std::iter::once(Cow::Borrowed(specifier_path))) } else { Box::new( cwd .ancestors() .map(move |dir| Cow::Owned(dir.join(specifier))), ) } } /// Discovers the path and content of an env file, which can then be parsed. /// /// This walks the ancestor directories attempting to find the env file. pub fn find_path_and_content( sys: &impl FsRead, cwd: &Path, specifier: &str, ) -> Result<Option<(PathBuf, Cow<'static, str>)>, FindPathAndContentError> { fn try_read( sys: &impl FsRead, file_path: Cow<'_, Path>, ) -> Result<Option<(PathBuf, Cow<'static, str>)>, FindPathAndContentError> { match sys.fs_read_to_string(&file_path) { Ok(content) => Ok(Some(( deno_path_util::normalize_path(file_path).into_owned(), content, ))), Err(err) => { if err.kind() != std::io::ErrorKind::NotFound { Err(FindPathAndContentError { path: deno_path_util::normalize_path(file_path).into_owned(), source: err, }) } else { Ok(None) } } } } for file_path in candidate_paths(cwd, specifier) { match try_read(sys, file_path) { Ok(Some(content)) => return Ok(Some(content)), Ok(None) => {} Err(err) => return Err(err), } } Ok(None) } #[derive(Debug, Error)] #[error("Error parsing line at index {index}: {line}")] pub struct ParseError { pub line: String, pub index: usize, } type IterElement = Result<(String, String), ParseError>; pub fn from_content_sanitized_iter_with_substitution( sys: &dyn BaseEnvVar, content: &str, ) -> Result<std::vec::IntoIter<IterElement>, ParseError> { let mut pairs = Vec::new(); parse_env_content_with_substitution_hook(sys, content, &mut |k, v| { if let Some(index) = k .find('\0') .or_else(|| v.find('\0').map(|i| k.len() + i + 1)) { pairs.push(Err(ParseError { line: format!("{}={}", k, v), index, })); } else { pairs.push(Ok((k.to_string(), v.to_string()))); } }); Ok(pairs.into_iter()) } /// Ported from: /// https://github.com/nodejs/node/blob/9cc7fcc26dece769d9ffa06c453f0171311b01f8/src/node_dotenv.cc#L138-L315 pub fn parse_env_content_hook(content: &str, cb: &mut dyn FnMut(&str, &str)) { parse_env_content_hook_impl(content, None, cb); } pub fn parse_env_content_with_substitution_hook( sys: &dyn BaseEnvVar, content: &str, mut cb: &mut dyn FnMut(&str, &str), ) { let mut substitution_map = HashMap::new(); parse_env_content_hook_impl( content, Some(SubstitutionMap { sys, map: &mut substitution_map, }), &mut cb, ); } struct SubstitutionMap<'a, 'b> { sys: &'a dyn BaseEnvVar, map: &'b mut HashMap<String, String>, } fn parse_env_content_hook_impl( content: &str, mut substitution_map: Option<SubstitutionMap<'_, '_>>, cb: &mut dyn FnMut(&str, &str), ) { let raw = content.as_bytes(); let mut filtered = Vec::new(); let mut saw_cr = false; let mut text = { // Handle windows newlines "\r\n": remove "\r" and keep only "\n" let mut i = 0; while i < raw.len() { if raw[i] == CHAR_CR { saw_cr = true; filtered = Vec::with_capacity(raw.len() - 1); filtered.extend_from_slice(&raw[..i]); i += 1; while i < raw.len() { let c = raw[i]; if c != CHAR_CR { filtered.push(c); } i += 1; } break; } i += 1; } if saw_cr { trim_spaces_slice(&filtered) } else { trim_spaces_slice(raw) } }; let mut emit_value = |key: &str, value: &str, can_substitute: bool| { if let Some(data) = &mut substitution_map { let sys = data.sys; let substitution_map = &mut data.map; let emitted_value = if can_substitute { apply_value_substitution(sys, value, substitution_map) } else { value.to_string() }; substitution_map.insert(key.to_string(), emitted_value.clone()); cb(key, &emitted_value); } else { cb(key, value); } }; while !text.is_empty() { let first = text[0]; // Skip empty lines and comments // Check if the first character of the content is a newline or a hash if first == CHAR_NL || first == CHAR_HASH { // Remove everything up to and including the newline character if let Some(newline) = find_char(text, CHAR_NL, 0) { text = &text[newline + 1..]; } else { // If no newline is found, clear the content text = &[]; } // Skip the remaining code in the loop and continue with the next // iteration. continue; } // Find the next equals sign or newline in a single pass. // This optimizes the search by avoiding multiple iterations. let equal_or_newline = { let mut index = None; let mut i = 0; while i < text.len() { let c = text[i]; if c == CHAR_EQ || c == CHAR_NL { index = Some(i); break; } i += 1; } match index { Some(index) => index, None => break, } }; // If we found nothing or found a newline before equals, the line is invalid if text[equal_or_newline] == CHAR_NL { text = trim_spaces_slice(&text[equal_or_newline + 1..]); continue; } // We found an equals sign, extract the key let mut key = trim_spaces_slice(&text[..equal_or_newline]); text = &text[equal_or_newline + 1..]; // If the value is not present (e.g. KEY=) set it to an empty string if text.is_empty() || text[0] == CHAR_NL { let key_str = std::str::from_utf8(key).unwrap(); emit_value(key_str, "", false); continue; } text = trim_spaces_slice(text); // Skip lines with empty keys after trimming spaces. // Examples of invalid keys that would be skipped: // =value // " "=value if key.is_empty() { continue; } // Remove export prefix from key and ensure proper spacing. // Example: export FOO=bar -> FOO=bar if key.len() >= 7 && key[0] == b'e' && key[1] == b'x' && key[2] == b'p' && key[3] == b'o' && key[4] == b'r' && key[5] == b't' && key[6] == CHAR_SPACE { // Trim spaces after removing export prefix to handle cases like: // export FOO=bar key = trim_spaces_slice(&key[7..]); } let key_str = std::str::from_utf8(key).unwrap(); // SAFETY: Content is guaranteed to have at least one character // In case the last line is a single key without value // Example: KEY= (without a newline at the EOF) if text.is_empty() { emit_value(key_str, "", false); break; } // Expand new line if \n it's inside double quotes // Example: EXPAND_NEWLINES = 'expand\nnew\nlines' if text[0] == CHAR_DQUOTE && let Some(closing) = find_closing_quote(text, CHAR_DQUOTE, 1) { let slice = &text[1..closing]; let mut needs_unescape = false; let mut i = 0; while i + 1 < slice.len() { if slice[i] == CHAR_BSLASH && slice[i + 1] == CHAR_N { needs_unescape = true; break; } i += 1; } let value_str = if !needs_unescape { Cow::Borrowed(std::str::from_utf8(slice).unwrap()) } else { let mut out = Vec::with_capacity(slice.len()); let mut i = 0; // Replace \n with actual newlines in double-quoted strings while i < slice.len() { let c = slice[i]; if c == CHAR_BSLASH && i + 1 < slice.len() && slice[i + 1] == CHAR_N { out.push(CHAR_NL); i += 2; continue; } out.push(c); i += 1; } Cow::Owned(String::from_utf8(out).unwrap()) }; emit_value(key_str, &value_str, true); if let Some(newline) = find_char(text, CHAR_NL, closing + 1) { text = &text[newline + 1..]; } else { // In case the last line is a single key/value pair // Example: KEY=VALUE (without a newline at the EOF) text = &[]; } // No valid data here, skip to next line continue; } // Handle quoted values (single quotes, double quotes, backticks) let quote = text[0]; if quote == CHAR_SQUOTE || quote == CHAR_DQUOTE || quote == CHAR_BQUOTE { if let Some(closing) = find_closing_quote(text, quote, 1) { // Found closing quote - take content between quotes let value = &text[1..closing]; emit_value( key_str, std::str::from_utf8(value).unwrap(), quote == CHAR_DQUOTE, ); if let Some(newline) = find_char(text, CHAR_NL, closing + 1) { text = &text[newline + 1..]; } else { text = &[]; } // No valid data here, skip to next line continue; } else { // Check if the closing quote is not found // Example: KEY="value // Check if newline exists. If it does, take the entire line as the value // Example: KEY="value\nKEY2=value2 // The value pair should be `"value` if let Some(newline) = find_char(text, CHAR_NL, 0) { let value = &text[..newline]; emit_value(key_str, std::str::from_utf8(value).unwrap(), false); text = &text[newline + 1..]; } else { // No newline - take rest of content emit_value(key_str, std::str::from_utf8(text).unwrap(), false); break; } } } else { // Regular key value pair. // Example: `KEY=this is value` if let Some(newline) = find_char(text, CHAR_NL, 0) { let mut value = &text[..newline]; // Check if there is a comment in the line // Example: KEY=value # comment // The value pair should be `value` if let Some(hash) = find_char(value, CHAR_HASH, 0) { value = &value[..hash]; } let value = trim_spaces_slice(value); emit_value(key_str, std::str::from_utf8(value).unwrap(), true); text = &text[newline + 1..]; } else { // Last line without newline let mut value = text; if let Some(hash) = find_char(value, CHAR_HASH, 0) { value = &value[..hash]; } let value = trim_spaces_slice(value); emit_value(key_str, std::str::from_utf8(value).unwrap(), true); text = &[]; } } text = trim_spaces_slice(text); } } fn lookup_substitution( sys: &dyn BaseEnvVar, substitution_name: &str, substitution_map: &HashMap<String, String>, output: &mut String, ) { if let Some(environment_value) = sys .base_env_var_os(substitution_name.as_ref()) .and_then(|n| n.into_string().ok()) { output.push_str(&environment_value); } else if let Some(stored_value) = substitution_map.get(substitution_name) { output.push_str(stored_value); } } fn apply_value_substitution( sys: &dyn BaseEnvVar, value: &str, substitution_map: &HashMap<String, String>, ) -> String { if !value.as_bytes().contains(&CHAR_DOLLAR) { return value.to_string(); } let mut output = String::with_capacity(value.len()); let mut i = 0; while i < value.len() { let remaining = &value[i..]; if remaining.as_bytes().starts_with(b"\\$") { output.push('$'); i += 2; continue; } if remaining.as_bytes().starts_with(b"$") { if remaining.as_bytes().get(1) == Some(&CHAR_LBRACE) { let mut end = value.len(); for (offset, ch) in value[i + 2..].char_indices() { if ch == '}' { end = i + 2 + offset; break; } } let substitution_name = &value[i + 2..end]; lookup_substitution( sys, substitution_name, substitution_map, &mut output, ); i = if end < value.len() { end + 1 } else { end }; continue; } let mut end = i + 1; for (offset, ch) in value[i + 1..].char_indices() { if ch.is_ascii_alphanumeric() || ch == '_' { end = i + 1 + offset + ch.len_utf8(); } else { break; } } let substitution_name = &value[i + 1..end]; lookup_substitution( sys, substitution_name, substitution_map, &mut output, ); i = end; continue; } let ch = remaining.chars().next().unwrap(); output.push(ch); i += ch.len_utf8(); } output } fn trim_spaces_slice(input: &[u8]) -> &[u8] { if input.is_empty() { return input; } let mut start = 0; let mut end = input.len(); while start < end { let c = input[start]; if c != CHAR_SPACE && c != CHAR_TAB && c != CHAR_NL { break; } start += 1; } while end > start { let c = input[end - 1]; if c != CHAR_SPACE && c != CHAR_TAB && c != CHAR_NL { break; } end -= 1; } &input[start..end] } fn find_char(input: &[u8], char_code: u8, from: usize) -> Option<usize> { let mut i = from; while i < input.len() { if input[i] == char_code { return Some(i); } i += 1; } None } /// Find the closing quote for a quoted value. /// /// First tries the nearest matching quote (standard behavior). If there is /// non-whitespace/non-comment content after that quote on the same line, /// inner quotes are present. In that case, scan backwards from the end of /// the line for the best closing candidate. A quote followed by a `#` /// comment is preferred over one followed by just whitespace/EOL, since /// it's a stronger signal of a real closing delimiter. This matches the /// npm `dotenv` package's greedy quote-stripping behavior. fn find_closing_quote(input: &[u8], quote: u8, from: usize) -> Option<usize> { let first = find_char(input, quote, from)?; // Find the end of the current line. let line_end = find_char(input, CHAR_NL, first + 1).unwrap_or(input.len()); // Check if everything after the first closing quote until EOL is // whitespace or a comment. If so, the first quote is the real closer. let after_first = &input[first + 1..line_end]; let trimmed_first = trim_spaces_slice(after_first); if trimmed_first.is_empty() || trimmed_first[0] == CHAR_HASH { return Some(first); } // Inner quotes detected. Scan backwards from the end of line. Prefer // a quote followed by a `#` comment (strong signal) over one followed // by just whitespace/EOL (weaker — could be a quote inside a comment). let mut best_empty = None; let mut i = line_end; while i > first + 1 { i -= 1; if input[i] == quote { let after = &input[i + 1..line_end]; let trimmed = trim_spaces_slice(after); if !trimmed.is_empty() && trimmed[0] == CHAR_HASH { // Comment follows — this is the real closing quote. return Some(i); } if trimmed.is_empty() && best_empty.is_none() { best_empty = Some(i); } } } // No comment-delimited candidate — use the last quote at EOL, or // fall back to the first quote if nothing else matched. Some(best_empty.unwrap_or(first)) } #[cfg(test)] mod tests { use std::collections::BTreeMap; use std::collections::HashMap; use sys_traits::EnvSetVar; use sys_traits::FsCreateDirAll; use sys_traits::FsWrite; use sys_traits::impls::InMemorySys; use super::*; /// Helper: parse content and return a HashMap for easy assertion fn parse_map(content: &str) -> HashMap<String, String> { let mut map = HashMap::new(); parse_env_content_hook(content, &mut |key, value| { map.insert(key.to_string(), value.to_string()); }); map } fn assert_parsed_eq(content: &str, expected: &[(&str, &str)]) { let actual = parse_map(content).into_iter().collect::<BTreeMap<_, _>>(); let expected = expected .iter() .map(|(k, v)| ((*k).to_string(), (*v).to_string())) .collect::<BTreeMap<_, _>>(); assert_eq!(actual, expected); } fn parse_map_with_substitution( sys: &dyn BaseEnvVar, content: &str, ) -> HashMap<String, String> { let mut map = HashMap::new(); parse_env_content_with_substitution_hook( sys, content, &mut |key, value| { map.insert(key.to_string(), value.to_string()); }, ); map } fn assert_parsed_eq_with_substitution( sys: &dyn BaseEnvVar, content: &str, expected: &[(&str, &str)], ) { let actual = parse_map_with_substitution(sys, content) .into_iter() .collect::<BTreeMap<_, _>>(); let expected = expected .iter() .map(|(k, v)| ((*k).to_string(), (*v).to_string())) .collect::<BTreeMap<_, _>>(); assert_eq!(actual, expected); } #[test] fn test_valid_env() { // https://github.com/nodejs/node/blob/70f6b58ac655234435a99d72b857dd7b316d34bf/benchmark/fixtures/valid.env let content = r#"BASIC=basic # COMMENTS=work #BASIC=basic2 #BASIC=basic3 # previous line intentionally left blank AFTER_LINE=after_line A="B=C" B=C=D EMPTY= EMPTY_SINGLE_QUOTES='' EMPTY_DOUBLE_QUOTES="" EMPTY_BACKTICKS=`` SINGLE_QUOTES='single_quotes' SINGLE_QUOTES_SPACED=' single quotes ' DOUBLE_QUOTES="double_quotes" DOUBLE_QUOTES_SPACED=" double quotes " DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes' DOUBLE_QUOTES_WITH_NO_SPACE_BRACKET="{ port: $MONGOLAB_PORT}" SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes" BACKTICKS_INSIDE_SINGLE='`backticks` work inside single quotes' BACKTICKS_INSIDE_DOUBLE="`backticks` work inside double quotes" BACKTICKS=`backticks` BACKTICKS_SPACED=` backticks ` DOUBLE_QUOTES_INSIDE_BACKTICKS=`double "quotes" work inside backticks` SINGLE_QUOTES_INSIDE_BACKTICKS=`single 'quotes' work inside backticks` DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS=`double "quotes" and single 'quotes' work inside backticks` EXPAND_NEWLINES="expand\nnew\nlines" DONT_EXPAND_UNQUOTED=dontexpand\nnewlines DONT_EXPAND_SQUOTED='dontexpand\nnewlines' # COMMENTS=work INLINE_COMMENTS=inline comments # work #very #well INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work INLINE_COMMENTS_BACKTICKS=`inline comments outside of #backticks` # work INLINE_COMMENTS_SPACE=inline comments start with a#number sign. no space required. EQUAL_SIGNS=equals== RETAIN_INNER_QUOTES={"foo": "bar"} RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}' RETAIN_INNER_QUOTES_AS_BACKTICKS=`{"foo": "bar's"}` TRIM_SPACE_FROM_UNQUOTED= some spaced out string SPACE_BEFORE_DOUBLE_QUOTES= "space before double quotes" EMAIL=therealnerdybeast@example.tld SPACED_KEY = parsed EDGE_CASE_INLINE_COMMENTS="VALUE1" # or "VALUE2" or "VALUE3" MULTI_DOUBLE_QUOTED="THIS IS A MULTILINE STRING" MULTI_SINGLE_QUOTED='THIS IS A MULTILINE STRING' MULTI_BACKTICKED=`THIS IS A "MULTILINE'S" STRING` export EXPORT_EXAMPLE = ignore export MULTI_NOT_VALID_QUOTE=" MULTI_NOT_VALID=THIS IS NOT MULTILINE "#; assert_parsed_eq( content, &[ ("BASIC", "basic"), ("AFTER_LINE", "after_line"), ("A", "B=C"), ("B", "C=D"), ("EMPTY", ""), ("EMPTY_SINGLE_QUOTES", ""), ("EMPTY_DOUBLE_QUOTES", ""), ("EMPTY_BACKTICKS", ""), ("SINGLE_QUOTES", "single_quotes"), ("SINGLE_QUOTES_SPACED", " single quotes "), ("DOUBLE_QUOTES", "double_quotes"), ("DOUBLE_QUOTES_SPACED", " double quotes "), ( "DOUBLE_QUOTES_INSIDE_SINGLE", r#"double "quotes" work inside single quotes"#, ), ( "DOUBLE_QUOTES_WITH_NO_SPACE_BRACKET", "{ port: $MONGOLAB_PORT}", ), ( "SINGLE_QUOTES_INSIDE_DOUBLE", "single 'quotes' work inside double quotes", ), ( "BACKTICKS_INSIDE_SINGLE", "`backticks` work inside single quotes", ), ( "BACKTICKS_INSIDE_DOUBLE", "`backticks` work inside double quotes", ), ("BACKTICKS", "backticks"), ("BACKTICKS_SPACED", " backticks "), ( "DOUBLE_QUOTES_INSIDE_BACKTICKS", r#"double "quotes" work inside backticks"#, ), ( "SINGLE_QUOTES_INSIDE_BACKTICKS", "single 'quotes' work inside backticks", ), ( "DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS", "double \"quotes\" and single 'quotes' work inside backticks", ), ("EXPAND_NEWLINES", "expand\nnew\nlines"), ("DONT_EXPAND_UNQUOTED", "dontexpand\\nnewlines"), ("DONT_EXPAND_SQUOTED", "dontexpand\\nnewlines"), ("INLINE_COMMENTS", "inline comments"), ( "INLINE_COMMENTS_SINGLE_QUOTES", "inline comments outside of #singlequotes", ), ( "INLINE_COMMENTS_DOUBLE_QUOTES", "inline comments outside of #doublequotes", ), ( "INLINE_COMMENTS_BACKTICKS", "inline comments outside of #backticks", ), ("INLINE_COMMENTS_SPACE", "inline comments start with a"), ("EQUAL_SIGNS", "equals=="), ("RETAIN_INNER_QUOTES", r#"{"foo": "bar"}"#), ("RETAIN_INNER_QUOTES_AS_STRING", r#"{"foo": "bar"}"#), ("RETAIN_INNER_QUOTES_AS_BACKTICKS", r#"{"foo": "bar's"}"#), ("TRIM_SPACE_FROM_UNQUOTED", "some spaced out string"), ("SPACE_BEFORE_DOUBLE_QUOTES", "space before double quotes"), ("EMAIL", "therealnerdybeast@example.tld"), ("SPACED_KEY", "parsed"), ("EDGE_CASE_INLINE_COMMENTS", "VALUE1"), ("MULTI_DOUBLE_QUOTED", "THIS\nIS\nA\nMULTILINE\nSTRING"), ("MULTI_SINGLE_QUOTED", "THIS\nIS\nA\nMULTILINE\nSTRING"), ("MULTI_BACKTICKED", "THIS\nIS\nA\n\"MULTILINE'S\"\nSTRING"), ("EXPORT_EXAMPLE", "ignore export"), ("MULTI_NOT_VALID_QUOTE", "\""), ("MULTI_NOT_VALID", "THIS"), ], ); } #[test] fn test_eof_without_value() { // https://github.com/nodejs/node/blob/70f6b58ac655234435a99d72b857dd7b316d34bf/test/fixtures/dotenv/eof-without-value.env let content = "BASIC=value\nEMPTY=\n"; assert_parsed_eq(content, &[("BASIC", "value"), ("EMPTY", "")]); } #[test] fn test_eof_without_value_no_trailing_newline() { let content = "BASIC=value\nEMPTY="; assert_parsed_eq(content, &[("BASIC", "value"), ("EMPTY", "")]); } #[test] fn test_multiline() { // https://github.com/nodejs/node/blob/70f6b58ac655234435a99d72b857dd7b316d34bf/test/fixtures/dotenv/multiline.env let content = r#"JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/ bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/ kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V u4QuUoobAgMBAAE= -----END PUBLIC KEY-----" "#; assert_parsed_eq( content, &[( "JWT_PUBLIC_KEY", "-----BEGIN PUBLIC KEY-----\n\ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u\n\ LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/\n\ bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/\n\ kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V\n\ u4QuUoobAgMBAAE=\n\ -----END PUBLIC KEY-----", )], ); } #[test] fn test_lines_with_only_spaces() { // https://github.com/nodejs/node/blob/70f6b58ac655234435a99d72b857dd7b316d34bf/test/fixtures/dotenv/lines-with-only-spaces.env let content = "\nEMPTY_LINE='value after an empty line'\n \nSPACES_LINE='value after a line with just some spaces'\n\t\t\t\t\nTABS_LINE='value after a line with just some tabs'\n\t \t\t\t\nSPACES_TABS_LINE='value after a line with just some spaces and tabs'\n"; assert_parsed_eq( content, &[ ("EMPTY_LINE", "value after an empty line"), ("SPACES_LINE", "value after a line with just some spaces"), ("TABS_LINE", "value after a line with just some tabs"), ( "SPACES_TABS_LINE", "value after a line with just some spaces and tabs", ), ], ); } #[test] fn test_windows_line_endings() { let content = "KEY1=value1\r\nKEY2=value2\r\nKEY3=value3\r\n"; assert_parsed_eq( content, &[("KEY1", "value1"), ("KEY2", "value2"), ("KEY3", "value3")], ); } #[test] fn test_empty_content() { let env = parse_map(""); assert!(env.is_empty()); } #[test] fn test_only_comments() { let content = "# this is a comment\n# another comment\n"; let env = parse_map(content); assert!(env.is_empty()); } #[test] fn test_export_prefix() { let content = "export FOO=bar\nexport BAZ=qux\n"; assert_parsed_eq(content, &[("FOO", "bar"), ("BAZ", "qux")]); } #[test] fn test_callback_order() { let content = "A=1\nB=2\nC=3\n"; let mut entries = Vec::new(); parse_env_content_hook(content, &mut |key, value| { entries.push((key.to_string(), value.to_string())); }); assert_eq!( entries, vec![ ("A".to_string(), "1".to_string()), ("B".to_string(), "2".to_string()), ("C".to_string(), "3".to_string()), ] ); } #[test] fn test_empty_key_skipped() { let content = "=value\n"; let env = parse_map(content); assert!(env.is_empty()); } #[test] fn test_single_key_no_value_eof() { let content = "KEY="; assert_parsed_eq(content, &[("KEY", "")]); } #[test] fn test_no_newline_at_eof() { let content = "KEY=value"; assert_parsed_eq(content, &[("KEY", "value")]); } #[test] fn test_no_newline_at_eof_with_single_quote() { let content = "KEY='value'"; assert_parsed_eq(content, &[("KEY", "value")]); } #[test] fn test_variable_in_parenthesis_surrounded_by_quotes() { let sys = sys_traits::impls::InMemorySys::default(); assert_parsed_eq_with_substitution( &sys, r#" KEY=test KEY1="${KEY}" "#, &[("KEY", "test"), ("KEY1", "test")], ); } #[test] fn test_substitute_undefined_variables_to_empty_string() { let sys = sys_traits::impls::InMemorySys::default(); assert_parsed_eq_with_substitution( &sys, r#"KEY=">$KEY1<>${KEY2}<""#, &[("KEY", "><><")], ); } #[test] fn test_do_not_substitute_variables_with_dollar_escaped() { let sys = sys_traits::impls::InMemorySys::default(); assert_parsed_eq_with_substitution( &sys, r#"KEY=>\$KEY1<>\${KEY2}<"#, &[("KEY", ">$KEY1<>${KEY2}<")], ); } #[test] fn test_do_not_substitute_variables_in_strong_quotes() { let sys = sys_traits::impls::InMemorySys::default(); assert_parsed_eq_with_substitution( &sys, "KEY='>${KEY1}<>$KEY2<'", &[("KEY", ">${KEY1}<>$KEY2<")], ); } #[test] fn test_same_variable_reused() { let sys = sys_traits::impls::InMemorySys::default(); assert_parsed_eq_with_substitution( &sys, r#" KEY=VALUE KEY1=$KEY$KEY "#, &[("KEY", "VALUE"), ("KEY1", "VALUEVALUE")], ); } #[test] fn test_variable_without_parenthesis_is_substituted_before_separators() { let sys = sys_traits::impls::InMemorySys::default(); assert_parsed_eq_with_substitution( &sys, r#" KEY1=test_user KEY1_1=test_user_with_separator KEY=">$KEY1_1<>$KEY1}<>$KEY1{<" "#, &[ ("KEY1", "test_user"), ("KEY1_1", "test_user_with_separator"), // an underscore is part of the variable name (matching bash and // @std/dotenv), so `$KEY1_1` resolves the `KEY1_1` variable; `{` and // `}` still terminate an unbraced reference ("KEY", ">test_user_with_separator<>test_user}<>test_user{<"), ], ); } #[test] fn test_underscore_is_part_of_unbraced_variable_name() { // https://github.com/denoland/deno/issues/26581 let sys = sys_traits::impls::InMemorySys::default(); assert_parsed_eq_with_substitution( &sys, r#" C_D=value1000 E=$C_D${C_D} "#, &[("C_D", "value1000"), ("E", "value1000value1000")], ); } #[test] fn test_substitute_variable_from_env_variable() { let sys = sys_traits::impls::InMemorySys::default(); sys.env_set_var("DENO_DOTENV_TEST_KEY11", "overriden from process env"); assert_parsed_eq_with_substitution( &sys, r#"KEY=">${DENO_DOTENV_TEST_KEY11}<""#, &[("KEY", ">overriden from process env<")], ); } #[test] fn test_substitute_variable_env_variable_overrides_dotenv_in_substitution() { let sys = sys_traits::impls::InMemorySys::default(); sys.env_set_var("DENO_DOTENV_TEST_KEY11", "overriden from process env"); assert_parsed_eq_with_substitution( &sys, r#" DENO_DOTENV_TEST_KEY11=test_user KEY=">${DENO_DOTENV_TEST_KEY11}<" "#, &[ ("DENO_DOTENV_TEST_KEY11", "test_user"), ("KEY", ">overriden from process env<"), ], ); } #[test] fn test_consequent_substitutions() { let sys = sys_traits::impls::InMemorySys::default(); assert_parsed_eq_with_substitution( &sys, r#" KEY1=test_user KEY2=${KEY1}_2 KEY=>${KEY1}<>${KEY2}< "#, &[ ("KEY1", "test_user"), ("KEY2", "test_user_2"), ("KEY", ">test_user<>test_user_2<"), ], ); } #[test] fn test_consequent_substitutions_with_one_missing() { let sys = sys_traits::impls::InMemorySys::default(); assert_parsed_eq_with_substitution( &sys, r#" KEY2=$KEY1_2 KEY=>${KEY1}<>${KEY2}< "#, // `KEY1_2` is the whole variable name and is undefined, so it resolves // to an empty string &[("KEY2", ""), ("KEY", "><><")], ); } #[test] fn find_path_and_content_reads_file_directly() { let sys = InMemorySys::default(); sys.fs_create_dir_all("/project").unwrap(); sys.fs_write("/project/.env", "KEY=value").unwrap(); let (path, content) = find_path_and_content(&sys, Path::new("/project"), "/project/.env") .unwrap() .unwrap(); assert_eq!(path, Path::new("/project/.env")); assert_eq!(content.as_ref(), "KEY=value"); } #[test] fn find_path_and_content_traverses_parent_dirs() { let sys = InMemorySys::default(); sys.fs_create_dir_all("/project/sub/deep").unwrap(); sys.fs_write("/project/.env", "FOUND=true").unwrap(); let (path, content) = find_path_and_content(&sys, Path::new("/project/sub/deep"), ".env") .unwrap() .unwrap(); assert_eq!(path, Path::new("/project/.env")); assert_eq!(content.as_ref(), "FOUND=true"); } #[test] fn find_path_and_content_returns_closest_ancestor() { let sys = InMemorySys::default(); sys.fs_create_dir_all("/project/sub/deep").unwrap(); sys.fs_write("/project/.env", "ROOT=true").unwrap(); sys.fs_write("/project/sub/.env", "MID=true").unwrap(); // starting from /project/sub/deep, should find /project/sub/.env first let (path, content) = find_path_and_content(&sys, Path::new("/project/sub/deep"), ".env") .unwrap() .unwrap(); assert_eq!(path, Path::new("/project/sub/.env")); assert_eq!(content.as_ref(), "MID=true"); } #[test] fn find_path_and_content_not_found() { let sys = InMemorySys::default(); sys.fs_create_dir_all("/project").unwrap(); let result = find_path_and_content(&sys, Path::new("/project"), ".env"); assert!(result.unwrap().is_none()); } #[test] fn find_path_and_content_custom_filename() { let sys = InMemorySys::default(); sys.fs_create_dir_all("/project/child").unwrap(); sys.fs_write("/project/.env.local", "LOCAL=1").unwrap(); let (path, content) = find_path_and_content(&sys, Path::new("/project/child"), ".env.local") .unwrap() .unwrap(); assert_eq!(path, Path::new("/project/.env.local")); assert_eq!(content.as_ref(), "LOCAL=1"); } #[test] fn find_path_and_content_relative_subdir_traverses_ancestors() { let sys = InMemorySys::default(); sys.fs_create_dir_all("/project/sub/deep").unwrap(); sys .fs_write("/project/sub/.envfile", "ANCESTOR=found") .unwrap(); let (path, content) = find_path_and_content( &sys, Path::new("/project/sub/deep"), "sub/.envfile", ) .unwrap() .unwrap(); assert_eq!(path, Path::new("/project/sub/.envfile")); assert_eq!(content.as_ref(), "ANCESTOR=found"); } // Regression test for https://github.com/denoland/deno/issues/32928 #[test] fn inner_quotes_in_double_quoted_values() { let input = r#"INNER_QUOTES="1: foo'bar"baz`qux" INNER_QUOTES_WITH_NEWLINE="2: foo bar\ni am "on" newline, 'yo'" "#; let pairs = parse_map(input); assert_eq!( pairs.get("INNER_QUOTES").map(String::as_str), Some("1: foo'bar\"baz`qux") ); assert_eq!( pairs.get("INNER_QUOTES_WITH_NEWLINE").map(String::as_str), Some("2: foo bar\ni am \"on\" newline, 'yo'") ); } #[test] fn inner_double_quotes_in_double_quoted_value() { // Tests find_closing_quote with same-type inner quotes let input = "KEY=\"hello \"world\" goodbye\"\n"; let pairs = parse_map(input); assert_eq!( pairs.get("KEY").map(String::as_str), Some("hello \"world\" goodbye") ); } #[test] fn inner_quotes_with_inline_comment() { // Inner quotes followed by an inline comment containing quotes. // The comment should not be included in the value. let input = "KEY=\"hello \"world\"\" # a \"comment\"\n"; let pairs = parse_map(input); assert_eq!( pairs.get("KEY").map(String::as_str), Some("hello \"world\"") ); } #[test] fn inner_single_quotes_preserved() { // Cross-quote-type inner quotes: single-quoted value with inner // double quotes. These are handled by find_char since the inner // quotes are a different character type. let input = "KEY='hello \"world\" goodbye'\n"; let pairs = parse_map(input); assert_eq!( pairs.get("KEY").map(String::as_str), Some("hello \"world\" goodbye") ); } }