Skip to main content

code_system_graph_core/
secret_safety.rs

1//! Secret-safe extraction of configuration key names and nested key paths.
2
3use 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/// Supported secret-safe configuration artifact format.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ConfigArtifactKind {
30    /// A dotenv assignment file.
31    Dotenv,
32    /// A YAML mapping document.
33    Yaml,
34    /// A JSON object or array document.
35    Json,
36    /// A TOML table document.
37    Toml,
38}
39
40/// Semantic category assigned to a configuration key whose name suggests sensitive data.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum SensitiveKeyKind {
44    /// Authentication or authorization token material.
45    Token,
46    /// A password, passphrase, or abbreviated password field.
47    Password,
48    /// Generic secret material.
49    Secret,
50    /// A private signing, SSH, or TLS key.
51    PrivateKey,
52    /// Credentials or abbreviated credential material.
53    Credential,
54    /// An access key or API key.
55    AccessKey,
56    /// A connection string.
57    ConnectionString,
58    /// A database URL or equivalent database locator.
59    DatabaseUrl,
60}
61
62/// One configuration key observation containing no configuration value.
63#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
64pub struct SafeConfigKey {
65    /// Parent key path, ordered from the document root to the immediate parent.
66    pub scope: Vec<String>,
67    /// Unqualified key name.
68    pub name: String,
69    /// One-based source line where the key is declared.
70    pub line: usize,
71    /// Sensitive-name classification, when applicable.
72    pub sensitive_kind: Option<SensitiveKeyKind>,
73}
74
75/// Owned, serializable configuration metadata that never contains configuration values.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct SafeConfigDocument {
78    /// Caller-supplied source path.
79    pub source_path: String,
80    /// Format selected from the source path.
81    pub artifact_kind: ConfigArtifactKind,
82    /// Deduplicated keys in deterministic path order.
83    pub keys: Vec<SafeConfigKey>,
84    /// Bounded, value-free extraction diagnostics.
85    pub warnings: Vec<String>,
86    /// Whether unsupported constructs prevented complete key-path extraction.
87    pub incomplete: bool,
88}
89
90/// Value-free failure returned by secret-safe configuration extraction.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
92#[serde(tag = "kind", rename_all = "snake_case")]
93pub enum ConfigExtractionError {
94    /// The source path does not identify a supported configuration format.
95    #[error("unsupported configuration artifact")]
96    UnsupportedArtifact,
97    /// Input exceeds the fixed extraction budget.
98    #[error("configuration input is {actual} bytes; maximum is {maximum}")]
99    InputTooLarge {
100        /// Observed input size.
101        actual: usize,
102        /// Fixed maximum input size.
103        maximum: usize,
104    },
105    /// Nesting exceeds the fixed extraction budget.
106    #[error("configuration nesting at line {line} exceeds maximum depth {maximum}")]
107    DepthLimitExceeded {
108        /// One-based line where the limit was exceeded.
109        line: usize,
110        /// Fixed maximum nesting depth.
111        maximum: usize,
112    },
113    /// The document contains more unique keys than the fixed extraction budget.
114    #[error("configuration key count exceeds maximum {maximum}")]
115    KeyLimitExceeded {
116        /// Fixed maximum unique-key count.
117        maximum: usize,
118    },
119    /// A key name exceeds the fixed extraction budget.
120    #[error("configuration key at line {line} exceeds maximum length {maximum}")]
121    KeyTooLong {
122        /// One-based line containing the key.
123        line: usize,
124        /// Fixed maximum key length in bytes.
125        maximum: usize,
126    },
127    /// The input is malformed; source content is intentionally omitted.
128    #[error("malformed {artifact_kind:?} configuration at line {line}, column {column}")]
129    Malformed {
130        /// Format whose parser rejected the input.
131        artifact_kind: ConfigArtifactKind,
132        /// One-based line nearest the malformed construct.
133        line: usize,
134        /// One-based column nearest the malformed construct.
135        column: usize,
136    },
137}
138
139/// Extracts only configuration key names, nested scopes, and value-free diagnostics.
140///
141/// The source path selects dotenv, YAML, JSON, or TOML parsing. Keys are deduplicated by
142/// `(scope, name)`, retain their earliest declaration line, and are returned in deterministic
143/// path order.
144///
145/// # Errors
146///
147/// Returns [`ConfigExtractionError`] for unsupported paths, malformed input, or a fixed size,
148/// depth, key-count, or key-length budget violation.
149pub 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/// Classifies a key name using separator-aware and camel-case-aware sensitive-name patterns.
183#[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/// Returns whether a literal is a narrowly recognized external secret reference.
227///
228/// Literal credentials, token-shaped strings, generic URLs, and every URI containing userinfo
229/// are rejected. Supported references are environment variables, selected secret-provider URIs,
230/// and absolute secret-file paths.
231#[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(&current_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}