@@ -378,32 +378,84 @@ impl ConfigInfo {
378378 info. config_path = Some ( config_path. display ( ) . to_string ( ) ) ;
379379
380380 if let Ok ( content) = fs:: read_to_string ( & config_path) {
381- let mut redacted = String :: new ( ) ;
382- for line in content. lines ( ) {
383- let trimmed = line. trim ( ) ;
384- if trimmed. is_empty ( ) || trimmed. starts_with ( '#' ) || trimmed. starts_with ( ';' ) {
385- continue ;
386- }
387- if trimmed. to_lowercase ( ) . contains ( "api_key" )
388- || trimmed. to_lowercase ( ) . contains ( "secret" )
389- || trimmed. to_lowercase ( ) . contains ( "token" )
390- {
391- if let Some ( key) = trimmed. split ( '=' ) . next ( ) {
392- redacted. push_str ( & format ! ( "{} = [REDACTED]\n " , key. trim( ) ) ) ;
393- }
394- } else {
395- redacted. push_str ( line) ;
396- redacted. push ( '\n' ) ;
397- }
398- }
399- info. config_contents = Some ( redacted) ;
381+ info. config_contents = Some ( redact_sensitive_ini ( & content) ) ;
400382 }
401383 }
402384
403385 info
404386 }
405387}
406388
389+ /// Track the current INI section while walking lines so we can construct
390+ /// `section.key` names and look them up via [`ConfigKey`].
391+ fn parse_section_header ( line : & str ) -> Option < & str > {
392+ let trimmed = line. trim ( ) ;
393+ if trimmed. starts_with ( '[' ) && trimmed. ends_with ( ']' ) && trimmed. len ( ) >= 2 {
394+ Some ( & trimmed[ 1 ..trimmed. len ( ) - 1 ] )
395+ } else {
396+ None
397+ }
398+ }
399+
400+ /// Redact sensitive credential values from raw INI text by checking each
401+ /// `key = value` line against [`ConfigKey::is_sensitive`] and substituting
402+ /// [`SENSITIVE_VALUE_MASK`] for the value.
403+ ///
404+ /// Section headers, comments, blank lines, non-sensitive entries, and
405+ /// entries with empty values are preserved verbatim — empty values aren't
406+ /// secrets and the absence is informative when reading a diagnostics
407+ /// dump. See issue #162 for why we drive both this and `config list`
408+ /// from the same `is_sensitive` predicate.
409+ fn redact_sensitive_ini ( content : & str ) -> String {
410+ use crate :: config:: { ConfigKey , SENSITIVE_VALUE_MASK } ;
411+
412+ let mut out = String :: with_capacity ( content. len ( ) ) ;
413+ let mut current_section: Option < String > = None ;
414+
415+ for line in content. lines ( ) {
416+ if let Some ( section) = parse_section_header ( line) {
417+ current_section = Some ( section. to_string ( ) ) ;
418+ out. push_str ( line) ;
419+ out. push ( '\n' ) ;
420+ continue ;
421+ }
422+
423+ let trimmed = line. trim ( ) ;
424+ if trimmed. is_empty ( ) || trimmed. starts_with ( '#' ) || trimmed. starts_with ( ';' ) {
425+ out. push_str ( line) ;
426+ out. push ( '\n' ) ;
427+ continue ;
428+ }
429+
430+ let Some ( eq_idx) = line. find ( '=' ) else {
431+ out. push_str ( line) ;
432+ out. push ( '\n' ) ;
433+ continue ;
434+ } ;
435+
436+ let key_part = line[ ..eq_idx] . trim ( ) ;
437+ let value_part = line[ eq_idx + 1 ..] . trim ( ) ;
438+ let qualified = match & current_section {
439+ Some ( s) => format ! ( "{}.{}" , s, key_part) ,
440+ None => key_part. to_string ( ) ,
441+ } ;
442+
443+ let is_sensitive = qualified
444+ . parse :: < ConfigKey > ( )
445+ . map ( |k| k. is_sensitive ( ) )
446+ . unwrap_or ( false ) ;
447+
448+ if is_sensitive && !value_part. is_empty ( ) {
449+ out. push_str ( & format ! ( "{} = {}\n " , key_part, SENSITIVE_VALUE_MASK ) ) ;
450+ } else {
451+ out. push_str ( line) ;
452+ out. push ( '\n' ) ;
453+ }
454+ }
455+
456+ out
457+ }
458+
407459impl fmt:: Display for SystemReport {
408460 fn fmt ( & self , f : & mut fmt:: Formatter < ' _ > ) -> fmt:: Result {
409461 writeln ! ( f, "XEarthLayer Diagnostics" ) ?;
@@ -687,4 +739,82 @@ mod tests {
687739 resolved. display( )
688740 ) ;
689741 }
742+
743+ #[ test]
744+ fn redact_sensitive_ini_masks_provider_credentials ( ) {
745+ let input = "\
746+ [provider]
747+ type = google
748+ google_api_key = AIzaTEST_KEY_NOT_REAL_xxxxxx
749+ mapbox_access_token = pk.eyJ1IjoidGVzdCJ9.fake
750+ " ;
751+ let redacted = redact_sensitive_ini ( input) ;
752+ assert ! ( redacted. contains( "type = google" ) ) ;
753+ assert ! ( redacted. contains( "google_api_key = xxxxxxxx" ) ) ;
754+ assert ! ( redacted. contains( "mapbox_access_token = xxxxxxxx" ) ) ;
755+ assert ! ( !redacted. contains( "AIzaTEST" ) ) ;
756+ assert ! ( !redacted. contains( "pk.eyJ1" ) ) ;
757+ }
758+
759+ #[ test]
760+ fn redact_sensitive_ini_preserves_empty_credential_values ( ) {
761+ // Empty values aren't secrets and the absence is informative
762+ // when reading a diagnostics dump (e.g., "user hasn't set their
763+ // Google key yet, that's why google provider isn't working").
764+ let input = "[provider]\n google_api_key =\n " ;
765+ let redacted = redact_sensitive_ini ( input) ;
766+ assert ! (
767+ redacted. contains( "google_api_key =" ) ,
768+ "empty value must pass through verbatim, got: {:?}" ,
769+ redacted
770+ ) ;
771+ assert ! ( !redacted. contains( "xxxxxxxx" ) ) ;
772+ }
773+
774+ #[ test]
775+ fn redact_sensitive_ini_preserves_non_credentials_and_structure ( ) {
776+ let input = "\
777+ # top comment
778+ [cache]
779+ directory = /tmp/cache
780+ memory_size = 512MB
781+
782+ ; section comment
783+ [provider]
784+ type = bing
785+ " ;
786+ let redacted = redact_sensitive_ini ( input) ;
787+ // Comments, headers, and non-sensitive values all preserved.
788+ assert ! ( redacted. contains( "# top comment" ) ) ;
789+ assert ! ( redacted. contains( "[cache]" ) ) ;
790+ assert ! ( redacted. contains( "directory = /tmp/cache" ) ) ;
791+ assert ! ( redacted. contains( "memory_size = 512MB" ) ) ;
792+ assert ! ( redacted. contains( "; section comment" ) ) ;
793+ assert ! ( redacted. contains( "[provider]" ) ) ;
794+ assert ! ( redacted. contains( "type = bing" ) ) ;
795+ assert ! ( !redacted. contains( "xxxxxxxx" ) ) ;
796+ }
797+
798+ #[ test]
799+ fn redact_sensitive_ini_passes_through_unknown_keys ( ) {
800+ // Unknown keys (e.g., from a future XEL version, or user typos)
801+ // pass through unchanged. The exhaustive `is_sensitive` test in
802+ // config::keys guarantees we never miss a real sensitive variant.
803+ let input = "[provider]\n future_unknown_key = some_value\n " ;
804+ let redacted = redact_sensitive_ini ( input) ;
805+ assert ! ( redacted. contains( "future_unknown_key = some_value" ) ) ;
806+ }
807+
808+ #[ test]
809+ fn redact_sensitive_ini_only_masks_within_correct_section ( ) {
810+ // A key literally named `google_api_key` outside `[provider]`
811+ // would be a parse error in ConfigKey, so passes through. This
812+ // proves we are NOT falling back to substring matching.
813+ let input = "\
814+ [cache]
815+ google_api_key = THIS_IS_NOT_A_REAL_KEY_AND_NOT_SENSITIVE
816+ " ;
817+ let redacted = redact_sensitive_ini ( input) ;
818+ assert ! ( redacted. contains( "THIS_IS_NOT_A_REAL_KEY_AND_NOT_SENSITIVE" ) ) ;
819+ }
690820}
0 commit comments