use crate::Record;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
pub min_chars: usize,
pub max_findings: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
min_chars: 4,
max_findings: 4096,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StringFinding {
record_index: usize,
offset: usize,
encoding: StringEncoding,
value: String,
}
impl StringFinding {
#[must_use]
pub const fn record_index(&self) -> usize {
self.record_index
}
#[must_use]
pub const fn offset(&self) -> usize {
self.offset
}
#[must_use]
pub const fn encoding(&self) -> StringEncoding {
self.encoding
}
#[must_use]
pub fn value(&self) -> &str {
self.value.as_str()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StringEncoding {
Ascii,
Utf16Le,
}
#[must_use]
pub fn extract_from_records(records: &[Record], limits: Limits) -> Vec<StringFinding> {
let mut findings = Vec::new();
for record in records {
extract_ascii(record.index(), record.payload_data(), limits, &mut findings);
if findings.len() >= limits.max_findings {
return findings;
}
extract_utf16le(record.index(), record.payload_data(), limits, &mut findings);
if findings.len() >= limits.max_findings {
return findings;
}
}
findings
}
fn extract_ascii(
record_index: usize,
data: &[u8],
limits: Limits,
findings: &mut Vec<StringFinding>,
) {
let mut start = None;
for (index, byte) in data.iter().enumerate() {
if is_ascii_string_byte(*byte) {
if start.is_none() {
start = Some(index);
}
} else if let Some(offset) = start {
push_ascii(record_index, data, offset, index, limits, findings);
start = None;
}
if findings.len() >= limits.max_findings {
return;
}
}
if let Some(offset) = start {
push_ascii(record_index, data, offset, data.len(), limits, findings);
}
}
fn push_ascii(
record_index: usize,
data: &[u8],
start: usize,
end: usize,
limits: Limits,
findings: &mut Vec<StringFinding>,
) {
let Some(len) = end.checked_sub(start) else {
return;
};
if len < limits.min_chars || findings.len() >= limits.max_findings {
return;
}
let Some(bytes) = data.get(start..end) else {
return;
};
let value = String::from_utf8_lossy(bytes).into_owned();
findings.push(StringFinding {
record_index,
offset: start,
encoding: StringEncoding::Ascii,
value,
});
}
fn extract_utf16le(
record_index: usize,
data: &[u8],
limits: Limits,
findings: &mut Vec<StringFinding>,
) {
for alignment in 0..2usize {
let mut start = None;
let mut cursor = alignment;
while cursor.checked_add(1).is_some_and(|end| end < data.len()) {
let Some(unit) = read_u16_at(data, cursor) else {
return;
};
if is_utf16_string_unit(unit) {
if start.is_none() {
start = Some(cursor);
}
} else if let Some(offset) = start {
push_utf16(record_index, data, offset, cursor, limits, findings);
start = None;
}
if findings.len() >= limits.max_findings {
return;
}
let Some(next) = cursor.checked_add(2) else {
return;
};
cursor = next;
}
if let Some(offset) = start {
push_utf16(record_index, data, offset, cursor, limits, findings);
}
}
}
fn push_utf16(
record_index: usize,
data: &[u8],
start: usize,
end: usize,
limits: Limits,
findings: &mut Vec<StringFinding>,
) {
let Some(byte_len) = end.checked_sub(start) else {
return;
};
let char_len = byte_len / 2;
if char_len < limits.min_chars || findings.len() >= limits.max_findings {
return;
}
let Some(bytes) = data.get(start..end) else {
return;
};
let units: Option<Vec<u16>> = bytes
.chunks_exact(2)
.map(|chunk| read_u16_at(chunk, 0))
.collect();
let Some(units) = units else {
return;
};
findings.push(StringFinding {
record_index,
offset: start,
encoding: StringEncoding::Utf16Le,
value: String::from_utf16_lossy(units.as_slice()),
});
}
fn is_ascii_string_byte(byte: u8) -> bool {
matches!(byte, 0x20..=0x7e | b'\t')
}
fn is_utf16_string_unit(unit: u16) -> bool {
matches!(unit, 0x20..=0x7e | 0x09)
}
fn read_u16_at(data: &[u8], offset: usize) -> Option<u16> {
let end = offset.checked_add(2)?;
let bytes: [u8; 2] = data.get(offset..end)?.try_into().ok()?;
Some(u16::from_le_bytes(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::au3::{DecodedString, DecompressionStatus, RecordTestParts};
#[test]
fn extracts_ascii_and_utf16_strings() -> Result<(), String> {
let mut payload = Vec::from(b"\0abcd\x01\x01".as_slice());
for unit in "WXYZ".encode_utf16() {
payload.extend_from_slice(&unit.to_le_bytes());
}
payload.push(0);
let record = test_record(payload)?;
let findings = extract_from_records(
&[record],
Limits {
min_chars: 4,
max_findings: 8,
},
);
check_eq(findings.len(), 2, "finding count")?;
check_eq(
findings.first().map(StringFinding::value),
Some("abcd"),
"ascii",
)?;
check_eq(
findings.get(1).map(StringFinding::encoding),
Some(StringEncoding::Utf16Le),
"utf16 encoding",
)?;
check_eq(
findings.get(1).map(StringFinding::value),
Some("WXYZ"),
"utf16",
)
}
fn test_record(payload: Vec<u8>) -> Result<Record, String> {
let payload_len = u32::try_from(payload.len()).map_err(|err| err.to_string())?;
Ok(Record::from_parts_for_test(RecordTestParts {
index: 3,
offset: 0,
subtype: DecodedString::from_text_for_test("artifact"),
name: DecodedString::from_text_for_test("artifact.bin"),
compressed: false,
compressed_size: payload_len,
uncompressed_size: payload_len,
checksum: 0,
checksum_valid: false,
creation_time: 0,
last_write_time: 0,
encrypted_data: payload.clone(),
decrypted_data: payload,
decompressed_data: None,
decompression_status: DecompressionStatus::NotCompressed,
profile: crate::RecordProfile {
encoding: crate::Encoding::Ea06,
encryption: crate::EncryptionProfile::Ea06Lame,
compression: crate::CompressionProfile::None,
},
}))
}
fn check_eq<T>(actual: T, expected: T, context: &str) -> Result<(), String>
where
T: core::fmt::Debug + PartialEq,
{
if actual == expected {
Ok(())
} else {
Err(format!("{context}: got {actual:?}, expected {expected:?}"))
}
}
}