1use std::collections::BTreeMap;
4
5use serde::de::IgnoredAny;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9const MAX_INPUT_BYTES: usize = 1024 * 1024;
10const MAX_DEPTH: usize = 32;
11const MAX_KEYS: usize = 4096;
12const MAX_KEY_BYTES: usize = 256;
13const PARTIAL_YAML_WARNING: &str =
14 "unsupported YAML constructs were omitted from key-path extraction";
15const PARTIAL_TOML_WARNING: &str =
16 "unsupported TOML inline structures were omitted from key-path extraction";
17const SAFE_URI_SCHEMES: [&str; 6] = [
18 "vault://",
19 "secret://",
20 "secretsmanager://",
21 "aws-secretsmanager://",
22 "gcp-secret-manager://",
23 "azure-key-vault://",
24];
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ConfigArtifactKind {
30 Dotenv,
32 Yaml,
34 Json,
36 Toml,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum SensitiveKeyKind {
44 Token,
46 Password,
48 Secret,
50 PrivateKey,
52 Credential,
54 AccessKey,
56 ConnectionString,
58 DatabaseUrl,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
64pub struct SafeConfigKey {
65 pub scope: Vec<String>,
67 pub name: String,
69 pub line: usize,
71 pub sensitive_kind: Option<SensitiveKeyKind>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct SafeConfigDocument {
78 pub source_path: String,
80 pub artifact_kind: ConfigArtifactKind,
82 pub keys: Vec<SafeConfigKey>,
84 pub warnings: Vec<String>,
86 pub incomplete: bool,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
92#[serde(tag = "kind", rename_all = "snake_case")]
93pub enum ConfigExtractionError {
94 #[error("unsupported configuration artifact")]
96 UnsupportedArtifact,
97 #[error("configuration input is {actual} bytes; maximum is {maximum}")]
99 InputTooLarge {
100 actual: usize,
102 maximum: usize,
104 },
105 #[error("configuration nesting at line {line} exceeds maximum depth {maximum}")]
107 DepthLimitExceeded {
108 line: usize,
110 maximum: usize,
112 },
113 #[error("configuration key count exceeds maximum {maximum}")]
115 KeyLimitExceeded {
116 maximum: usize,
118 },
119 #[error("configuration key at line {line} exceeds maximum length {maximum}")]
121 KeyTooLong {
122 line: usize,
124 maximum: usize,
126 },
127 #[error("malformed {artifact_kind:?} configuration at line {line}, column {column}")]
129 Malformed {
130 artifact_kind: ConfigArtifactKind,
132 line: usize,
134 column: usize,
136 },
137}
138
139pub fn extract_safe_config(
150 source_path: &str,
151 input: &str,
152) -> Result<SafeConfigDocument, ConfigExtractionError> {
153 let artifact_kind = artifact_kind(source_path)?;
154 if input.len() > MAX_INPUT_BYTES {
155 return Err(ConfigExtractionError::InputTooLarge {
156 actual: input.len(),
157 maximum: MAX_INPUT_BYTES,
158 });
159 }
160
161 let mut collector = KeyCollector::default();
162 let mut warnings = Vec::new();
163 match artifact_kind {
164 ConfigArtifactKind::Dotenv => parse_dotenv(input, &mut collector)?,
165 ConfigArtifactKind::Yaml => parse_yaml(input, &mut collector, &mut warnings)?,
166 ConfigArtifactKind::Json => parse_json(input, &mut collector)?,
167 ConfigArtifactKind::Toml => parse_toml(input, &mut collector, &mut warnings)?,
168 }
169 warnings.sort_unstable();
170 warnings.dedup();
171 let incomplete = !warnings.is_empty();
172
173 Ok(SafeConfigDocument {
174 source_path: source_path.to_owned(),
175 artifact_kind,
176 keys: collector.into_keys(),
177 warnings,
178 incomplete,
179 })
180}
181
182#[must_use]
184pub fn classify_sensitive_key(name: &str) -> Option<SensitiveKeyKind> {
185 let words = normalized_words(name);
186 let compact = words.concat();
187 let has = |needle: &str| words.iter().any(|word| word == needle);
188 let adjacent = |first: &str, second: &str| {
189 words
190 .windows(2)
191 .any(|pair| pair[0] == first && pair[1] == second)
192 };
193
194 if compact.contains("privatekey") || adjacent("private", "key") {
195 Some(SensitiveKeyKind::PrivateKey)
196 } else if compact.contains("connectionstring")
197 || compact.contains("connstr")
198 || adjacent("connection", "string")
199 {
200 Some(SensitiveKeyKind::ConnectionString)
201 } else if compact.contains("databaseurl")
202 || compact.contains("dburl")
203 || compact.contains("jdbcurl")
204 || adjacent("database", "url")
205 {
206 Some(SensitiveKeyKind::DatabaseUrl)
207 } else if compact.contains("accesskey")
208 || compact.contains("apikey")
209 || adjacent("access", "key")
210 || adjacent("api", "key")
211 {
212 Some(SensitiveKeyKind::AccessKey)
213 } else if has("password") || has("passwd") || has("passphrase") || has("pwd") {
214 Some(SensitiveKeyKind::Password)
215 } else if has("credential") || has("credentials") || has("cred") || has("creds") {
216 Some(SensitiveKeyKind::Credential)
217 } else if has("token") || compact.ends_with("token") || has("authorization") || has("bearer") {
218 Some(SensitiveKeyKind::Token)
219 } else if has("secret") || compact.ends_with("secret") {
220 Some(SensitiveKeyKind::Secret)
221 } else {
222 None
223 }
224}
225
226#[must_use]
232pub fn is_safe_literal_reference(value: &str) -> bool {
233 if value.is_empty()
234 || value.trim() != value
235 || value.chars().any(char::is_control)
236 || uri_contains_userinfo(value)
237 || looks_like_secret_literal(value)
238 {
239 return false;
240 }
241
242 if let Some(name) = value
243 .strip_prefix("${")
244 .and_then(|rest| rest.strip_suffix('}'))
245 {
246 return is_reference_identifier(name);
247 }
248 if let Some(name) = value.strip_prefix('$') {
249 return is_reference_identifier(name);
250 }
251 if let Some(name) = value.strip_prefix("env:") {
252 return is_reference_identifier(name.trim_start_matches("//"));
253 }
254 if let Some(name) = value
255 .strip_prefix("env(")
256 .and_then(|rest| rest.strip_suffix(')'))
257 {
258 return is_reference_identifier(name);
259 }
260 if let Some(name) = value
261 .strip_prefix("{{")
262 .and_then(|rest| rest.strip_suffix("}}"))
263 .map(str::trim)
264 .and_then(|inner| inner.strip_prefix("env.").or(Some(inner)))
265 {
266 return is_reference_identifier(name);
267 }
268
269 if SAFE_URI_SCHEMES
270 .iter()
271 .any(|prefix| value.starts_with(prefix))
272 {
273 let reference = value.split_once("://").map_or("", |(_, rest)| rest);
274 return !reference.is_empty()
275 && !reference.contains('?')
276 && reference
277 .bytes()
278 .all(|byte| byte.is_ascii_alphanumeric() || b"/._:-#@".contains(&byte));
279 }
280
281 value
282 .strip_prefix("file:")
283 .is_some_and(is_absolute_secret_path)
284 || is_absolute_secret_path(value)
285}
286
287#[derive(Default)]
288struct KeyCollector {
289 keys: BTreeMap<(Vec<String>, String), SafeConfigKey>,
290}
291
292impl KeyCollector {
293 fn insert(
294 &mut self,
295 scope: &[String],
296 name: String,
297 line: usize,
298 ) -> Result<(), ConfigExtractionError> {
299 if name.len() > MAX_KEY_BYTES {
300 return Err(ConfigExtractionError::KeyTooLong {
301 line,
302 maximum: MAX_KEY_BYTES,
303 });
304 }
305 if name.is_empty() {
306 return Ok(());
307 }
308 let identity = (scope.to_vec(), name.clone());
309 if let Some(existing) = self.keys.get_mut(&identity) {
310 existing.line = existing.line.min(line);
311 return Ok(());
312 }
313 if self.keys.len() >= MAX_KEYS {
314 return Err(ConfigExtractionError::KeyLimitExceeded { maximum: MAX_KEYS });
315 }
316 self.keys.insert(
317 identity,
318 SafeConfigKey {
319 scope: scope.to_vec(),
320 sensitive_kind: classify_sensitive_key(&name),
321 name,
322 line,
323 },
324 );
325 Ok(())
326 }
327
328 fn insert_path(
329 &mut self,
330 base_scope: &[String],
331 path: &[String],
332 line: usize,
333 ) -> Result<(), ConfigExtractionError> {
334 let mut scope = base_scope.to_vec();
335 for name in path {
336 self.insert(&scope, name.clone(), line)?;
337 scope.push(name.clone());
338 check_depth(scope.len(), line)?;
339 }
340 Ok(())
341 }
342
343 fn into_keys(self) -> Vec<SafeConfigKey> {
344 self.keys.into_values().collect()
345 }
346}
347
348fn artifact_kind(source_path: &str) -> Result<ConfigArtifactKind, ConfigExtractionError> {
349 let file_name = source_path
350 .rsplit(['/', '\\'])
351 .next()
352 .unwrap_or(source_path)
353 .to_ascii_lowercase();
354 if file_name == ".env" || file_name.starts_with(".env.") {
355 return Ok(ConfigArtifactKind::Dotenv);
356 }
357 match file_name.rsplit_once('.').map(|(_, extension)| extension) {
358 Some("env") => Ok(ConfigArtifactKind::Dotenv),
359 Some("yaml" | "yml") => Ok(ConfigArtifactKind::Yaml),
360 Some("json") => Ok(ConfigArtifactKind::Json),
361 Some("toml") => Ok(ConfigArtifactKind::Toml),
362 _ => Err(ConfigExtractionError::UnsupportedArtifact),
363 }
364}
365
366fn parse_dotenv(input: &str, collector: &mut KeyCollector) -> Result<(), ConfigExtractionError> {
367 for (index, source_line) in input.lines().enumerate() {
368 let line = index + 1;
369 let text = source_line.trim();
370 if text.is_empty() || text.starts_with('#') {
371 continue;
372 }
373 let assignment = text.strip_prefix("export ").map_or(text, str::trim_start);
374 let Some((key, _)) = assignment.split_once('=') else {
375 return Err(malformed(ConfigArtifactKind::Dotenv, line, 1));
376 };
377 let key = key.trim();
378 if !is_dotenv_key(key) {
379 return Err(malformed(ConfigArtifactKind::Dotenv, line, 1));
380 }
381 collector.insert(&[], key.to_owned(), line)?;
382 }
383 Ok(())
384}
385
386fn parse_yaml(
387 input: &str,
388 collector: &mut KeyCollector,
389 warnings: &mut Vec<String>,
390) -> Result<(), ConfigExtractionError> {
391 if let Err(error) = crate::yaml::from_multiple::<IgnoredAny>(input) {
392 let location = error.location();
393 return Err(malformed(
394 ConfigArtifactKind::Yaml,
395 location.map_or(1, |location| {
396 usize::try_from(location.line()).unwrap_or(usize::MAX)
397 }),
398 location.map_or(1, |location| {
399 usize::try_from(location.column()).unwrap_or(usize::MAX)
400 }),
401 ));
402 }
403
404 let mut stack: Vec<(usize, String)> = Vec::new();
405 let mut block_scalar_indent = None;
406 for (index, source_line) in input.lines().enumerate() {
407 let line = index + 1;
408 let indentation = source_line.bytes().take_while(|byte| *byte == b' ').count();
409 let trimmed = source_line.trim();
410 if let Some(parent_indent) = block_scalar_indent {
411 if trimmed.is_empty() || indentation > parent_indent {
412 continue;
413 }
414 block_scalar_indent = None;
415 }
416 if trimmed.is_empty()
417 || trimmed.starts_with('#')
418 || matches!(trimmed, "---" | "...")
419 || trimmed.starts_with('%')
420 {
421 if trimmed == "---" {
422 stack.clear();
423 }
424 continue;
425 }
426 if source_line
427 .bytes()
428 .take_while(u8::is_ascii_whitespace)
429 .any(|byte| byte == b'\t')
430 {
431 return Err(malformed(ConfigArtifactKind::Yaml, line, 1));
432 }
433
434 let (effective_indent, candidate) = if let Some(rest) = trimmed.strip_prefix("- ") {
435 (indentation.saturating_add(2), rest.trim_start())
436 } else if trimmed == "-" {
437 continue;
438 } else {
439 (indentation, trimmed)
440 };
441 while stack
442 .last()
443 .is_some_and(|(parent_indent, _)| *parent_indent >= effective_indent)
444 {
445 stack.pop();
446 }
447 if candidate.starts_with(['?', '{', '[']) {
448 push_warning(warnings, PARTIAL_YAML_WARNING);
449 continue;
450 }
451 let Some(colon) = find_unquoted(candidate, b':') else {
452 continue;
453 };
454 let key_token = candidate[..colon].trim();
455 if key_token.is_empty() {
456 return Err(malformed(ConfigArtifactKind::Yaml, line, indentation + 1));
457 }
458 let Some(key) = parse_yaml_key(key_token) else {
459 push_warning(warnings, PARTIAL_YAML_WARNING);
460 continue;
461 };
462 let scope = stack
463 .iter()
464 .map(|(_, name)| name.clone())
465 .collect::<Vec<_>>();
466 check_depth(scope.len().saturating_add(1), line)?;
467 collector.insert(&scope, key.clone(), line)?;
468
469 let value = strip_yaml_comment(candidate[colon + 1..].trim());
470 if value.is_empty() {
471 stack.push((effective_indent, key));
472 } else if value.starts_with(['|', '>']) {
473 block_scalar_indent = Some(effective_indent);
474 } else if value.starts_with(['{', '[']) || value.starts_with(['&', '*', '!']) {
475 push_warning(warnings, PARTIAL_YAML_WARNING);
476 }
477 }
478 Ok(())
479}
480
481fn parse_json(input: &str, collector: &mut KeyCollector) -> Result<(), ConfigExtractionError> {
482 let mut parser = JsonKeyParser {
483 input,
484 position: 0,
485 line: 1,
486 collector,
487 };
488 parser.parse_value(&[], 0)?;
489 parser.skip_whitespace();
490 if parser.position != input.len() {
491 return Err(parser.error());
492 }
493 if let Err(error) = serde_json::from_str::<IgnoredAny>(input) {
494 return Err(malformed(
495 ConfigArtifactKind::Json,
496 error.line(),
497 error.column(),
498 ));
499 }
500 Ok(())
501}
502
503struct JsonKeyParser<'a, 'b> {
504 input: &'a str,
505 position: usize,
506 line: usize,
507 collector: &'b mut KeyCollector,
508}
509
510impl JsonKeyParser<'_, '_> {
511 fn parse_value(&mut self, scope: &[String], depth: usize) -> Result<(), ConfigExtractionError> {
512 self.skip_whitespace();
513 check_depth(depth, self.line)?;
514 match self.current_byte() {
515 Some(b'{') => self.parse_object(scope, depth),
516 Some(b'[') => self.parse_array(scope, depth),
517 Some(b'"') => self.skip_string(),
518 Some(_) => self.skip_scalar(),
519 None => Err(self.error()),
520 }
521 }
522
523 fn parse_object(
524 &mut self,
525 scope: &[String],
526 depth: usize,
527 ) -> Result<(), ConfigExtractionError> {
528 self.advance();
529 self.skip_whitespace();
530 if self.consume(b'}') {
531 return Ok(());
532 }
533 loop {
534 self.skip_whitespace();
535 let key_line = self.line;
536 let key = self.parse_key_string()?;
537 self.skip_whitespace();
538 if !self.consume(b':') {
539 return Err(self.error());
540 }
541 self.collector.insert(scope, key.clone(), key_line)?;
542 let mut nested_scope = scope.to_vec();
543 nested_scope.push(key);
544 self.parse_value(&nested_scope, depth.saturating_add(1))?;
545 self.skip_whitespace();
546 if self.consume(b'}') {
547 return Ok(());
548 }
549 if !self.consume(b',') {
550 return Err(self.error());
551 }
552 }
553 }
554
555 fn parse_array(&mut self, scope: &[String], depth: usize) -> Result<(), ConfigExtractionError> {
556 self.advance();
557 self.skip_whitespace();
558 if self.consume(b']') {
559 return Ok(());
560 }
561 loop {
562 self.parse_value(scope, depth.saturating_add(1))?;
563 self.skip_whitespace();
564 if self.consume(b']') {
565 return Ok(());
566 }
567 if !self.consume(b',') {
568 return Err(self.error());
569 }
570 }
571 }
572
573 fn parse_key_string(&mut self) -> Result<String, ConfigExtractionError> {
574 if self.current_byte() != Some(b'"') {
575 return Err(self.error());
576 }
577 let start = self.position;
578 self.skip_string()?;
579 serde_json::from_str(&self.input[start..self.position]).map_err(|error| {
580 malformed(
581 ConfigArtifactKind::Json,
582 error.line().saturating_add(self.line.saturating_sub(1)),
583 error.column(),
584 )
585 })
586 }
587
588 fn skip_string(&mut self) -> Result<(), ConfigExtractionError> {
589 if !self.consume(b'"') {
590 return Err(self.error());
591 }
592 let mut escaped = false;
593 while let Some(byte) = self.current_byte() {
594 self.advance();
595 if escaped {
596 escaped = false;
597 } else if byte == b'\\' {
598 escaped = true;
599 } else if byte == b'"' {
600 return Ok(());
601 }
602 }
603 Err(self.error())
604 }
605
606 fn skip_scalar(&mut self) -> Result<(), ConfigExtractionError> {
607 let start = self.position;
608 while let Some(byte) = self.current_byte() {
609 if byte.is_ascii_whitespace() || b",]}".contains(&byte) {
610 break;
611 }
612 self.advance();
613 }
614 if self.position == start {
615 Err(self.error())
616 } else {
617 Ok(())
618 }
619 }
620
621 fn skip_whitespace(&mut self) {
622 while self
623 .current_byte()
624 .is_some_and(|byte| byte.is_ascii_whitespace())
625 {
626 self.advance();
627 }
628 }
629
630 fn consume(&mut self, expected: u8) -> bool {
631 if self.current_byte() == Some(expected) {
632 self.advance();
633 true
634 } else {
635 false
636 }
637 }
638
639 fn current_byte(&self) -> Option<u8> {
640 self.input.as_bytes().get(self.position).copied()
641 }
642
643 fn advance(&mut self) {
644 if self.current_byte() == Some(b'\n') {
645 self.line = self.line.saturating_add(1);
646 }
647 self.position = self.position.saturating_add(1);
648 }
649
650 fn error(&self) -> ConfigExtractionError {
651 let line_start = self.input[..self.position.min(self.input.len())]
652 .rfind('\n')
653 .map_or(0, |position| position + 1);
654 malformed(
655 ConfigArtifactKind::Json,
656 self.line,
657 self.position.saturating_sub(line_start).saturating_add(1),
658 )
659 }
660}
661
662fn parse_toml(
663 input: &str,
664 collector: &mut KeyCollector,
665 warnings: &mut Vec<String>,
666) -> Result<(), ConfigExtractionError> {
667 let mut current_scope = Vec::new();
668 let mut value_state: Option<TomlValueState> = None;
669 for (index, source_line) in input.lines().enumerate() {
670 let line = index + 1;
671 if let Some(state) = value_state.as_mut() {
672 state.scan(source_line);
673 if state.is_complete() {
674 value_state = None;
675 }
676 continue;
677 }
678
679 let text = strip_toml_comment(source_line).trim();
680 if text.is_empty() {
681 continue;
682 }
683 if text.starts_with('[') {
684 let array_table = text.starts_with("[[");
685 let (open_len, close) = if array_table { (2, "]]") } else { (1, "]") };
686 let Some(inner) = text
687 .strip_prefix(&text[..open_len])
688 .and_then(|rest| rest.strip_suffix(close))
689 .map(str::trim)
690 else {
691 return Err(malformed(ConfigArtifactKind::Toml, line, 1));
692 };
693 let path = parse_toml_key_path(inner)
694 .ok_or_else(|| malformed(ConfigArtifactKind::Toml, line, 1))?;
695 check_depth(path.len(), line)?;
696 collector.insert_path(&[], &path, line)?;
697 current_scope = path;
698 continue;
699 }
700
701 let Some(equals) = find_unquoted(text, b'=') else {
702 return Err(malformed(ConfigArtifactKind::Toml, line, 1));
703 };
704 let key_text = text[..equals].trim();
705 let path = parse_toml_key_path(key_text)
706 .ok_or_else(|| malformed(ConfigArtifactKind::Toml, line, 1))?;
707 check_depth(current_scope.len().saturating_add(path.len()), line)?;
708 collector.insert_path(¤t_scope, &path, line)?;
709
710 let value = text[equals + 1..].trim_start();
711 if value.is_empty() {
712 return Err(malformed(
713 ConfigArtifactKind::Toml,
714 line,
715 equals.saturating_add(2),
716 ));
717 }
718 if value.starts_with('{') {
719 push_warning(warnings, PARTIAL_TOML_WARNING);
720 }
721 let mut state = TomlValueState::default();
722 state.scan(value);
723 if state.invalid || state.unclosed_single_line_string {
724 return Err(malformed(
725 ConfigArtifactKind::Toml,
726 line,
727 equals.saturating_add(2),
728 ));
729 }
730 if !state.is_complete() {
731 value_state = Some(state);
732 }
733 }
734 if value_state.is_some() {
735 return Err(malformed(
736 ConfigArtifactKind::Toml,
737 input.lines().count().max(1),
738 1,
739 ));
740 }
741 Ok(())
742}
743
744#[derive(Debug, Clone, Copy, PartialEq, Eq)]
745enum TomlQuote {
746 Basic,
747 Literal,
748 MultiBasic,
749 MultiLiteral,
750}
751
752#[derive(Default)]
753struct TomlValueState {
754 quote: Option<TomlQuote>,
755 escaped: bool,
756 square_depth: usize,
757 curly_depth: usize,
758 invalid: bool,
759 unclosed_single_line_string: bool,
760}
761
762impl TomlValueState {
763 fn scan(&mut self, text: &str) {
764 self.unclosed_single_line_string = false;
765 let bytes = text.as_bytes();
766 let mut position = 0;
767 while position < bytes.len() {
768 match self.quote {
769 Some(TomlQuote::MultiBasic) => {
770 if bytes[position..].starts_with(b"\"\"\"") && !self.escaped {
771 self.quote = None;
772 position += 3;
773 continue;
774 }
775 self.escaped = bytes[position] == b'\\' && !self.escaped;
776 if bytes[position] != b'\\' {
777 self.escaped = false;
778 }
779 }
780 Some(TomlQuote::MultiLiteral) => {
781 if bytes[position..].starts_with(b"'''") {
782 self.quote = None;
783 position += 3;
784 continue;
785 }
786 }
787 Some(TomlQuote::Basic) => {
788 if bytes[position] == b'"' && !self.escaped {
789 self.quote = None;
790 }
791 self.escaped = bytes[position] == b'\\' && !self.escaped;
792 if bytes[position] != b'\\' {
793 self.escaped = false;
794 }
795 }
796 Some(TomlQuote::Literal) => {
797 if bytes[position] == b'\'' {
798 self.quote = None;
799 }
800 }
801 None => {
802 if bytes[position..].starts_with(b"\"\"\"") {
803 self.quote = Some(TomlQuote::MultiBasic);
804 position += 3;
805 continue;
806 }
807 if bytes[position..].starts_with(b"'''") {
808 self.quote = Some(TomlQuote::MultiLiteral);
809 position += 3;
810 continue;
811 }
812 match bytes[position] {
813 b'"' => self.quote = Some(TomlQuote::Basic),
814 b'\'' => self.quote = Some(TomlQuote::Literal),
815 b'[' => self.square_depth = self.square_depth.saturating_add(1),
816 b']' => {
817 let Some(depth) = self.square_depth.checked_sub(1) else {
818 self.invalid = true;
819 return;
820 };
821 self.square_depth = depth;
822 }
823 b'{' => self.curly_depth = self.curly_depth.saturating_add(1),
824 b'}' => {
825 let Some(depth) = self.curly_depth.checked_sub(1) else {
826 self.invalid = true;
827 return;
828 };
829 self.curly_depth = depth;
830 }
831 b'#' => break,
832 _ => {}
833 }
834 }
835 }
836 position += 1;
837 }
838 if matches!(self.quote, Some(TomlQuote::Basic | TomlQuote::Literal)) {
839 self.unclosed_single_line_string = true;
840 }
841 self.escaped = false;
842 }
843
844 fn is_complete(&self) -> bool {
845 self.quote.is_none() && self.square_depth == 0 && self.curly_depth == 0
846 }
847}
848
849fn parse_toml_key_path(input: &str) -> Option<Vec<String>> {
850 let mut parts = Vec::new();
851 let mut start = 0;
852 let mut quote = None;
853 let mut escaped = false;
854 for (position, byte) in input.bytes().enumerate() {
855 match quote {
856 Some(b'"') => {
857 if byte == b'"' && !escaped {
858 quote = None;
859 }
860 escaped = byte == b'\\' && !escaped;
861 if byte != b'\\' {
862 escaped = false;
863 }
864 }
865 Some(b'\'') => {
866 if byte == b'\'' {
867 quote = None;
868 }
869 }
870 Some(_) => return None,
871 None if matches!(byte, b'"' | b'\'') => quote = Some(byte),
872 None if byte == b'.' => {
873 parts.push(parse_toml_key_part(input[start..position].trim())?);
874 start = position + 1;
875 }
876 None => {}
877 }
878 }
879 if quote.is_some() {
880 return None;
881 }
882 parts.push(parse_toml_key_part(input[start..].trim())?);
883 (!parts.is_empty()).then_some(parts)
884}
885
886fn parse_toml_key_part(input: &str) -> Option<String> {
887 if input.len() >= 2 && input.starts_with('"') && input.ends_with('"') {
888 return parse_toml_basic_quoted(&input[1..input.len() - 1]);
889 }
890 if input.len() >= 2 && input.starts_with('\'') && input.ends_with('\'') {
891 return Some(input[1..input.len() - 1].to_owned());
892 }
893 (!input.is_empty()
894 && input
895 .bytes()
896 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')))
897 .then(|| input.to_owned())
898}
899
900fn parse_toml_basic_quoted(input: &str) -> Option<String> {
901 let mut decoded = String::new();
902 let mut chars = input.chars();
903 while let Some(character) = chars.next() {
904 if character != '\\' {
905 decoded.push(character);
906 continue;
907 }
908 let escaped = chars.next()?;
909 match escaped {
910 '"' | '\\' => decoded.push(escaped),
911 'b' => decoded.push('\u{0008}'),
912 't' => decoded.push('\t'),
913 'n' => decoded.push('\n'),
914 'f' => decoded.push('\u{000c}'),
915 'r' => decoded.push('\r'),
916 _ => return None,
917 }
918 }
919 Some(decoded)
920}
921
922fn parse_yaml_key(input: &str) -> Option<String> {
923 if input.starts_with(['"', '\'']) {
924 crate::yaml::from_str::<String>(input).ok()
925 } else if input.contains(['[', ']', '{', '}', ',', '&', '*', '!']) {
926 None
927 } else {
928 Some(input.to_owned())
929 }
930}
931
932fn strip_yaml_comment(input: &str) -> &str {
933 let mut quote = None;
934 let mut escaped = false;
935 for (position, byte) in input.bytes().enumerate() {
936 match quote {
937 Some(b'"') => {
938 if byte == b'"' && !escaped {
939 quote = None;
940 }
941 escaped = byte == b'\\' && !escaped;
942 if byte != b'\\' {
943 escaped = false;
944 }
945 }
946 Some(b'\'') => {
947 if byte == b'\'' {
948 quote = None;
949 }
950 }
951 None if matches!(byte, b'"' | b'\'') => quote = Some(byte),
952 None if byte == b'#'
953 && (position == 0
954 || input.as_bytes()[position.saturating_sub(1)].is_ascii_whitespace()) =>
955 {
956 return input[..position].trim_end();
957 }
958 Some(_) | None => {}
959 }
960 }
961 input.trim_end()
962}
963
964fn strip_toml_comment(input: &str) -> &str {
965 let mut quote = None;
966 let mut escaped = false;
967 for (position, byte) in input.bytes().enumerate() {
968 match quote {
969 Some(b'"') => {
970 if byte == b'"' && !escaped {
971 quote = None;
972 }
973 escaped = byte == b'\\' && !escaped;
974 if byte != b'\\' {
975 escaped = false;
976 }
977 }
978 Some(b'\'') => {
979 if byte == b'\'' {
980 quote = None;
981 }
982 }
983 None if matches!(byte, b'"' | b'\'') => quote = Some(byte),
984 None if byte == b'#' => return &input[..position],
985 Some(_) | None => {}
986 }
987 }
988 input
989}
990
991fn find_unquoted(input: &str, needle: u8) -> Option<usize> {
992 let mut quote = None;
993 let mut escaped = false;
994 for (position, byte) in input.bytes().enumerate() {
995 match quote {
996 Some(b'"') => {
997 if byte == b'"' && !escaped {
998 quote = None;
999 }
1000 escaped = byte == b'\\' && !escaped;
1001 if byte != b'\\' {
1002 escaped = false;
1003 }
1004 }
1005 Some(b'\'') => {
1006 if byte == b'\'' {
1007 quote = None;
1008 }
1009 }
1010 None if matches!(byte, b'"' | b'\'') => quote = Some(byte),
1011 None if byte == needle => return Some(position),
1012 Some(_) | None => {}
1013 }
1014 }
1015 None
1016}
1017
1018fn normalized_words(name: &str) -> Vec<String> {
1019 let mut normalized = String::with_capacity(name.len());
1020 let mut previous_lower_or_digit = false;
1021 for character in name.chars() {
1022 if character.is_ascii_alphanumeric() {
1023 if character.is_ascii_uppercase() && previous_lower_or_digit {
1024 normalized.push(' ');
1025 }
1026 normalized.push(character.to_ascii_lowercase());
1027 previous_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit();
1028 } else {
1029 normalized.push(' ');
1030 previous_lower_or_digit = false;
1031 }
1032 }
1033 normalized.split_whitespace().map(str::to_owned).collect()
1034}
1035
1036fn is_dotenv_key(key: &str) -> bool {
1037 let mut bytes = key.bytes();
1038 bytes
1039 .next()
1040 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
1041 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
1042}
1043
1044fn is_reference_identifier(value: &str) -> bool {
1045 let mut bytes = value.bytes();
1046 bytes
1047 .next()
1048 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
1049 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
1050}
1051
1052fn uri_contains_userinfo(value: &str) -> bool {
1053 let Some((scheme, remainder)) = value.split_once("://") else {
1054 return false;
1055 };
1056 if scheme.is_empty()
1057 || !scheme
1058 .bytes()
1059 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.'))
1060 {
1061 return false;
1062 }
1063 remainder
1064 .split(['/', '?', '#'])
1065 .next()
1066 .is_some_and(|authority| authority.contains('@'))
1067}
1068
1069fn looks_like_secret_literal(value: &str) -> bool {
1070 let lower = value.to_ascii_lowercase();
1071 if lower.contains("-----begin") && lower.contains("private key-----") {
1072 return true;
1073 }
1074 if [
1075 "ghp_",
1076 "github_pat_",
1077 "sk-",
1078 "xoxb-",
1079 "xoxp-",
1080 "akia",
1081 "asia",
1082 ]
1083 .iter()
1084 .any(|prefix| lower.starts_with(prefix))
1085 {
1086 return true;
1087 }
1088 let jwt_parts = value.split('.').collect::<Vec<_>>();
1089 if jwt_parts.len() == 3
1090 && jwt_parts.iter().all(|part| {
1091 part.len() >= 8
1092 && part
1093 .bytes()
1094 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'='))
1095 })
1096 {
1097 return true;
1098 }
1099 value.len() >= 32
1100 && value.bytes().all(|byte| {
1101 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'+' | b'/' | b'=')
1102 })
1103 && value.bytes().any(|byte| byte.is_ascii_lowercase())
1104 && value.bytes().any(|byte| byte.is_ascii_uppercase())
1105 && value.bytes().any(|byte| byte.is_ascii_digit())
1106}
1107
1108fn is_absolute_secret_path(value: &str) -> bool {
1109 (value.starts_with("/run/secrets/")
1110 || value.starts_with("/var/run/secrets/")
1111 || value.starts_with("/etc/secrets/"))
1112 && value.len()
1113 > value
1114 .find("/secrets/")
1115 .map_or(usize::MAX, |index| index + 9)
1116 && !value.contains(['\0', '\n', '\r'])
1117}
1118
1119fn check_depth(depth: usize, line: usize) -> Result<(), ConfigExtractionError> {
1120 if depth > MAX_DEPTH {
1121 Err(ConfigExtractionError::DepthLimitExceeded {
1122 line,
1123 maximum: MAX_DEPTH,
1124 })
1125 } else {
1126 Ok(())
1127 }
1128}
1129
1130fn malformed(
1131 artifact_kind: ConfigArtifactKind,
1132 line: usize,
1133 column: usize,
1134) -> ConfigExtractionError {
1135 ConfigExtractionError::Malformed {
1136 artifact_kind,
1137 line: line.max(1),
1138 column: column.max(1),
1139 }
1140}
1141
1142fn push_warning(warnings: &mut Vec<String>, warning: &'static str) {
1143 if !warnings.iter().any(|existing| existing == warning) {
1144 warnings.push(warning.to_owned());
1145 }
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use super::{
1151 ConfigArtifactKind, ConfigExtractionError, SensitiveKeyKind, classify_sensitive_key, extract_safe_config, is_safe_literal_reference
1152 };
1153
1154 const FIXTURE_SECRETS: [&str; 4] = [
1155 "dotenv-super-secret-9f47",
1156 "yaml-password-never-retain-61d2",
1157 "json-token-never-retain-a830",
1158 "toml-private-key-never-retain-77bc",
1159 ];
1160
1161 #[test]
1162 fn extracts_dotenv_names_without_values() {
1163 let input = format!(
1164 "PUBLIC_NAME=mesh\nAPI_TOKEN={}\nAPI_TOKEN=duplicate\n",
1165 FIXTURE_SECRETS[0]
1166 );
1167 let document = extract_safe_config(".env.production", &input).expect("valid dotenv");
1168
1169 assert_eq!(
1170 document
1171 .keys
1172 .iter()
1173 .map(|key| (&key.scope, key.name.as_str(), key.line, key.sensitive_kind))
1174 .collect::<Vec<_>>(),
1175 vec![
1176 (&Vec::new(), "API_TOKEN", 2, Some(SensitiveKeyKind::Token)),
1177 (&Vec::new(), "PUBLIC_NAME", 1, None),
1178 ]
1179 );
1180 }
1181
1182 #[test]
1183 fn extracts_nested_yaml_paths_without_values() {
1184 let input = format!(
1185 "service:\n replicas: 2\n database:\n password: {}\n",
1186 FIXTURE_SECRETS[1]
1187 );
1188 let document = extract_safe_config("deploy.yaml", &input).expect("valid YAML");
1189
1190 assert!(document.keys.iter().any(|key| {
1191 key.scope == ["service", "database"]
1192 && key.name == "password"
1193 && key.sensitive_kind == Some(SensitiveKeyKind::Password)
1194 }));
1195 }
1196
1197 #[test]
1198 fn extracts_nested_json_paths_without_values() {
1199 let input = format!(
1200 r#"{{"service":{{"token":"{}","port":8080}}}}"#,
1201 FIXTURE_SECRETS[2]
1202 );
1203 let document = extract_safe_config("app.json", &input).expect("valid JSON");
1204
1205 assert!(document.keys.iter().any(|key| {
1206 key.scope == ["service"]
1207 && key.name == "token"
1208 && key.sensitive_kind == Some(SensitiveKeyKind::Token)
1209 }));
1210 }
1211
1212 #[test]
1213 fn extracts_nested_toml_paths_without_values() {
1214 let input = format!(
1215 "[service.database]\nprivate_key = \"{}\"\nport = 5432\n",
1216 FIXTURE_SECRETS[3]
1217 );
1218 let document = extract_safe_config("settings.toml", &input).expect("valid TOML");
1219
1220 assert!(document.keys.iter().any(|key| {
1221 key.scope == ["service", "database"]
1222 && key.name == "private_key"
1223 && key.sensitive_kind == Some(SensitiveKeyKind::PrivateKey)
1224 }));
1225 }
1226
1227 #[test]
1228 fn serialized_and_debug_documents_never_contain_fixture_values() {
1229 let fixtures = [
1230 (".env", format!("TOKEN={}\n", FIXTURE_SECRETS[0])),
1231 ("secrets.yml", format!("password: {}\n", FIXTURE_SECRETS[1])),
1232 (
1233 "secrets.json",
1234 format!(r#"{{"token":"{}"}}"#, FIXTURE_SECRETS[2]),
1235 ),
1236 (
1237 "secrets.toml",
1238 format!("private_key = \"{}\"\n", FIXTURE_SECRETS[3]),
1239 ),
1240 ];
1241 let mut representations = Vec::new();
1242 for (path, input) in &fixtures {
1243 let document = extract_safe_config(path, input).expect("valid configuration");
1244 representations.push(serde_json::to_string(&document).expect("serialize JSON"));
1245 representations.push(serde_saphyr::to_string(&document).expect("serialize YAML"));
1246 representations.push(format!("{document:?}"));
1247 }
1248 let representations = representations.join("\n");
1249
1250 assert!(
1251 FIXTURE_SECRETS
1252 .iter()
1253 .all(|secret| !representations.contains(secret))
1254 );
1255 }
1256
1257 #[test]
1258 fn serialized_display_and_debug_errors_never_contain_fixture_values() {
1259 let fixtures = [
1260 (".env", format!("INVALID {}\n", FIXTURE_SECRETS[0])),
1261 (
1262 "secrets.yml",
1263 format!("password: [{}\n", FIXTURE_SECRETS[1]),
1264 ),
1265 (
1266 "secrets.json",
1267 format!("{{\"token\":\"{}\",", FIXTURE_SECRETS[2]),
1268 ),
1269 (
1270 "secrets.toml",
1271 format!("private_key = \"{}\n", FIXTURE_SECRETS[3]),
1272 ),
1273 ];
1274 let mut representations = Vec::new();
1275 for (path, input) in &fixtures {
1276 let error = extract_safe_config(path, input).expect_err("malformed configuration");
1277 representations.push(serde_json::to_string(&error).expect("serialize error"));
1278 representations.push(serde_saphyr::to_string(&error).expect("serialize error"));
1279 representations.push(format!("{error:?}"));
1280 representations.push(error.to_string());
1281 }
1282 let representations = representations.join("\n");
1283
1284 assert!(
1285 FIXTURE_SECRETS
1286 .iter()
1287 .all(|secret| !representations.contains(secret))
1288 );
1289 }
1290
1291 #[test]
1292 fn malformed_errors_are_value_free_and_located() {
1293 let error =
1294 extract_safe_config("broken.json", "{\"password\":}").expect_err("malformed JSON");
1295
1296 assert!(matches!(
1297 error,
1298 ConfigExtractionError::Malformed {
1299 artifact_kind: ConfigArtifactKind::Json,
1300 line: 1,
1301 column: _
1302 }
1303 ));
1304 }
1305
1306 #[test]
1307 fn fixed_input_and_depth_limits_fail_closed() {
1308 let oversized = "x".repeat(super::MAX_INPUT_BYTES + 1);
1309 let deeply_nested = format!(
1310 "{}0{}",
1311 "[".repeat(super::MAX_DEPTH + 1),
1312 "]".repeat(super::MAX_DEPTH + 1)
1313 );
1314
1315 assert!(matches!(
1316 extract_safe_config(".env", &oversized),
1317 Err(ConfigExtractionError::InputTooLarge { .. })
1318 ));
1319 assert!(matches!(
1320 extract_safe_config("deep.json", &deeply_nested),
1321 Err(ConfigExtractionError::DepthLimitExceeded { .. })
1322 ));
1323 }
1324
1325 #[test]
1326 fn classification_recognizes_required_sensitive_styles() {
1327 let cases = [
1328 ("refreshToken", SensitiveKeyKind::Token),
1329 ("db-password", SensitiveKeyKind::Password),
1330 ("client_secret", SensitiveKeyKind::Secret),
1331 ("sshPrivateKey", SensitiveKeyKind::PrivateKey),
1332 ("service_credentials", SensitiveKeyKind::Credential),
1333 ("AWS_ACCESS_KEY_ID", SensitiveKeyKind::AccessKey),
1334 ("connectionString", SensitiveKeyKind::ConnectionString),
1335 ("DATABASE_URL", SensitiveKeyKind::DatabaseUrl),
1336 ];
1337
1338 assert!(
1339 cases
1340 .iter()
1341 .all(|(name, expected)| classify_sensitive_key(name) == Some(*expected))
1342 );
1343 }
1344
1345 #[test]
1346 fn literal_reference_accepts_only_narrow_external_references() {
1347 assert!(is_safe_literal_reference("${DATABASE_PASSWORD}"));
1348 assert!(is_safe_literal_reference(
1349 "vault://applications/code-system-graph#database"
1350 ));
1351 assert!(is_safe_literal_reference("/run/secrets/database_password"));
1352 }
1353
1354 #[test]
1355 fn literal_reference_rejects_uri_userinfo_and_secret_literals() {
1356 let values = [
1357 "postgres://admin:password@database.example/app",
1358 concat!("ghp_", "012345678901234567890123456789012345"),
1359 concat!(
1360 "eyJhbGciOiJIUzI1NiJ9",
1361 ".",
1362 "eyJzdWIiOiIxMjM0NTY3ODkwIn0",
1363 ".",
1364 "signature123"
1365 ),
1366 "ordinary-literal",
1367 ];
1368
1369 assert!(values.iter().all(|value| !is_safe_literal_reference(value)));
1370 }
1371}