use super::heuristics::{js_trim_end, js_trim_start};
const MAX_PREVIEW: usize = 8;
pub(crate) fn mask_secret_value(value: &str) -> String {
if value.is_empty() {
return "(empty)".to_string();
}
let length = value.encode_utf16().count();
if length < 3 {
return format!("({length} chars)");
}
let shown = MAX_PREVIEW.min(length / 2);
format!("{}… ({length} chars)", take_utf16(value, shown))
}
pub(crate) fn mask_within(context: &str, value: &str) -> String {
if value.is_empty() {
return context.to_string();
}
context.replace(value, &mask_secret_value(value))
}
const CONTEXT_MARGIN: usize = 60;
fn redact_spans(line: &str, from: usize, to: usize, spans: &[(usize, usize)]) -> String {
let mut out = String::new();
let mut cursor = from;
for (start, end) in spans.iter().copied() {
let start = start.max(from);
let end = end.min(to);
if start >= end || end <= cursor {
continue;
}
if start > cursor {
out.push_str(&line[cursor..start]);
}
out.push('\u{2026}');
cursor = end;
}
if cursor < to {
out.push_str(&line[cursor..to]);
}
out
}
pub(crate) fn mask_context(
line: &str,
value_start: usize,
value_length: usize,
value: &str,
values: &[String],
spans: &[(usize, usize)],
) -> String {
let value_end = value_start.saturating_add(value_length).min(line.len());
let before_start = back_from(line, value_start, CONTEXT_MARGIN);
let after_end = forward_from(line, value_end, CONTEXT_MARGIN);
let mut before_raw = redact_spans(line, before_start, value_start, spans);
let mut after_raw = redact_spans(line, value_end, after_end, spans);
if before_start > 0 {
before_raw = drop_leading_partial(&before_raw).to_string();
}
if after_end < line.len() {
after_raw = drop_trailing_partial(&after_raw).to_string();
}
let before = collapse_unclaimed_runs(&mask_all(js_trim_start(&before_raw), values));
let after = collapse_unclaimed_runs(&mask_all(js_trim_end(&after_raw), values));
let mut context = String::new();
if before_start > 0 {
context.push('…');
}
context.push_str(&before);
context.push_str(&mask_secret_value(value));
context.push_str(&after);
if after_end < line.len() {
context.push('…');
}
context
}
pub(crate) fn mask_all(text: &str, values: &[String]) -> String {
let mut masked = text.to_string();
for value in values {
if !masked.contains(value.as_str()) {
continue;
}
masked = mask_within(&masked, value);
}
masked
}
pub(crate) fn masking_order(values: &[String]) -> Vec<String> {
let mut ordered: Vec<String> = values.to_vec();
ordered.sort_by(|a, b| {
b.encode_utf16()
.count()
.cmp(&a.encode_utf16().count())
.then_with(|| a.cmp(b))
});
ordered.dedup();
ordered
}
const MAX_RUN: usize = 16;
fn ends_a_token(character: char) -> bool {
character.is_whitespace()
|| matches!(
character,
'=' | ':'
| ';'
| ','
| '\''
| '"'
| '`'
| '('
| ')'
| '{'
| '}'
| '['
| ']'
| '<'
| '>'
| '&'
| '|'
| '?'
| '!'
| '\u{2026}'
)
}
fn reads_as_a_name(token: &str) -> bool {
token.chars().all(|character| {
character.is_alphabetic()
|| character == '_'
|| character == '-'
|| character == '.'
|| character == '/'
})
}
pub(crate) fn collapse_unclaimed_runs(text: &str) -> String {
let mut out = String::new();
let mut token = String::new();
for character in text.chars() {
if ends_a_token(character) {
out.push_str(&collapse_token(&token));
token.clear();
out.push(character);
continue;
}
token.push(character);
}
out.push_str(&collapse_token(&token));
out
}
fn collapse_token(token: &str) -> String {
let length = token.encode_utf16().count();
if length >= MAX_RUN && !reads_as_a_name(token) {
return format!("({length} chars)");
}
token.to_string()
}
fn drop_leading_partial(window: &str) -> &str {
match window
.char_indices()
.find(|(_, character)| ends_a_token(*character))
{
Some((offset, _)) => &window[offset..],
None => "",
}
}
fn drop_trailing_partial(window: &str) -> &str {
match window
.char_indices()
.rev()
.find(|(_, character)| ends_a_token(*character))
{
Some((offset, character)) => &window[..offset + character.len_utf8()],
None => "",
}
}
fn back_from(line: &str, from: usize, units: usize) -> usize {
let mut seen = 0;
for (offset, character) in line[..from].char_indices().rev() {
seen += character.len_utf16();
if seen > units {
return offset + character.len_utf8();
}
}
0
}
fn forward_from(line: &str, from: usize, units: usize) -> usize {
let mut seen = 0;
for (offset, character) in line[from..].char_indices() {
if seen + character.len_utf16() > units {
return from + offset;
}
seen += character.len_utf16();
}
line.len()
}
fn take_utf16(value: &str, units: usize) -> &str {
let mut seen = 0;
for (offset, character) in value.char_indices() {
if seen >= units {
return &value[..offset];
}
seen += character.len_utf16();
}
value
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_empty_value_is_named_rather_than_previewed() {
assert_eq!(mask_secret_value(""), "(empty)");
}
#[test]
fn a_value_too_short_to_preview_gives_only_its_length() {
assert_eq!(mask_secret_value("a"), "(1 chars)");
assert_eq!(mask_secret_value("ab"), "(2 chars)");
}
#[test]
fn the_preview_is_capped_at_half_the_length() {
assert_eq!(mask_secret_value("abcd"), "ab… (4 chars)");
assert_eq!(mask_secret_value("abcdef"), "abc… (6 chars)");
}
#[test]
fn the_preview_is_capped_at_eight_characters() {
let value = "a".repeat(200);
assert_eq!(mask_secret_value(&value), "aaaaaaaa… (200 chars)");
}
#[test]
fn every_occurrence_in_a_context_line_is_masked() {
let masked = mask_within("a=secret and again secret", "secret");
assert!(!masked.contains("secret and"), "{masked}");
assert_eq!(masked.matches("sec…").count(), 2, "{masked}");
}
#[test]
fn a_context_without_the_value_is_unchanged() {
assert_eq!(mask_within("nothing here", "absent"), "nothing here");
assert_eq!(mask_within("empty value", ""), "empty value");
}
#[test]
fn a_value_too_short_to_preview_discloses_nothing_but_its_length() {
for value in ["a", "x", "1", "ab", "xy", "()"] {
let length = value.encode_utf16().count();
assert_eq!(mask_secret_value(value), format!("({length} chars)"));
}
}
#[test]
fn no_preview_ever_contains_its_whole_value() {
for length in 3..=300 {
let value: String = std::iter::repeat_n('x', length).collect();
let preview = mask_secret_value(&value);
assert!(
!preview.contains(&value),
"a {length}-character value leaked through its preview: {preview}"
);
let context = format!("KEY={value}");
let masked = mask_within(&context, &value);
assert!(
!masked.contains(&value),
"a {length}-character value leaked through its context line: {masked}"
);
}
}
#[test]
fn no_preview_leaks_a_varied_value() {
let alphabet: Vec<char> = "aB3xY7zQ9mK2pL5vN8wR4tS6/+=-_.".chars().collect();
for length in 3..=300 {
let value: String = (0..length).map(|i| alphabet[i % alphabet.len()]).collect();
assert!(!mask_secret_value(&value).contains(&value), "{length}");
let context = format!("KEY={value} trailing");
assert!(!mask_within(&context, &value).contains(&value), "{length}");
}
}
#[test]
fn multibyte_values_are_previewed_by_code_unit() {
let value = "ééééééééééééééé";
let preview = mask_secret_value(value);
assert_eq!(preview, "ééééééé… (15 chars)");
assert!(!preview.contains(value));
}
#[test]
fn a_context_masks_every_value_on_the_line_not_only_its_own() {
let line = "DB_PASSWORD=hunter2hunter2 API_KEY=abcdefghijklmnopqrstuvwx";
let values = masking_order(&[
"hunter2hunter2".to_string(),
"abcdefghijklmnopqrstuvwx".to_string(),
]);
let context = mask_context(line, 12, 14, "hunter2hunter2", &values, &[]);
assert!(!context.contains("hunter2hunter2"), "{context}");
assert!(
!context.contains("abcdefghijklmnopqrstuvwx"),
"the neighbouring key survived: {context}"
);
}
#[test]
fn a_short_line_is_not_windowed_at_all() {
let line = " DATABASE_PASSWORD=hunter2hunter2 ";
let values = masking_order(&["hunter2hunter2".to_string()]);
assert_eq!(
mask_context(line, 20, 14, "hunter2hunter2", &values, &[]),
"DATABASE_PASSWORD=hunter2… (14 chars)"
);
}
#[test]
fn a_long_line_is_cut_down_to_a_window_around_the_value() {
let filler = "z".repeat(5_000);
let line = format!("{filler} DATABASE_PASSWORD=hunter2hunter2 {filler}");
let values = masking_order(&["hunter2hunter2".to_string()]);
let context = mask_context(&line, 5_019, 14, "hunter2hunter2", &values, &[]);
assert!(context.len() < 200, "{} bytes", context.len());
assert!(context.starts_with('…'), "{context}");
assert!(context.ends_with('…'), "{context}");
assert!(context.contains("DATABASE_PASSWORD="), "{context}");
assert!(!context.contains("hunter2hunter2"), "{context}");
}
#[test]
fn a_value_longer_than_the_window_is_still_masked_entirely() {
let value = "aB3xY7zQ9mK2pL5vN8wR4tS6".repeat(20);
let line = format!("token = {value} trailing");
let values = masking_order(std::slice::from_ref(&value));
let context = mask_context(&line, 8, value.len(), &value, &values, &[]);
assert!(!context.contains(&value), "{context}");
assert!(context.contains("trailing"), "{context}");
}
#[test]
fn the_masking_order_puts_the_longest_value_first() {
let order = masking_order(&[
"hunter2hunter2".to_string(),
"hunter2hunter2hunter2".to_string(),
"hunter2hunter2".to_string(),
]);
assert_eq!(
order,
[
"hunter2hunter2hunter2".to_string(),
"hunter2hunter2".to_string()
]
);
let line = "a=hunter2hunter2hunter2 b=hunter2hunter2";
let context = mask_context(line, 2, 21, "hunter2hunter2hunter2", &order, &[]);
assert!(!context.contains("hunter2hunter2"), "{context}");
}
#[test]
fn an_astral_value_counts_in_utf16_units_like_the_extension() {
let value = "🎯🎯🎯🎯";
let preview = mask_secret_value(value);
assert!(preview.contains("(8 chars)"), "{preview}");
assert!(!preview.contains(value), "{preview}");
}
}