agent_first_data/document/format/
dotenv.rs1use 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
34pub 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
74fn 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 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 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
157pub 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
167pub 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
176pub 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 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
262fn 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 if !value.contains(['$', '`', '\\', '"', '\r']) {
283 return format!("\"{value}\"");
284 }
285 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
372fn 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 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 #[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 let error = load("A=\"unterminated\nB=after\n").unwrap_err();
586 assert_eq!(error.code(), "document_parse_failed");
587 }
588}