Files
SpotiFLAC-Mobile/rust_backend/crates/extensions/src/redact.rs
T
zarzet aa99726439 feat(backend): migrate production backend to Rust
Replace the Go backend with the Rust workspace and route Android/iOS through
UniFFI bindings. Include the migrated extension runtime, network, providers,
media metadata, downloads and library operations, with version 5.0.0+147.

Remove Go sources, adapters, native build selection and CI dependencies.
Build Rust unconditionally and lock the iOS Rust pod using a relative path.
Retain legacy source and migration evidence in a local ignored archive.

Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch
equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and
diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI
build checkpoint; no new APK or iOS application build for this cleanup.

Known follow-up: URL/URLSearchParams globals are missing from the Rust JS
runtime. A controlled extension replay confirms a URL-resolution regression;
this commit does not fix that runtime gap.
2026-09-14 22:58:27 +07:00

110 lines
4.7 KiB
Rust

use regex::Regex;
use std::borrow::Cow;
use std::sync::LazyLock;
static PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
// Go's RE2 uses ASCII whitespace and word boundaries. Keep Unicode token
// contents inside the redacted match instead of treating them as delimiters.
vec![
(r"(?i)(?-u:\b)Authorization(?-u:\b)[\t\n\f\r ]*[:=][\t\n\f\r ]*Bearer[\t\n\f\r ]+[A-Za-z0-9._~+/\-]+=*", "Authorization: Bearer [REDACTED]"),
(r#"(?i)("?(?:access[_\t\n\f\r -]?token|refresh[_\t\n\f\r -]?token|id[_\t\n\f\r -]?token|client[_\t\n\f\r -]?secret|authorization|password|api[_\t\n\f\r -]?key|session[_\t\n\f\r -]?secret|decryption[_\t\n\f\r -]?key|cookie|set-cookie)"?)([\t\n\f\r ]*[:=][\t\n\f\r ]*)("(?:\\.|[^"\\])*"|[^\t\n\f\r ,;}\]]+)"#, "${1}${2}[REDACTED]"),
(r"(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password|code|grant|sig|signature|x-amz-signature|x-amz-credential|x-amz-security-token|awsaccesskeyid|googleaccessid|policy|key-pair-id)=)[^&\t\n\f\r ]+", "${1}[REDACTED]"),
(r"(?i)(?-u:\b)Bearer[\t\n\f\r ]+[A-Za-z0-9._~+/\-]+=*", "Bearer [REDACTED]"),
(r"(?i)(-decryption_key[\t\n\f\r ]+)[^\t\n\f\r ]+", "${1}[REDACTED]"),
]
.into_iter()
.map(|(pattern, replacement)| (Regex::new(pattern).expect("static redaction pattern"), replacement))
.collect()
});
fn sanitize_text(mut text: String) -> String {
for (pattern, replacement) in PATTERNS.iter() {
if let Cow::Owned(replaced) = pattern.replace_all(&text, *replacement) {
text = replaced;
}
}
text
}
pub(crate) fn preview(bytes: &[u8], limit: usize) -> String {
let mut text = sanitize_text(crate::host::decode_go_utf8(bytes));
if text.len() > limit {
text = crate::host::decode_go_utf8(&text.as_bytes()[..limit]) + "...[truncated]";
}
text
}
/// Log arguments can end mid-codepoint after Go's 512-byte truncation. Preserve
/// their raw bytes while matching as Go's regexp UTF-8 decoder would; converting
/// them to replacement characters first would change the later 4000-byte cut.
pub(crate) fn sanitize_bytes(bytes: Vec<u8>) -> Vec<u8> {
let mut bytes = match String::from_utf8(bytes) {
Ok(text) => return sanitize_text(text).into_bytes(),
Err(error) => error.into_bytes(),
};
for (pattern, replacement) in PATTERNS.iter() {
let (text, invalid) = decoded_positions(&bytes);
let position = |index: usize| index - 2 * invalid.partition_point(|start| *start < index);
let mut output = Vec::with_capacity(bytes.len());
let mut copied = 0;
for captures in pattern.captures_iter(&text) {
let found = captures.get(0).expect("redaction match");
let start = position(found.start());
let end = position(found.end());
output.extend_from_slice(&bytes[copied..start]);
let mut expanded = String::new();
// Retained captures are only key names/separators, never secret
// values; they cannot contain an invalid UTF-8 byte.
captures.expand(replacement, &mut expanded);
output.extend_from_slice(expanded.as_bytes());
copied = end;
}
output.extend_from_slice(&bytes[copied..]);
bytes = output;
}
bytes
}
fn decoded_positions(mut bytes: &[u8]) -> (String, Vec<usize>) {
let mut text = String::with_capacity(bytes.len());
let mut invalid = Vec::new();
while !bytes.is_empty() {
match std::str::from_utf8(bytes) {
Ok(valid) => {
text.push_str(valid);
break;
}
Err(error) => {
let prefix = error.valid_up_to();
text.push_str(std::str::from_utf8(&bytes[..prefix]).expect("valid UTF-8 prefix"));
invalid.push(text.len());
text.push('\u{fffd}');
bytes = &bytes[prefix + 1..];
}
}
}
(text, invalid)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redaction_preserves_unicode_and_invalid_bytes_around_secret_values() {
for prefix in [b"track: ".as_slice(), "曲名: ".as_bytes(), b"\xff\xe3\x81 "] {
let input = [prefix, b"https://example.test/audio?token=fixture-secret&track=7 password=fixture-password;"].concat();
let expected = [
prefix,
b"https://example.test/audio?token=[REDACTED]&track=7 password=[REDACTED];",
]
.concat();
assert_eq!(sanitize_bytes(input.clone()), expected);
assert_eq!(
preview(&input, usize::MAX),
crate::host::decode_go_utf8(&expected)
);
}
}
}