use super::*;
pub const MASK_WINDOW_CHARS: usize = 4_096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoundedMask {
pub text: String,
pub truncated: bool,
pub redacted: bool,
}
pub fn mask_bounded(
surface: RedactionSurface,
text: &str,
window_chars: usize,
output_cap_chars: usize,
) -> BoundedMask {
debug_assert!(
window_chars >= output_cap_chars,
"the input window must stay at least as large as the output cap"
);
let output_cap_chars = output_cap_chars.min(window_chars);
let input_truncated = text.chars().nth(window_chars).is_some();
let mut window: String = text.chars().take(window_chars).collect();
if input_truncated {
match window
.char_indices()
.rev()
.find(|(_, ch)| ch.is_whitespace())
{
Some((idx, ch)) => window.truncate(idx + ch.len_utf8()),
None => window.clear(),
}
}
if input_truncated && !window.is_empty() {
if let Some(cut) = trailing_bridge_fragment_cut(&window) {
window.truncate(cut);
}
}
if input_truncated && window.is_empty() {
return BoundedMask {
text: TRUNCATION_MARKER.to_string(),
truncated: true,
redacted: true,
};
}
debug_assert!(window.chars().count() <= window_chars);
let masked = mask_for_redaction_surface(surface, &window);
let redacted = masked.as_ref() != window.as_str();
let mut chars = masked.chars();
let mut bounded: String = chars.by_ref().take(output_cap_chars).collect();
let output_capped = chars.next().is_some();
if output_capped || input_truncated {
bounded.push_str(TRUNCATION_MARKER);
}
BoundedMask {
text: bounded,
truncated: input_truncated || output_capped,
redacted,
}
}
pub(super) const TRUNCATION_MARKER: &str = "…";
pub fn mask_secrets(text: &str) -> std::borrow::Cow<'_, str> {
let (spans, _scan_work_bytes) = collect_mask_spans(text);
if spans.is_empty() {
return std::borrow::Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len());
let mut cursor = 0;
for (start, end) in spans {
let start = start.max(cursor);
out.push_str(&text[cursor..start]);
out.push_str(REDACTION_MARKER);
cursor = end.max(cursor);
}
out.push_str(&text[cursor..]);
std::borrow::Cow::Owned(out)
}
pub(super) fn collect_mask_spans(text: &str) -> (Vec<(usize, usize)>, usize) {
let base = text.as_ptr() as usize;
let context = EntropyScanContext::new(text);
let tokens = &context.tokens;
let mut spans: Vec<(usize, usize)> = Vec::new();
let mut from = 0;
let mut scan_work_bytes = 0usize;
while from < text.len() {
let token_index = tokens.partition_point(|&(offset, raw)| offset + raw.len() <= from);
let scan_start = tokens
.get(token_index)
.map_or(from, |&(offset, _)| offset.min(from));
let scan_len = text.len() - scan_start;
let next_scan_work = scan_work_bytes.saturating_add(scan_len);
if scan_work_bytes > 0 && next_scan_work > MAX_MASK_SCAN_WORK_BYTES {
spans.last_mut().expect("a prior scan found a span").1 = text.len();
break;
}
scan_work_bytes = next_scan_work;
match scan_from(text, from, &context) {
Some((sub, _detector)) => {
let start = sub.as_ptr() as usize - base;
let core_len = sub
.trim_end_matches(['"', '\'', '`', '}', ']', ')', ',', ';'])
.len();
let end = extend_across_invisible_bridge(text, start + core_len.max(1));
push_mask_spans(text, start, end, &mut spans);
from = end;
}
None => break,
}
}
(spans, scan_work_bytes)
}
fn is_invisible_bridge_separator(c: char) -> bool {
!c.is_ascii() && !c.is_alphanumeric()
}
pub(super) fn extend_across_invisible_bridge(text: &str, end: usize) -> usize {
let mut end = end;
for _ in 1..MAX_BRIDGE_FRAGMENTS {
let rest = &text[end..];
let Some(gap_len) = rest.find(|c: char| c.is_ascii_alphanumeric()) else {
break;
};
let gap = &rest[..gap_len];
if gap.is_empty() || !gap.chars().all(is_invisible_bridge_separator) {
break;
}
let fragment = &rest[gap_len..];
let fragment_len = fragment
.find(|c: char| !c.is_ascii_alphanumeric())
.unwrap_or(fragment.len());
if fragment_len < MIN_BRIDGE_FRAGMENT_LEN {
break;
}
end += gap_len + fragment_len;
}
end
}
fn push_mask_spans(text: &str, start: usize, end: usize, spans: &mut Vec<(usize, usize)>) {
let span = &text[start..end];
if !span.chars().any(is_invisible_bridge_separator) {
spans.push((start, end));
return;
}
let before = spans.len();
let mut run_start: Option<usize> = None;
for (offset, ch) in span.char_indices() {
if is_invisible_bridge_separator(ch) {
if let Some(run) = run_start.take() {
spans.push((start + run, start + offset));
}
} else {
run_start.get_or_insert(offset);
}
}
if let Some(run) = run_start {
spans.push((start + run, end));
}
if spans.len() == before {
spans.push((start, end));
}
}
pub(super) const MAX_LOG_TEXT_MASK_INPUT_CHARS: usize = 1_048_576;
pub(super) const MAX_LOG_TEXT_OUTPUT_CHARS: usize = 1_024;
pub fn bounded_masked_log_text(text: &str) -> String {
let mask_input_truncated = text.chars().nth(MAX_LOG_TEXT_MASK_INPUT_CHARS).is_some();
let bounded_input: std::borrow::Cow<'_, str> = if mask_input_truncated {
std::borrow::Cow::Owned(text.chars().take(MAX_LOG_TEXT_MASK_INPUT_CHARS).collect())
} else {
std::borrow::Cow::Borrowed(text)
};
let masked = mask_secrets(&bounded_input);
let masked = if mask_input_truncated {
redact_crossing_boundary_url_userinfo(&masked)
} else {
masked
};
let neutralized = neutralize_log_unsafe_chars(&masked);
let mut chars = neutralized.chars();
let mut bounded: String = chars.by_ref().take(MAX_LOG_TEXT_OUTPUT_CHARS).collect();
if chars.next().is_some() || mask_input_truncated {
bounded.push('…');
}
bounded
}
fn redact_crossing_boundary_url_userinfo(text: &str) -> std::borrow::Cow<'_, str> {
let mut search_from = 0usize;
while let Some(rel) = text[search_from..].find("://") {
let scheme_pos = search_from + rel;
let rest = &text[scheme_pos + 3..];
let terminated =
rest.contains('@') || rest.contains(' ') || rest.contains('\n') || rest.contains('\r');
if !terminated {
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
if let Some(colon) = rest[..authority_end].find(':') {
let pass = &rest[colon + 1..];
if !pass.is_empty() {
let redact_from = scheme_pos + 3 + colon;
let mut out = String::with_capacity(redact_from + REDACTION_MARKER.len());
out.push_str(&text[..redact_from]);
out.push_str(REDACTION_MARKER);
return std::borrow::Cow::Owned(out);
}
}
}
search_from = scheme_pos + 3;
}
std::borrow::Cow::Borrowed(text)
}
fn is_log_unsafe_char(c: char) -> bool {
if c == '\t' {
return false;
}
matches!(
unicode_general_category::get_general_category(c),
unicode_general_category::GeneralCategory::Control
| unicode_general_category::GeneralCategory::Format
| unicode_general_category::GeneralCategory::LineSeparator
| unicode_general_category::GeneralCategory::ParagraphSeparator
)
}
fn neutralize_log_unsafe_chars(text: &str) -> std::borrow::Cow<'_, str> {
if !text.chars().any(is_log_unsafe_char) {
return std::borrow::Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if is_log_unsafe_char(c) {
out.push_str(&format!("\\u{{{:04x}}}", c as u32));
} else {
out.push(c);
}
}
std::borrow::Cow::Owned(out)
}