1use crate::Record;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct Limits {
8 pub min_chars: usize,
10 pub max_findings: usize,
12}
13
14impl Default for Limits {
15 fn default() -> Self {
22 Self {
23 min_chars: 4,
24 max_findings: 4096,
25 }
26 }
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct StringFinding {
32 record_index: usize,
33 offset: usize,
34 encoding: StringEncoding,
35 value: String,
36}
37
38impl StringFinding {
39 #[must_use]
45 pub const fn record_index(&self) -> usize {
46 self.record_index
47 }
48
49 #[must_use]
55 pub const fn offset(&self) -> usize {
56 self.offset
57 }
58
59 #[must_use]
65 pub const fn encoding(&self) -> StringEncoding {
66 self.encoding
67 }
68
69 #[must_use]
75 pub fn value(&self) -> &str {
76 self.value.as_str()
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum StringEncoding {
83 Ascii,
85 Utf16Le,
87}
88
89#[must_use]
103pub fn extract_from_records(records: &[Record], limits: Limits) -> Vec<StringFinding> {
104 let mut findings = Vec::new();
105 for record in records {
106 extract_ascii(record.index(), record.payload_data(), limits, &mut findings);
107 if findings.len() >= limits.max_findings {
108 return findings;
109 }
110 extract_utf16le(record.index(), record.payload_data(), limits, &mut findings);
111 if findings.len() >= limits.max_findings {
112 return findings;
113 }
114 }
115 findings
116}
117
118fn extract_ascii(
130 record_index: usize,
131 data: &[u8],
132 limits: Limits,
133 findings: &mut Vec<StringFinding>,
134) {
135 let mut start = None;
136 for (index, byte) in data.iter().enumerate() {
137 if is_ascii_string_byte(*byte) {
138 if start.is_none() {
139 start = Some(index);
140 }
141 } else if let Some(offset) = start {
142 push_ascii(record_index, data, offset, index, limits, findings);
143 start = None;
144 }
145 if findings.len() >= limits.max_findings {
146 return;
147 }
148 }
149 if let Some(offset) = start {
150 push_ascii(record_index, data, offset, data.len(), limits, findings);
151 }
152}
153
154fn push_ascii(
169 record_index: usize,
170 data: &[u8],
171 start: usize,
172 end: usize,
173 limits: Limits,
174 findings: &mut Vec<StringFinding>,
175) {
176 let Some(len) = end.checked_sub(start) else {
177 return;
178 };
179 if len < limits.min_chars || findings.len() >= limits.max_findings {
180 return;
181 }
182 let Some(bytes) = data.get(start..end) else {
183 return;
184 };
185 let value = String::from_utf8_lossy(bytes).into_owned();
186 findings.push(StringFinding {
187 record_index,
188 offset: start,
189 encoding: StringEncoding::Ascii,
190 value,
191 });
192}
193
194fn extract_utf16le(
207 record_index: usize,
208 data: &[u8],
209 limits: Limits,
210 findings: &mut Vec<StringFinding>,
211) {
212 for alignment in 0..2usize {
213 let mut start = None;
214 let mut cursor = alignment;
215 while cursor.checked_add(1).is_some_and(|end| end < data.len()) {
216 let Some(unit) = read_u16_at(data, cursor) else {
217 return;
218 };
219 if is_utf16_string_unit(unit) {
220 if start.is_none() {
221 start = Some(cursor);
222 }
223 } else if let Some(offset) = start {
224 push_utf16(record_index, data, offset, cursor, limits, findings);
225 start = None;
226 }
227 if findings.len() >= limits.max_findings {
228 return;
229 }
230 let Some(next) = cursor.checked_add(2) else {
231 return;
232 };
233 cursor = next;
234 }
235 if let Some(offset) = start {
236 push_utf16(record_index, data, offset, cursor, limits, findings);
237 }
238 }
239}
240
241fn push_utf16(
257 record_index: usize,
258 data: &[u8],
259 start: usize,
260 end: usize,
261 limits: Limits,
262 findings: &mut Vec<StringFinding>,
263) {
264 let Some(byte_len) = end.checked_sub(start) else {
265 return;
266 };
267 let char_len = byte_len / 2;
268 if char_len < limits.min_chars || findings.len() >= limits.max_findings {
269 return;
270 }
271 let Some(bytes) = data.get(start..end) else {
272 return;
273 };
274 let units: Option<Vec<u16>> = bytes
275 .chunks_exact(2)
276 .map(|chunk| read_u16_at(chunk, 0))
277 .collect();
278 let Some(units) = units else {
279 return;
280 };
281 findings.push(StringFinding {
282 record_index,
283 offset: start,
284 encoding: StringEncoding::Utf16Le,
285 value: String::from_utf16_lossy(units.as_slice()),
286 });
287}
288
289fn is_ascii_string_byte(byte: u8) -> bool {
299 matches!(byte, 0x20..=0x7e | b'\t')
300}
301
302fn is_utf16_string_unit(unit: u16) -> bool {
313 matches!(unit, 0x20..=0x7e | 0x09)
314}
315
316fn read_u16_at(data: &[u8], offset: usize) -> Option<u16> {
328 let end = offset.checked_add(2)?;
329 let bytes: [u8; 2] = data.get(offset..end)?.try_into().ok()?;
330 Some(u16::from_le_bytes(bytes))
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::au3::{DecodedString, DecompressionStatus, RecordTestParts};
337
338 #[test]
339 fn extracts_ascii_and_utf16_strings() -> Result<(), String> {
340 let mut payload = Vec::from(b"\0abcd\x01\x01".as_slice());
341 for unit in "WXYZ".encode_utf16() {
342 payload.extend_from_slice(&unit.to_le_bytes());
343 }
344 payload.push(0);
345 let record = test_record(payload)?;
346
347 let findings = extract_from_records(
348 &[record],
349 Limits {
350 min_chars: 4,
351 max_findings: 8,
352 },
353 );
354
355 check_eq(findings.len(), 2, "finding count")?;
356 check_eq(
357 findings.first().map(StringFinding::value),
358 Some("abcd"),
359 "ascii",
360 )?;
361 check_eq(
362 findings.get(1).map(StringFinding::encoding),
363 Some(StringEncoding::Utf16Le),
364 "utf16 encoding",
365 )?;
366 check_eq(
367 findings.get(1).map(StringFinding::value),
368 Some("WXYZ"),
369 "utf16",
370 )
371 }
372
373 fn test_record(payload: Vec<u8>) -> Result<Record, String> {
374 let payload_len = u32::try_from(payload.len()).map_err(|err| err.to_string())?;
375 Ok(Record::from_parts_for_test(RecordTestParts {
376 index: 3,
377 offset: 0,
378 subtype: DecodedString::from_text_for_test("artifact"),
379 name: DecodedString::from_text_for_test("artifact.bin"),
380 compressed: false,
381 compressed_size: payload_len,
382 uncompressed_size: payload_len,
383 checksum: 0,
384 checksum_valid: false,
385 creation_time: 0,
386 last_write_time: 0,
387 encrypted_data: payload.clone(),
388 decrypted_data: payload,
389 decompressed_data: None,
390 decompression_status: DecompressionStatus::NotCompressed,
391 profile: crate::RecordProfile {
392 encoding: crate::Encoding::Ea06,
393 encryption: crate::EncryptionProfile::Ea06Lame,
394 compression: crate::CompressionProfile::None,
395 },
396 }))
397 }
398
399 fn check_eq<T>(actual: T, expected: T, context: &str) -> Result<(), String>
400 where
401 T: core::fmt::Debug + PartialEq,
402 {
403 if actual == expected {
404 Ok(())
405 } else {
406 Err(format!("{context}: got {actual:?}, expected {expected:?}"))
407 }
408 }
409}