Files
Psychotoxical-psysonic/src-tauri/crates/psysonic-core/src/log_sanitize.rs
T
cucadmuh 086c7e43b4 feat(psylab): rename probe, Tuning tab, log tools, and safe log sanitization (#1027)
* feat(psylab): rename probe UI, add Tuning tab and log copy/export

PsyLab (Ctrl+Shift+D): cover backfill threads move to Tuning; logs gain
selectable text, toolbar copy/export, and a selection-only context menu.

* fix(logging): redact secrets and mask remote hosts in runtime logs

Sanitize lines at append time (buffer, CLI tail, export) and in PsyLab:
Subsonic/auth query params, bearer tokens, password fields, URL userinfo;
remote hostnames partially starred, LAN/localhost left readable.

* fix(logging): UTF-8-safe log sanitization — unbreak playback on em dash

Byte-indexed URL scanning panicked on multi-byte chars (e.g. "—" in stream
logs), killing tokio workers and aborting playback. Iterate by char boundary;
add infallible wrapper on the append hot path.

* docs(changelog): PsyLab UI and safe log sanitization (PR #1027)
2026-06-08 02:29:23 +03:00

379 lines
12 KiB
Rust

//! Redact secrets and partially mask remote server hostnames before log lines
//! are stored or exported (PsyLab / Settings log export).
const SENSITIVE_QUERY_KEYS: &[&str] = &[
"t", "s", "p", "token", "password", "passwd", "secret", "api_key", "apikey",
"access_token", "refresh_token", "auth",
];
const SENSITIVE_KV_KEYS: &[&str] = &[
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
"refresh_token", "authorization", "auth",
];
/// Sanitize one runtime log line for display and export.
pub fn sanitize_log_line(line: &str) -> String {
let mut out = redact_bearer_tokens(line);
out = redact_sensitive_key_values(&out);
out = redact_urls_in_text(&out);
out
}
/// Never panic on the logging hot path — fall back to the raw line if needed.
pub fn sanitize_log_line_infallible(line: &str) -> String {
std::panic::catch_unwind(|| sanitize_log_line(line)).unwrap_or_else(|_| line.to_string())
}
fn redact_bearer_tokens(line: &str) -> String {
let marker = "Bearer ";
let mut s = line.to_string();
let mut search_from = 0;
while let Some(rel) = s[search_from..].find(marker) {
let idx = search_from + rel;
let start = idx + marker.len();
let end = s[start..]
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')' || c == ']')
.map(|i| start + i)
.unwrap_or(s.len());
if end > start {
s.replace_range(start..end, "REDACTED");
}
search_from = start + "REDACTED".len();
}
s
}
fn redact_sensitive_key_values(line: &str) -> String {
let mut out = line.to_string();
for key in SENSITIVE_KV_KEYS {
for sep in [':', '='] {
let needle = format!("{key}{sep}");
let lower = out.to_ascii_lowercase();
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find(&needle) {
let idx = search_from + rel;
let val_start = idx + needle.len();
let slice = &out[val_start..];
let trimmed = slice.trim_start();
let ws = slice.len().saturating_sub(trimmed.len());
let val_start = val_start + ws;
let end = trimmed
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
.unwrap_or(trimmed.len());
if end > 0 {
out.replace_range(val_start..val_start + end, "REDACTED");
}
search_from = val_start + "REDACTED".len();
if search_from >= out.len() {
break;
}
}
}
}
out
}
fn url_char_ends_url(ch: char, s: &str, byte_off: usize) -> bool {
if ch.is_whitespace() || ch == '"' || ch == '\'' || ch == '>' {
return true;
}
if ch == ')' || ch == ']' || ch == ',' {
if let Some(next) = s[byte_off..].chars().nth(1) {
return next.is_whitespace() || next == '"' || next == '\'';
}
}
false
}
fn redact_urls_in_text(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut cursor = 0;
while cursor < line.len() {
let slice = &line[cursor..];
let rel = match (slice.find("http://"), slice.find("https://")) {
(Some(h), Some(s)) => Some(h.min(s)),
(Some(h), None) => Some(h),
(None, Some(s)) => Some(s),
(None, None) => None,
};
let Some(rel) = rel else {
out.push_str(slice);
break;
};
out.push_str(&slice[..rel]);
let url_start = cursor + rel;
let url_slice = &line[url_start..];
let scheme_len = if url_slice.starts_with("https://") { 8 } else { 7 };
let mut url_end = scheme_len;
for (off, ch) in url_slice[scheme_len..].char_indices() {
let abs = scheme_len + off;
if url_char_ends_url(ch, url_slice, abs) {
break;
}
url_end = abs + ch.len_utf8();
}
out.push_str(&redact_url(&line[url_start..url_start + url_end]));
cursor = url_start + url_end;
}
out
}
fn redact_url(raw: &str) -> String {
let (url, suffix) = split_trailing_punct(raw);
let mut out = String::new();
let scheme_end = url.find("://").map(|i| i + 3).unwrap_or(0);
out.push_str(&url[..scheme_end]);
let mut rest = &url[scheme_end..];
if let Some(at) = rest.rfind('@') {
// Drop userinfo entirely.
out.push_str("***@");
rest = &rest[at + 1..];
}
let (hostport, path) = split_host_path(rest);
let (host, port) = split_host_port(&hostport);
let masked_host = mask_hostname(&host);
out.push_str(&masked_host);
if let Some(p) = port {
out.push(':');
out.push_str(&p);
}
if let Some((path_only, query)) = path.split_once('?') {
out.push_str(path_only);
out.push('?');
out.push_str(&redact_query_string(query));
} else {
out.push_str(&path);
}
format!("{out}{suffix}")
}
fn split_trailing_punct(raw: &str) -> (&str, &str) {
let mut end = raw.len();
while end > 0 {
let ch = raw.as_bytes()[end - 1] as char;
if ch == ')' || ch == ']' || ch == ',' {
end -= 1;
continue;
}
break;
}
(&raw[..end], &raw[end..])
}
fn split_host_path(rest: &str) -> (String, String) {
if rest.starts_with('[') {
if let Some(end) = rest.find(']') {
let hostport = &rest[..=end];
return (hostport.to_string(), rest[end + 1..].to_string());
}
}
if let Some(slash) = rest.find('/') {
(rest[..slash].to_string(), rest[slash..].to_string())
} else {
(rest.to_string(), String::new())
}
}
fn split_host_port(hostport: &str) -> (String, Option<String>) {
if hostport.starts_with('[') {
if let Some(end) = hostport.find("]:") {
return (
hostport[..=end].to_string(),
Some(hostport[end + 2..].to_string()),
);
}
return (hostport.to_string(), None);
}
if let Some((h, p)) = hostport.rsplit_once(':') {
if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !h.contains(':') {
return (h.to_string(), Some(p.to_string()));
}
}
(hostport.to_string(), None)
}
fn redact_query_string(query: &str) -> String {
query
.split('&')
.map(|pair| {
let (k, _v) = pair.split_once('=').unwrap_or((pair, ""));
if is_sensitive_query_key(k) {
format!("{k}=REDACTED")
} else {
pair.to_string()
}
})
.collect::<Vec<_>>()
.join("&")
}
fn is_sensitive_query_key(key: &str) -> bool {
let k = key.trim().to_ascii_lowercase();
SENSITIVE_QUERY_KEYS.iter().any(|needle| *needle == k)
}
fn is_lan_ipv4(ip: &str) -> bool {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return false;
}
let Ok(a) = parts[0].parse::<u8>() else { return false };
let Ok(b) = parts[1].parse::<u8>() else { return false };
a == 127
|| a == 10
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
}
fn is_lan_ipv6(host: &str) -> bool {
let h = host.to_ascii_lowercase();
if h == "::1" {
return true;
}
if h.starts_with("fe8") || h.starts_with("fe9") || h.starts_with("fea") || h.starts_with("feb") {
return true;
}
if h.starts_with("fc") || h.starts_with("fd") {
return true;
}
if let Some(rest) = h.strip_prefix("::ffff:") {
if rest.contains('.') {
return is_lan_ipv4(rest);
}
if let Some((a, b)) = rest.split_once(':') {
if let (Ok(v1), Ok(v2)) = (u16::from_str_radix(a, 16), u16::from_str_radix(b, 16)) {
let ip = format!(
"{}.{}.{}.{}",
(v1 >> 8) & 0xff,
v1 & 0xff,
(v2 >> 8) & 0xff,
v2 & 0xff
);
return is_lan_ipv4(&ip);
}
}
}
false
}
fn is_lan_host(host: &str) -> bool {
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
let lower = stripped.to_ascii_lowercase();
if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") {
return true;
}
if stripped.contains(':') {
return is_lan_ipv6(stripped);
}
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
return is_lan_ipv4(stripped);
}
false
}
fn mask_label_prefix(label: &str) -> String {
let mut chars = label.chars();
let c1 = chars.next();
let c2 = chars.next();
match (c1, c2) {
(None, _) => "*".to_string(),
(Some(a), None) => a.to_string(),
(Some(a), Some(b)) => {
let rest = label.chars().count().saturating_sub(2);
let stars = rest.clamp(1, 4);
format!("{a}{b}{}", "*".repeat(stars))
}
}
}
fn mask_public_ipv4(ip: &str) -> String {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return "***".to_string();
}
format!("{}.*.*.{}", parts[0], parts[3])
}
fn mask_hostname(host: &str) -> String {
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
if is_lan_host(stripped) {
return host.to_string();
}
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
return mask_public_ipv4(stripped);
}
if stripped.contains(':') {
return "[ipv6-redacted]".to_string();
}
let parts: Vec<&str> = stripped.split('.').collect();
if parts.is_empty() {
return "***".to_string();
}
let masked_first = mask_label_prefix(parts[0]);
if parts.len() == 1 {
masked_first
} else {
format!("{}.{}", masked_first, parts[1..].join("."))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_subsonic_wire_auth_params() {
let line = "GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi";
let out = sanitize_log_line(line);
assert!(out.contains("t=REDACTED"));
assert!(out.contains("s=REDACTED"));
assert!(out.contains("p=REDACTED"));
assert!(!out.contains("abc"));
}
#[test]
fn masks_remote_hostname_keeps_lan_ip() {
let remote = sanitize_log_line("connect https://my-server.example.com:4533/rest/ping");
assert!(remote.contains("my****.example.com"));
assert!(!remote.contains("my-server.example.com"));
let lan = sanitize_log_line("connect http://192.168.1.42:4533/rest/ping");
assert!(lan.contains("192.168.1.42"));
}
#[test]
fn redacts_bearer_and_password_kv() {
let line = "auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit";
let out = sanitize_log_line(line);
assert!(out.contains("Bearer REDACTED"));
assert!(!out.contains("eyJhbGci"));
assert!(out.contains("password=REDACTED"));
assert!(!out.contains("sekrit"));
}
#[test]
fn strips_url_userinfo() {
let line = "fetch https://user:pass@10.0.0.5:4533/rest/ping";
let out = sanitize_log_line(line);
assert!(out.contains("***@10.0.0.5"));
assert!(!out.contains("user:pass"));
}
#[test]
fn stream_log_with_em_dash_does_not_panic() {
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
let out = sanitize_log_line(line);
assert!(out.contains('—'));
assert!(out.contains("RangedHttpSource"));
}
}