Skip to main content

agent_first_data/document/format/
dotenv.rs

1//! Source-preserving dotenv backend.
2
3use crate::document::{DocumentError, DocumentResult, Value};
4use std::collections::BTreeMap;
5
6const MAX_DOTENV_VALUE_BYTES: usize = 1024 * 1024;
7
8#[derive(Debug)]
9pub struct DotenvDocument<'a> {
10    source: &'a str,
11    keys: Vec<String>,
12}
13
14impl<'a> DotenvDocument<'a> {
15    pub fn parse(source: &'a str) -> DocumentResult<Self> {
16        let value = parse_semantic(source)?;
17        let keys = value
18            .as_object()
19            .map(|object| object.keys().cloned().collect())
20            .unwrap_or_default();
21        Ok(Self { source, keys })
22    }
23
24    fn has_key(&self, key: &str) -> bool {
25        self.keys.iter().any(|candidate| candidate == key)
26    }
27
28    #[allow(dead_code)]
29    fn source(&self) -> &'a str {
30        self.source
31    }
32}
33
34/// Parse dotenv without shell execution, environment lookup, or variable expansion.
35pub fn load(content: &str) -> DocumentResult<Value> {
36    parse_semantic(content)
37}
38
39fn parse_semantic(content: &str) -> DocumentResult<Value> {
40    let mut values = BTreeMap::new();
41
42    for (line_number, raw_line) in logical_lines(content)? {
43        let raw_line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
44        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line).trim_start();
45        if line.is_empty() || line.starts_with('#') {
46            continue;
47        }
48
49        let assignment = line
50            .strip_prefix("export")
51            .and_then(|rest| {
52                rest.starts_with(char::is_whitespace)
53                    .then(|| rest.trim_start())
54            })
55            .unwrap_or(line);
56        let Some((raw_key, raw_value)) = assignment.split_once('=') else {
57            return Err(parse_error(line_number, "expected KEY=value assignment"));
58        };
59        let key = raw_key.trim();
60        if !valid_key(key) {
61            return Err(parse_error(line_number, "invalid variable name"));
62        }
63
64        let value = parse_value(raw_value.trim_start(), line_number)?;
65        if values.contains_key(key) {
66            return Err(parse_error(line_number, "duplicate variable name"));
67        }
68        values.insert(key.to_string(), Value::String(value));
69    }
70
71    Ok(Value::Object(values))
72}
73
74/// Split `content` into logical lines, joining the physical lines a quoted
75/// value spans.
76///
77/// A quote only opens a value where a value can start: after the `=`, before
78/// any other non-whitespace character, and never inside a comment. Tracking
79/// less than that made an ordinary English comment — `# set KEY=value if it
80/// isn't already there` — open a quote at the apostrophe that no later line
81/// closed, swallowing the rest of the file into one unparseable line and
82/// yielding zero keys with no error at all.
83fn logical_lines(content: &str) -> DocumentResult<Vec<(usize, &str)>> {
84    let mut lines = Vec::new();
85    let mut start = 0;
86    let mut first_line = 1;
87    let mut line_number = 1;
88    let mut quote = None;
89    let mut quote_opened_at = 0;
90    let mut escaped = false;
91    let mut value_may_open = false;
92    let mut assignment_started = false;
93    let mut in_comment = false;
94    let mut at_line_start = true;
95
96    for (offset, character) in content.char_indices() {
97        if quote.is_none() {
98            if at_line_start && !character.is_whitespace() {
99                at_line_start = false;
100                in_comment = character == '#';
101            }
102            if !in_comment {
103                if value_may_open
104                    && character.is_whitespace()
105                    && character != '\n'
106                    && character != '\r'
107                {
108                    continue;
109                }
110                if value_may_open && (character == '\'' || character == '"') {
111                    quote = Some(character);
112                    quote_opened_at = line_number;
113                    value_may_open = false;
114                } else if character == '=' && !assignment_started {
115                    value_may_open = true;
116                    assignment_started = true;
117                } else if !character.is_whitespace() {
118                    // The value has already started unquoted, so a quote from
119                    // here on is an ordinary character, not a delimiter.
120                    value_may_open = false;
121                }
122            }
123        } else if escaped {
124            escaped = false;
125        } else if character == '\\' {
126            escaped = true;
127        } else if Some(character) == quote {
128            quote = None;
129        }
130        if character == '\n' && quote.is_none() {
131            lines.push((first_line, &content[start..offset + 1]));
132            start = offset + 1;
133            first_line = line_number + 1;
134        }
135        if character == '\n' {
136            line_number += 1;
137            if quote.is_none() {
138                value_may_open = false;
139                assignment_started = false;
140                in_comment = false;
141                at_line_start = true;
142            }
143        }
144    }
145    if quote.is_some() {
146        // Reaching end of input inside a quote means the file is malformed.
147        // Reporting zero keys instead let `value --default` hand a caller its
148        // fallback while the real value sat in the file, unread and unmentioned.
149        return Err(parse_error(quote_opened_at, "unterminated quoted value"));
150    }
151    if start < content.len() || content.is_empty() {
152        lines.push((first_line, &content[start..]));
153    }
154    Ok(lines)
155}
156
157/// dotenv is intentionally read-only because the generic IR loses source formatting.
158pub fn save(_value: &Value) -> DocumentResult<String> {
159    Err(DocumentError::UnsupportedOperation {
160        format: "dotenv".to_string(),
161        operation: "save".to_string(),
162        detail: "dotenv files are read-only because rewriting would lose comments, ordering, and quoting"
163            .to_string(),
164    })
165}
166
167/// Replace one existing assignment while preserving every other source byte.
168pub fn set_preserving(content: &str, key: &str, value: &Value) -> DocumentResult<String> {
169    let Value::String(value) = value else {
170        return Err(unsupported("set", "dotenv values are strings"));
171    };
172    let replacement = encode_value(value);
173    edit_line(content, key, Some(&replacement))
174}
175
176/// Remove one existing assignment while preserving every other source byte.
177pub fn unset_preserving(content: &str, key: &str) -> DocumentResult<String> {
178    edit_line(content, key, None)
179}
180
181fn edit_line(content: &str, key: &str, replacement: Option<&str>) -> DocumentResult<String> {
182    // The path is at fault here, not the file: a parse error would send the
183    // caller looking for a malformed line that does not exist.
184    if !valid_key(key) {
185        let operation = if replacement.is_some() {
186            "set"
187        } else {
188            "unset"
189        };
190        return Err(unsupported(
191            operation,
192            &format!("`{key}` is not a dotenv variable name"),
193        ));
194    }
195    let document = DotenvDocument::parse(content).map_err(|error| with_path(error, key))?;
196    let mut output = String::with_capacity(content.len());
197    let found = document.has_key(key);
198    for (line_number, line) in logical_lines(content)? {
199        let body = line.strip_suffix('\n').unwrap_or(line);
200        let bare = body.strip_suffix('\r').unwrap_or(body);
201        let trimmed = bare.trim_start();
202        let assignment = trimmed
203            .strip_prefix("export")
204            .and_then(|rest| {
205                rest.starts_with(char::is_whitespace)
206                    .then(|| rest.trim_start())
207            })
208            .unwrap_or(trimmed);
209        let matches = assignment
210            .split_once('=')
211            .is_some_and(|(name, _)| name.trim() == key);
212        if matches {
213            if let Some(value) = replacement {
214                let prefix_len = bare.len() - assignment.len();
215                let prefix = &body[..prefix_len];
216                let (name, old_value) = assignment.split_once('=').unwrap_or((key, ""));
217                let old_trimmed = old_value.trim_start();
218                let spacing = &old_value[..old_value.len() - old_trimmed.len()];
219                let suffix = value_suffix(old_trimmed, line_number)?;
220                output.push_str(prefix);
221                output.push_str(name);
222                output.push('=');
223                output.push_str(spacing);
224                output.push_str(value);
225                output.push_str(suffix);
226                if body.ends_with('\r') {
227                    output.push('\r');
228                }
229                if line.ends_with('\n') {
230                    output.push('\n');
231                }
232            }
233        } else {
234            output.push_str(line);
235        }
236    }
237    if !found {
238        if let Some(value) = replacement {
239            let newline = if content.contains("\r\n") {
240                "\r\n"
241            } else {
242                "\n"
243            };
244            if !output.is_empty() && !output.ends_with('\n') {
245                output.push_str(newline);
246            }
247            output.push_str(key);
248            output.push('=');
249            output.push_str(value);
250            if content.ends_with('\n') || !output.ends_with('\n') {
251                output.push_str(newline);
252            }
253            return Ok(output);
254        }
255        return Err(DocumentError::PathNotFound {
256            path: key.to_string(),
257        });
258    }
259    Ok(output)
260}
261
262/// Encode a value so the file reads back the same here and when a shell
263/// sources it (`set -a; . ./.env`): bare only for characters no shell treats
264/// specially, plain double quotes when nothing inside would expand or need an
265/// escape, single quotes (no expansion at all) when the value allows them, and
266/// double quotes with `$` and `` ` `` escaped otherwise.
267///
268/// Newlines and tabs are written literally inside the quotes, since a shell
269/// reads `\n` in double quotes as two characters. A carriage return is the
270/// one exception: written as `\r`, so editors and line-ending conversion
271/// cannot silently change the value, at the cost of a shell seeing `\r`.
272fn encode_value(value: &str) -> String {
273    if !value.is_empty()
274        && value.chars().all(|c| {
275            c.is_alphanumeric()
276                || matches!(c, '_' | '.' | '/' | ':' | '@' | '%' | '+' | ',' | '=' | '-')
277        })
278    {
279        return value.to_string();
280    }
281    // Double quotes need nothing escaped here, and nothing in them expands.
282    if !value.contains(['$', '`', '\\', '"', '\r']) {
283        return format!("\"{value}\"");
284    }
285    // The reader treats `\'` inside single quotes as an escaped quote, so a
286    // trailing backslash would swallow the closing quote.
287    if !value.contains(['\'', '\r']) && !value.ends_with('\\') {
288        return format!("'{value}'");
289    }
290    format!(
291        "\"{}\"",
292        value
293            .replace('\\', "\\\\")
294            .replace('"', "\\\"")
295            .replace('$', "\\$")
296            .replace('`', "\\`")
297            .replace('\r', "\\r")
298    )
299}
300
301fn unsupported(operation: &str, detail: &str) -> DocumentError {
302    DocumentError::UnsupportedOperation {
303        format: "dotenv".to_string(),
304        operation: operation.to_string(),
305        detail: detail.to_string(),
306    }
307}
308
309fn valid_key(key: &str) -> bool {
310    let mut chars = key.chars();
311    chars
312        .next()
313        .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
314        && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
315}
316
317fn parse_value(input: &str, line_number: usize) -> DocumentResult<String> {
318    if input.len() > MAX_DOTENV_VALUE_BYTES {
319        return Err(parse_error(line_number, "value exceeds 1 MiB"));
320    }
321    match input.chars().next() {
322        Some('\'') => parse_quoted(input, '\'', line_number).map(|(value, _)| value),
323        Some('"') => parse_quoted(input, '"', line_number).map(|(value, _)| value),
324        _ => parse_unquoted(input),
325    }
326}
327
328fn parse_quoted(input: &str, quote: char, line_number: usize) -> DocumentResult<(String, usize)> {
329    let mut output = String::new();
330    let mut escaped = false;
331
332    for (offset, character) in input[quote.len_utf8()..].char_indices() {
333        if escaped {
334            match (quote, character) {
335                ('"', 'n') => output.push('\n'),
336                ('"', 'r') => output.push('\r'),
337                ('"', 't') => output.push('\t'),
338                ('"', '\\') => output.push('\\'),
339                ('"', '"') => output.push('"'),
340                ('"', '$') => output.push('$'),
341                ('"', '`') => output.push('`'),
342                ('\'', '\'') => output.push('\''),
343                (_, other) => {
344                    output.push('\\');
345                    output.push(other);
346                }
347            }
348            escaped = false;
349            continue;
350        }
351        if character == '\\' {
352            escaped = true;
353            continue;
354        }
355        if character == quote {
356            let remainder_index = quote.len_utf8() + offset + character.len_utf8();
357            let remainder = input[remainder_index..].trim_start();
358            if !remainder.is_empty() && !remainder.starts_with('#') {
359                return Err(parse_error(
360                    line_number,
361                    "unexpected content after quoted value",
362                ));
363            }
364            return Ok((output, remainder_index));
365        }
366        output.push(character);
367    }
368
369    Err(parse_error(line_number, "unterminated quoted value"))
370}
371
372/// Keep only an actual trailing comment or whitespace, never a ` #` inside a
373/// quoted value. The parser and editor share the quote boundary.
374fn value_suffix(input: &str, line_number: usize) -> DocumentResult<&str> {
375    if let Some(quote @ ('\'' | '"')) = input.chars().next() {
376        let (_, end) = parse_quoted(input, quote, line_number)?;
377        return Ok(&input[end..]);
378    }
379    let mut escaped = false;
380    let mut whitespace = None;
381    for (offset, character) in input.char_indices() {
382        if escaped {
383            escaped = false;
384        } else if character == '\\' {
385            escaped = true;
386            continue;
387        } else if character == '#'
388            && let Some(start) = whitespace
389        {
390            return Ok(&input[start..]);
391        }
392        if character.is_whitespace() {
393            whitespace.get_or_insert(offset);
394        } else {
395            whitespace = None;
396        }
397    }
398    Ok(&input[whitespace.unwrap_or(input.len())..])
399}
400
401fn parse_unquoted(input: &str) -> DocumentResult<String> {
402    let mut output = String::new();
403    let mut escaped = false;
404
405    for character in input.chars() {
406        if escaped {
407            output.push(character);
408            escaped = false;
409        } else if character == '\\' {
410            escaped = true;
411        } else if character == '#' && output.chars().last().is_some_and(char::is_whitespace) {
412            break;
413        } else {
414            output.push(character);
415        }
416    }
417    if escaped {
418        output.push('\\');
419    }
420    Ok(output.trim_end().to_string())
421}
422
423fn parse_error(line_number: usize, detail: &str) -> DocumentError {
424    DocumentError::ParseError {
425        format: "dotenv".to_string(),
426        detail: format!("line {line_number}: {detail}"),
427    }
428}
429
430fn with_path(error: DocumentError, path: &str) -> DocumentError {
431    match error {
432        DocumentError::ParseError { format, detail } => DocumentError::ParseError {
433            format,
434            detail: format!("path `{path}`: {detail}"),
435        },
436        other => other,
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn a_comment_does_not_swallow_the_file() {
446        // The apostrophe in "isn't" follows an `=` earlier on the same comment
447        // line. Treating it as an opening quote consumed every later line, so
448        // the file parsed to zero keys and reported success — a caller using
449        // `--default` got its fallback while the real value sat in the file.
450        let source =
451            "# set KEY=value if it isn't already there\nAPI_HOST=example.com\nAPI_PORT=8080\n";
452        let Value::Object(values) = load(source).unwrap() else {
453            panic!("expected an object");
454        };
455        assert_eq!(values.len(), 2);
456        assert_eq!(
457            values.get("API_HOST"),
458            Some(&Value::String("example.com".to_string()))
459        );
460    }
461
462    #[test]
463    fn a_quote_after_the_value_started_is_literal() {
464        let Value::Object(values) = load("MESSAGE=it isn't quoted\n").unwrap() else {
465            panic!("expected an object");
466        };
467        assert_eq!(
468            values.get("MESSAGE"),
469            Some(&Value::String("it isn't quoted".to_string()))
470        );
471    }
472
473    #[test]
474    fn quoted_values_still_span_lines() {
475        let Value::Object(values) = load("A=\"line1\nline2\"\nB=after\n").unwrap() else {
476            panic!("expected an object");
477        };
478        assert_eq!(
479            values.get("A"),
480            Some(&Value::String("line1\nline2".to_string()))
481        );
482        assert_eq!(values.get("B"), Some(&Value::String("after".to_string())));
483    }
484
485    #[test]
486    fn editing_a_multiline_value_replaces_its_whole_assignment() {
487        let source = "# before\r\n export A = \"first # value\r\nB=inside\"\t# keep\r\nB=after\r\n";
488        let value = Value::String("\"quoted\"\r\t\n".into());
489        let edited = set_preserving(source, "A", &value).unwrap();
490        assert!(edited.starts_with("# before\r\n export A = "));
491        assert!(edited.ends_with("\t# keep\r\nB=after\r\n"));
492        assert!(!edited.contains("B=inside"));
493        let Value::Object(values) = load(&edited).unwrap() else {
494            panic!("expected an object");
495        };
496        assert_eq!(values["A"], value);
497        assert_eq!(values["B"], Value::String("after".into()));
498        assert_eq!(
499            unset_preserving(source, "A").unwrap(),
500            "# before\r\nB=after\r\n"
501        );
502    }
503
504    #[test]
505    fn written_quotes_and_comment_like_values_round_trip() {
506        for value in [
507            "\"quoted\"",
508            "'quoted'",
509            "x=\"literal",
510            "x # value",
511            "a\r\nb\tc",
512        ] {
513            let value = Value::String(value.into());
514            let edited = set_preserving("A=old # keep\n", "A", &value).unwrap();
515            let Value::Object(values) = load(&edited).unwrap() else {
516                panic!("expected an object");
517            };
518            assert_eq!(values["A"], value, "{edited}");
519            assert!(edited.ends_with(" # keep\n"));
520        }
521        assert_eq!(
522            set_preserving("A=\"old # value\"\n", "A", &Value::String("new".into())).unwrap(),
523            "A=new\n"
524        );
525    }
526
527    /// A written value must mean the same thing to a shell that sources the
528    /// file as it does to this reader, and must never run anything there.
529    #[cfg(unix)]
530    #[test]
531    fn written_values_are_inert_when_a_shell_sources_the_file() {
532        let dir = std::env::temp_dir().join(format!("afdata-dotenv-{}", std::process::id()));
533        std::fs::create_dir_all(&dir).unwrap();
534        let file = dir.join("t.env");
535        for value in [
536            "x;touch pwned",
537            "a $(touch pwned)",
538            "`touch pwned`",
539            "it's $HOME",
540            "it's `x` \\ $y\\",
541            "~/path",
542            "a|b&c>d<e*f?g[h]",
543            "plain-value_1.2",
544            "line1\nline2\tend",
545            "it's\nmulti $x",
546            "a\\\nb",
547        ] {
548            let value = Value::String(value.into());
549            let edited = set_preserving("", "A", &value).unwrap();
550            let Value::Object(values) = load(&edited).unwrap() else {
551                panic!("expected an object");
552            };
553            assert_eq!(values["A"], value, "{edited}");
554            std::fs::write(&file, &edited).unwrap();
555            let output = std::process::Command::new("sh")
556                .arg("-c")
557                .arg("set -a; . ./t.env; printf %s \"$A\"")
558                .current_dir(&dir)
559                .output()
560                .unwrap();
561            assert!(output.status.success(), "{edited}");
562            assert_eq!(
563                Value::String(String::from_utf8(output.stdout).unwrap()),
564                value,
565                "{edited}"
566            );
567            assert!(!dir.join("pwned").exists(), "{edited}");
568        }
569        std::fs::remove_dir_all(&dir).unwrap();
570    }
571
572    #[test]
573    fn later_equals_signs_cannot_open_an_unquoted_value() {
574        let Value::Object(values) = load("A=query=\"literal\nB=after\n").unwrap() else {
575            panic!("expected an object");
576        };
577        assert_eq!(values["A"], Value::String("query=\"literal".into()));
578        assert_eq!(values["B"], Value::String("after".into()));
579    }
580
581    #[test]
582    fn an_unterminated_quote_is_a_parse_error() {
583        // Silence was the unacceptable answer here: an unreadable file has to
584        // say so rather than present itself as an empty one.
585        let error = load("A=\"unterminated\nB=after\n").unwrap_err();
586        assert_eq!(error.code(), "document_parse_failed");
587    }
588}