autoit/au3/mod.rs
1//! AU3 compiled payload record parsing.
2
3mod reader;
4
5use crate::{Encoding, Error, RecognitionFailure, crypto, decompress};
6use reader::Reader;
7
8const FILE_MARKER_LEN: usize = 4;
9
10/// Parser limits for AU3 record extraction.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct Limits {
13 /// Maximum number of records to parse.
14 pub max_records: usize,
15 /// Maximum encrypted blob size accepted for a single record.
16 pub max_encrypted_blob_size: usize,
17 /// Maximum decoded metadata string length in bytes.
18 pub max_metadata_string_bytes: usize,
19 /// Maximum decompressed blob size accepted for a single record.
20 pub max_decompressed_blob_size: usize,
21}
22
23impl Default for Limits {
24 /// Returns conservative default parser limits.
25 ///
26 /// Allows up to 256 records, 64 MiB per encrypted blob, 1 MiB per decoded
27 /// metadata string, and 64 MiB per decompressed blob.
28 ///
29 /// # Returns
30 ///
31 /// A [`Limits`] populated with the default caps.
32 fn default() -> Self {
33 Self {
34 max_records: 256,
35 max_encrypted_blob_size: 64 * 1024 * 1024,
36 max_metadata_string_bytes: 1024 * 1024,
37 max_decompressed_blob_size: 64 * 1024 * 1024,
38 }
39 }
40}
41
42/// Decompression status for a record payload.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum DecompressionStatus {
45 /// The record was not marked compressed.
46 NotCompressed,
47 /// The record was marked compressed and decompressed successfully.
48 Decompressed,
49 /// The record was marked compressed but decompression failed.
50 Failed {
51 /// Structured reason for the decompression failure.
52 reason: RecognitionFailure,
53 },
54}
55
56/// Diagnostic produced when AU3 record parsing stops after partial recovery.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct RecordParseDiagnostic {
59 /// Zero-based record index that failed to parse.
60 pub record_index: usize,
61 /// File offset where the failing record began, or where a configured limit
62 /// stopped parsing.
63 pub offset: usize,
64 /// Structured reason parsing stopped.
65 pub reason: RecognitionFailure,
66}
67
68/// Result of tolerant AU3 record parsing.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct RecordParseReport {
71 records: Vec<Record>,
72 diagnostic: Option<RecordParseDiagnostic>,
73}
74
75impl RecordParseReport {
76 /// Returns the parsed records recovered before parsing stopped.
77 ///
78 /// # Returns
79 ///
80 /// A borrowed slice of the recovered [`Record`]s, in parse order.
81 #[must_use]
82 pub fn records(&self) -> &[Record] {
83 self.records.as_slice()
84 }
85
86 /// Consumes the report and returns the recovered records by value.
87 ///
88 /// # Returns
89 ///
90 /// The owned [`Record`] vector recovered before parsing stopped.
91 #[must_use]
92 pub fn into_records(self) -> Vec<Record> {
93 self.records
94 }
95
96 /// Returns the diagnostic describing why parsing stopped, when present.
97 ///
98 /// # Returns
99 ///
100 /// `Some` with a [`RecordParseDiagnostic`] when parsing stopped on a failure or
101 /// limit, or `None` when the stream ended cleanly at a non-`FILE` marker.
102 #[must_use]
103 pub const fn diagnostic(&self) -> Option<RecordParseDiagnostic> {
104 self.diagnostic
105 }
106}
107
108/// Cryptographic stream used for record decryption.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum EncryptionProfile {
111 /// EA05 Mersenne Twister-derived stream.
112 Ea05Mt,
113 /// EA06 LAME-derived stream.
114 Ea06Lame,
115}
116
117/// Compression wrapper/profile observed for record data.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum CompressionProfile {
120 /// Record was not marked compressed.
121 None,
122 /// EA04 AutoIt LZ wrapper (same LZ scheme as EA05).
123 Ea04,
124 /// EA05 AutoIt LZ wrapper.
125 Ea05,
126 /// EA06 AutoIt LZ wrapper.
127 Ea06,
128 /// JB00 adaptive-Huffman wrapper (AutoHotkey-classic / AutoIt v2-era).
129 Jb00,
130 /// JB01 adaptive-Huffman wrapper (AutoHotkey-classic / AutoIt v2-era).
131 Jb01,
132 /// Record was marked compressed but wrapper magic was unrecognized or data
133 /// was too short.
134 Unknown,
135}
136
137/// Extraction profile facts for a record.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub struct RecordProfile {
140 /// AU3 encoding profile used for record metadata and payload decryption.
141 pub encoding: Encoding,
142 /// Cryptographic stream used for decryption.
143 pub encryption: EncryptionProfile,
144 /// Compression wrapper/profile observed for payload data.
145 pub compression: CompressionProfile,
146}
147
148/// One AU3 resource record.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct Record {
151 index: usize,
152 offset: usize,
153 subtype: DecodedString,
154 name: DecodedString,
155 compressed: bool,
156 compressed_size: u32,
157 uncompressed_size: u32,
158 checksum: u32,
159 checksum_valid: bool,
160 creation_time: u64,
161 last_write_time: u64,
162 encrypted_data: Vec<u8>,
163 decrypted_data: Vec<u8>,
164 decompressed_data: Option<Vec<u8>>,
165 decompression_status: DecompressionStatus,
166 profile: RecordProfile,
167}
168
169impl Record {
170 /// Returns the zero-based record index.
171 ///
172 /// # Returns
173 ///
174 /// The position of this record within the parsed stream.
175 #[must_use]
176 pub const fn index(&self) -> usize {
177 self.index
178 }
179
180 /// Returns the file offset where this record starts.
181 ///
182 /// # Returns
183 ///
184 /// The absolute byte offset of the record's `FILE` marker in the input.
185 #[must_use]
186 pub const fn offset(&self) -> usize {
187 self.offset
188 }
189
190 /// Returns the decoded subtype text.
191 ///
192 /// # Returns
193 ///
194 /// The record subtype as decoded text (e.g. the `>>>AUTOIT SCRIPT<<<` tag).
195 #[must_use]
196 pub fn subtype(&self) -> &str {
197 self.subtype.text()
198 }
199
200 /// Returns the raw decrypted subtype bytes.
201 ///
202 /// # Returns
203 ///
204 /// The decrypted subtype bytes prior to text decoding.
205 #[must_use]
206 pub fn subtype_bytes(&self) -> &[u8] {
207 self.subtype.bytes()
208 }
209
210 /// Returns the decoded stored name/path text.
211 ///
212 /// # Returns
213 ///
214 /// The record's stored name or path as decoded text.
215 #[must_use]
216 pub fn name(&self) -> &str {
217 self.name.text()
218 }
219
220 /// Returns the raw decrypted name/path bytes.
221 ///
222 /// # Returns
223 ///
224 /// The decrypted name/path bytes prior to text decoding.
225 #[must_use]
226 pub fn name_bytes(&self) -> &[u8] {
227 self.name.bytes()
228 }
229
230 /// Returns whether the record payload is marked compressed.
231 ///
232 /// # Returns
233 ///
234 /// `true` when the record's compression flag is set, `false` otherwise.
235 #[must_use]
236 pub const fn compressed(&self) -> bool {
237 self.compressed
238 }
239
240 /// Returns the stored encrypted/compressed data size.
241 ///
242 /// # Returns
243 ///
244 /// The byte length of the on-disk encrypted (and possibly compressed) blob.
245 #[must_use]
246 pub const fn compressed_size(&self) -> u32 {
247 self.compressed_size
248 }
249
250 /// Returns the advertised uncompressed data size.
251 ///
252 /// # Returns
253 ///
254 /// The uncompressed byte length declared in the record header.
255 #[must_use]
256 pub const fn uncompressed_size(&self) -> u32 {
257 self.uncompressed_size
258 }
259
260 /// Returns the stored checksum value.
261 ///
262 /// For EA04/JB01 records, which carry no checksum field, this is `0`.
263 ///
264 /// # Returns
265 ///
266 /// The stored adler32 checksum (EA05/EA06), or `0` when absent (EA04/JB01).
267 #[must_use]
268 pub const fn checksum(&self) -> u32 {
269 self.checksum
270 }
271
272 /// Returns whether the stored checksum matches the decrypted record data.
273 ///
274 /// EA04/JB01 records carry no checksum, so they are treated as validated and
275 /// this returns `true`.
276 ///
277 /// # Returns
278 ///
279 /// `true` when the adler32 of the decrypted data matches the stored checksum,
280 /// or when no checksum is present (EA04/JB01); `false` on mismatch.
281 #[must_use]
282 pub const fn checksum_valid(&self) -> bool {
283 self.checksum_valid
284 }
285
286 /// Returns the creation timestamp as a raw Windows FILETIME.
287 ///
288 /// # Returns
289 ///
290 /// The record's creation time as a 64-bit Windows FILETIME value.
291 #[must_use]
292 pub const fn creation_time(&self) -> u64 {
293 self.creation_time
294 }
295
296 /// Returns the last-write timestamp as a raw Windows FILETIME.
297 ///
298 /// # Returns
299 ///
300 /// The record's last-write time as a 64-bit Windows FILETIME value.
301 #[must_use]
302 pub const fn last_write_time(&self) -> u64 {
303 self.last_write_time
304 }
305
306 /// Returns the encrypted record data bytes as stored on disk.
307 ///
308 /// # Returns
309 ///
310 /// The raw encrypted (and possibly compressed) payload bytes.
311 #[must_use]
312 pub fn encrypted_data(&self) -> &[u8] {
313 self.encrypted_data.as_slice()
314 }
315
316 /// Returns the decrypted record data bytes.
317 ///
318 /// These are still in their compressed (wrapped) form when the record is marked
319 /// compressed.
320 ///
321 /// # Returns
322 ///
323 /// The decrypted payload bytes, prior to any decompression.
324 #[must_use]
325 pub fn decrypted_data(&self) -> &[u8] {
326 self.decrypted_data.as_slice()
327 }
328
329 /// Returns the decompressed record data bytes when decompression succeeded.
330 ///
331 /// # Returns
332 ///
333 /// `Some` with the decompressed bytes when the record was compressed and
334 /// decompression succeeded, or `None` when the record was not compressed or
335 /// decompression failed.
336 #[must_use]
337 pub fn decompressed_data(&self) -> Option<&[u8]> {
338 self.decompressed_data.as_deref()
339 }
340
341 /// Returns the best available payload bytes.
342 ///
343 /// This is decompressed data when present, otherwise decrypted record data.
344 ///
345 /// # Returns
346 ///
347 /// The decompressed bytes when available, otherwise the decrypted bytes.
348 #[must_use]
349 pub fn payload_data(&self) -> &[u8] {
350 match self.decompressed_data.as_deref() {
351 Some(data) => data,
352 None => self.decrypted_data.as_slice(),
353 }
354 }
355
356 /// Returns the decompression status for this record.
357 ///
358 /// # Returns
359 ///
360 /// A [`DecompressionStatus`] describing whether the record was compressed and,
361 /// if so, whether decompression succeeded.
362 #[must_use]
363 pub const fn decompression_status(&self) -> DecompressionStatus {
364 self.decompression_status
365 }
366
367 /// Returns the extraction profile facts for this record.
368 ///
369 /// # Returns
370 ///
371 /// A [`RecordProfile`] describing the encoding, encryption stream, and
372 /// compression wrapper observed for the record.
373 #[must_use]
374 pub const fn profile(&self) -> RecordProfile {
375 self.profile
376 }
377
378 /// Builds a [`Record`] directly from explicit parts, for tests.
379 ///
380 /// Bypasses parsing so tests can construct records with arbitrary field values.
381 ///
382 /// # Arguments
383 ///
384 /// * `parts` - The [`RecordTestParts`] supplying every field of the record.
385 ///
386 /// # Returns
387 ///
388 /// A [`Record`] whose fields are taken verbatim from `parts`.
389 #[cfg(test)]
390 pub fn from_parts_for_test(parts: RecordTestParts) -> Self {
391 Self {
392 index: parts.index,
393 offset: parts.offset,
394 subtype: parts.subtype,
395 name: parts.name,
396 compressed: parts.compressed,
397 compressed_size: parts.compressed_size,
398 uncompressed_size: parts.uncompressed_size,
399 checksum: parts.checksum,
400 checksum_valid: parts.checksum_valid,
401 creation_time: parts.creation_time,
402 last_write_time: parts.last_write_time,
403 encrypted_data: parts.encrypted_data,
404 decrypted_data: parts.decrypted_data,
405 decompressed_data: parts.decompressed_data,
406 decompression_status: parts.decompression_status,
407 profile: parts.profile,
408 }
409 }
410}
411
412/// Field values used to construct a [`Record`] directly in tests.
413#[cfg(test)]
414#[derive(Debug, Clone)]
415pub struct RecordTestParts {
416 /// Zero-based record index.
417 pub index: usize,
418 /// File offset where the record begins.
419 pub offset: usize,
420 /// Decoded subtype string.
421 pub subtype: DecodedString,
422 /// Decoded name/path string.
423 pub name: DecodedString,
424 /// Whether the payload is marked compressed.
425 pub compressed: bool,
426 /// Stored encrypted/compressed data size.
427 pub compressed_size: u32,
428 /// Advertised uncompressed data size.
429 pub uncompressed_size: u32,
430 /// Stored checksum value.
431 pub checksum: u32,
432 /// Whether the checksum was validated.
433 pub checksum_valid: bool,
434 /// Creation timestamp as raw Windows FILETIME.
435 pub creation_time: u64,
436 /// Last-write timestamp as raw Windows FILETIME.
437 pub last_write_time: u64,
438 /// Encrypted record data bytes.
439 pub encrypted_data: Vec<u8>,
440 /// Decrypted record data bytes.
441 pub decrypted_data: Vec<u8>,
442 /// Decompressed record data bytes, when present.
443 pub decompressed_data: Option<Vec<u8>>,
444 /// Decompression status for the record.
445 pub decompression_status: DecompressionStatus,
446 /// Extraction profile facts for the record.
447 pub profile: RecordProfile,
448}
449
450/// Decoded string plus its raw decrypted bytes.
451#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct DecodedString {
453 raw: Vec<u8>,
454 text: String,
455}
456
457impl DecodedString {
458 /// Pairs raw decrypted bytes with their decoded text.
459 ///
460 /// # Arguments
461 ///
462 /// * `raw` - The raw decrypted string bytes.
463 /// * `text` - The decoded text form of `raw`.
464 ///
465 /// # Returns
466 ///
467 /// A [`DecodedString`] holding both representations.
468 fn new(raw: Vec<u8>, text: String) -> Self {
469 Self { raw, text }
470 }
471
472 /// Builds a [`DecodedString`] from text, for tests.
473 ///
474 /// The raw bytes are taken as the UTF-8 encoding of `text`.
475 ///
476 /// # Arguments
477 ///
478 /// * `text` - The text to wrap; its UTF-8 bytes become the raw bytes.
479 ///
480 /// # Returns
481 ///
482 /// A [`DecodedString`] whose text and raw bytes both derive from `text`.
483 #[cfg(test)]
484 pub fn from_text_for_test(text: &str) -> Self {
485 Self {
486 raw: text.as_bytes().to_vec(),
487 text: text.to_string(),
488 }
489 }
490
491 /// Returns the decoded text.
492 ///
493 /// # Returns
494 ///
495 /// The decoded text representation of the string.
496 #[must_use]
497 pub fn text(&self) -> &str {
498 self.text.as_str()
499 }
500
501 /// Returns the raw decrypted string bytes.
502 ///
503 /// # Returns
504 ///
505 /// The decrypted bytes prior to text decoding.
506 #[must_use]
507 pub fn bytes(&self) -> &[u8] {
508 self.raw.as_slice()
509 }
510}
511
512/// Per-encoding constants and behavior for decrypting and decoding records.
513#[derive(Debug, Clone, Copy)]
514struct Profile {
515 /// AU3 encoding this profile applies to.
516 encoding: Encoding,
517 /// Decryption key for the `FILE` record marker.
518 file_key: u32,
519 /// XOR mask applied to the encoded subtype character length.
520 subtype_len_xor: u32,
521 /// Base decryption key for the subtype string (combined with its length).
522 subtype_key_base: u32,
523 /// XOR mask applied to the encoded name character length.
524 name_len_xor: u32,
525 /// Base decryption key for the name string (combined with its length).
526 name_key_base: u32,
527 /// XOR mask applied to the encoded compressed/uncompressed size fields.
528 size_xor: u32,
529 /// XOR mask applied to the encoded checksum field (unused when absent).
530 checksum_xor: u32,
531 /// Base value for deriving the payload data-decryption key.
532 data_key_base: u32,
533 /// Whether each record carries a 4-byte checksum field between the size
534 /// fields and the timestamps. EA04 omits it; EA05/EA06 include it.
535 has_checksum: bool,
536}
537
538impl Profile {
539 /// Builds the decryption/decoding profile for an AU3 encoding.
540 ///
541 /// EA04 and JB01 share EA05's Mersenne Twister field keys but carry no
542 /// per-record checksum field, so their `checksum_xor` is unused. EA06 uses its
543 /// own LAME-derived key set.
544 ///
545 /// # Arguments
546 ///
547 /// * `encoding` - The AU3 encoding to build a profile for.
548 ///
549 /// # Returns
550 ///
551 /// A [`Profile`] holding the constants and flags for `encoding`.
552 ///
553 /// # Errors
554 ///
555 /// Currently infallible for all supported [`Encoding`] variants; the `Result`
556 /// is retained for forward compatibility.
557 fn for_encoding(encoding: Encoding) -> Result<Self, Error> {
558 match encoding {
559 // EA04 shares EA05's MT field keys but has no per-record checksum
560 // field; `checksum_xor` is therefore unused.
561 Encoding::Ea04 => Ok(Self {
562 encoding,
563 file_key: 0x16fa,
564 subtype_len_xor: 0x29bc,
565 subtype_key_base: 0xa25e,
566 name_len_xor: 0x29ac,
567 name_key_base: 0xf25e,
568 size_xor: 0x45aa,
569 checksum_xor: 0,
570 data_key_base: 0x22af,
571 has_checksum: false,
572 }),
573 Encoding::Ea05 => Ok(Self {
574 encoding,
575 file_key: 0x16fa,
576 subtype_len_xor: 0x29bc,
577 subtype_key_base: 0xa25e,
578 name_len_xor: 0x29ac,
579 name_key_base: 0xf25e,
580 size_xor: 0x45aa,
581 checksum_xor: 0xc3d2,
582 data_key_base: 0x22af,
583 has_checksum: true,
584 }),
585 Encoding::Ea06 => Ok(Self {
586 encoding,
587 file_key: 0x18ee,
588 subtype_len_xor: 0xadbc,
589 subtype_key_base: 0xb33f,
590 name_len_xor: 0xf820,
591 name_key_base: 0xf479,
592 size_xor: 0x87bc,
593 checksum_xor: 0xa685,
594 data_key_base: 0x2477,
595 has_checksum: true,
596 }),
597 // JB01 (AutoHotkey-classic / AutoIt v2-era) shares EA05's MT field
598 // keys and EA04's checksum-less record layout.
599 Encoding::Jb01 => Ok(Self {
600 encoding,
601 file_key: 0x16fa,
602 subtype_len_xor: 0x29bc,
603 subtype_key_base: 0xa25e,
604 name_len_xor: 0x29ac,
605 name_key_base: 0xf25e,
606 size_xor: 0x45aa,
607 checksum_xor: 0,
608 data_key_base: 0x22af,
609 has_checksum: false,
610 }),
611 }
612 }
613
614 /// Decrypts a byte slice using this profile's cipher and a key.
615 ///
616 /// EA04/EA05/JB01 use the Mersenne Twister stream cipher; EA06 uses the LAME
617 /// stream cipher.
618 ///
619 /// # Arguments
620 ///
621 /// * `data` - The encrypted bytes to decrypt.
622 /// * `key` - The seed key for the stream cipher.
623 ///
624 /// # Returns
625 ///
626 /// The decrypted bytes on success.
627 ///
628 /// # Errors
629 ///
630 /// Returns [`Error::crypto_mismatch`] when the underlying cipher rejects the
631 /// input (e.g. the keystream cannot be derived for the given input).
632 fn decrypt(self, data: &[u8], key: u32) -> Result<Vec<u8>, Error> {
633 match self.encoding {
634 Encoding::Ea04 | Encoding::Ea05 | Encoding::Jb01 => {
635 crypto::mt::decrypt(data, key).ok_or_else(Error::crypto_mismatch)
636 }
637 Encoding::Ea06 => crypto::lame::decrypt(data, key).ok_or_else(Error::crypto_mismatch),
638 }
639 }
640
641 /// Returns the byte width of one metadata-string character for this encoding.
642 ///
643 /// EA04/EA05/JB01 store single-byte characters; EA06 stores UTF-16 code units.
644 ///
645 /// # Returns
646 ///
647 /// `1` for single-byte encodings, `2` for the UTF-16 EA06 encoding.
648 fn character_width(self) -> usize {
649 match self.encoding {
650 Encoding::Ea04 | Encoding::Ea05 | Encoding::Jb01 => 1,
651 Encoding::Ea06 => 2,
652 }
653 }
654
655 /// Returns the cryptographic stream identifier for this encoding.
656 ///
657 /// # Returns
658 ///
659 /// [`EncryptionProfile::Ea05Mt`] for EA04/EA05/JB01, or
660 /// [`EncryptionProfile::Ea06Lame`] for EA06.
661 fn encryption_profile(self) -> EncryptionProfile {
662 match self.encoding {
663 Encoding::Ea04 | Encoding::Ea05 | Encoding::Jb01 => EncryptionProfile::Ea05Mt,
664 Encoding::Ea06 => EncryptionProfile::Ea06Lame,
665 }
666 }
667
668 /// Derives the payload data-decryption key for this encoding.
669 ///
670 /// For EA04/EA05/JB01 the key is the data-key base plus the salt/checksum
671 /// (wrapping); EA06 ignores the salt and uses the data-key base directly.
672 ///
673 /// # Arguments
674 ///
675 /// * `checksum` - The per-stream salt value (the EA05 data-key salt) folded
676 /// into the key for MT-based encodings; ignored for EA06.
677 ///
678 /// # Returns
679 ///
680 /// The seed key used to decrypt the record payload.
681 fn data_key(self, checksum: u32) -> u32 {
682 match self.encoding {
683 Encoding::Ea04 | Encoding::Ea05 | Encoding::Jb01 => {
684 checksum.wrapping_add(self.data_key_base)
685 }
686 Encoding::Ea06 => self.data_key_base,
687 }
688 }
689}
690
691/// Parses AU3 records from `offset`, strictly failing on any partial-parse stop.
692///
693/// Parsing stops normally when the next record marker does not decrypt to
694/// `FILE`. If no `FILE` marker is present at `offset`, this returns an empty
695/// vector. Unlike [`parse_records_partial`], any diagnostic recorded mid-stream
696/// is converted into an error and the recovered records are discarded.
697///
698/// # Arguments
699///
700/// * `data` - The full input buffer to parse from.
701/// * `offset` - Absolute offset of the first record.
702/// * `encoding` - The AU3 [`Encoding`] to interpret records with.
703/// * `limits` - Parser [`Limits`] bounding record count and blob sizes.
704///
705/// # Returns
706///
707/// The fully parsed [`Record`] vector, empty when no `FILE` marker is present.
708///
709/// # Errors
710///
711/// Propagates the encoding-profiling error for an unsupported encoding, and
712/// converts any partial-parse diagnostic into the matching [`Error`] — including
713/// [`Error::truncated`], [`Error::crypto_mismatch`], [`Error::compression_error`],
714/// or [`Error::limit_exceeded`] when the configured limits are reached.
715pub fn parse_records(
716 data: &[u8],
717 offset: usize,
718 encoding: Encoding,
719 limits: Limits,
720) -> Result<Vec<Record>, Error> {
721 let report = parse_records_partial(data, offset, encoding, limits)?;
722 if let Some(diagnostic) = report.diagnostic() {
723 return Err(error_from_failure(diagnostic.reason));
724 }
725 Ok(report.into_records())
726}
727
728/// Parses AU3 records from `offset`, tolerantly preserving records recovered
729/// before a later parse failure.
730///
731/// Parsing stops normally when the next record marker does not decrypt to
732/// `FILE`. If no `FILE` marker is present at `offset`, this returns an empty
733/// report without a diagnostic. A failure while parsing a record, or reaching
734/// the `max_records` limit, is reported as a [`RecordParseDiagnostic`] on the
735/// returned report rather than as an `Err`, keeping the earlier records.
736///
737/// The EA05 data-key salt is computed once from the 16 bytes preceding the
738/// record stream and reused for every record; EA04/JB01 use a salt of `0` and
739/// EA06 ignores the salt entirely.
740///
741/// # Arguments
742///
743/// * `data` - The full input buffer to parse from.
744/// * `offset` - Absolute offset of the first record.
745/// * `encoding` - The AU3 [`Encoding`] to interpret records with.
746/// * `limits` - Parser [`Limits`] bounding record count and blob sizes.
747///
748/// # Returns
749///
750/// A [`RecordParseReport`] holding the recovered records and, when parsing
751/// stopped on a failure or limit, a [`RecordParseDiagnostic`].
752///
753/// # Errors
754///
755/// Returns an error when the encoding cannot be profiled. Per-record parse
756/// failures are not returned as `Err`; they are captured in the report's
757/// diagnostic instead.
758pub fn parse_records_partial(
759 data: &[u8],
760 offset: usize,
761 encoding: Encoding,
762 limits: Limits,
763) -> Result<RecordParseReport, Error> {
764 let profile = Profile::for_encoding(encoding)?;
765 let mut reader = Reader::new(data, offset);
766 let mut records = Vec::new();
767
768 // The EA05 data-decryption key salt is the byte sum of the 16 bytes that
769 // precede the record stream. It is fixed for the whole stream, so compute
770 // it once from the first record offset and reuse it for every record.
771 // (EA06 ignores the salt entirely; see `Profile::data_key`.)
772 //
773 // EA04 derives its salt from the signed byte sum of the compiled-in
774 // passphrase hash, which is empty (sum 0) for password-free scripts; only
775 // those are supported here, so the salt is 0.
776 let data_key_salt = match encoding {
777 Encoding::Ea04 | Encoding::Jb01 => 0,
778 _ => ea05_data_key_salt(&reader, offset),
779 };
780
781 while records.len() < limits.max_records {
782 let index = records.len();
783 let record_offset = reader.position();
784 let parse_result = parse_one(&mut reader, profile, index, limits, data_key_salt);
785 let Some(record) = (match parse_result {
786 Ok(record) => record,
787 Err(err) => {
788 return Ok(RecordParseReport {
789 records,
790 diagnostic: Some(RecordParseDiagnostic {
791 record_index: index,
792 offset: record_offset,
793 reason: failure_from_error(&err),
794 }),
795 });
796 }
797 }) else {
798 return Ok(RecordParseReport {
799 records,
800 diagnostic: None,
801 });
802 };
803 records.push(record);
804 }
805
806 Ok(RecordParseReport {
807 records,
808 diagnostic: Some(RecordParseDiagnostic {
809 record_index: limits.max_records,
810 offset: reader.position(),
811 reason: RecognitionFailure::LimitExceeded,
812 }),
813 })
814}
815
816/// Maps an [`Error`] to the [`RecognitionFailure`] recorded in a diagnostic.
817///
818/// Errors without an associated recognition failure are reported as
819/// [`RecognitionFailure::MalformedContainer`].
820///
821/// # Arguments
822///
823/// * `err` - The error to classify.
824///
825/// # Returns
826///
827/// The structured [`RecognitionFailure`] for `err`.
828fn failure_from_error(err: &Error) -> RecognitionFailure {
829 match err.recognition_failure() {
830 Some(reason) => reason,
831 None => RecognitionFailure::MalformedContainer,
832 }
833}
834
835/// Maps a [`RecognitionFailure`] back to its corresponding [`Error`].
836///
837/// Inverse of [`failure_from_error`], used by [`parse_records`] to turn a
838/// diagnostic into a strict error.
839///
840/// # Arguments
841///
842/// * `failure` - The structured failure to convert.
843///
844/// # Returns
845///
846/// The [`Error`] constructed for `failure` (e.g. [`Error::truncated`],
847/// [`Error::limit_exceeded`], or [`Error::crypto_mismatch`]).
848fn error_from_failure(failure: RecognitionFailure) -> Error {
849 match failure {
850 RecognitionFailure::NotRecognized => Error::not_recognized(),
851 RecognitionFailure::UnsupportedEncoding => Error::unsupported_encoding(),
852 RecognitionFailure::MalformedContainer => Error::malformed_container(),
853 RecognitionFailure::Truncated => Error::truncated(),
854 RecognitionFailure::LimitExceeded => Error::limit_exceeded(),
855 RecognitionFailure::CryptoMismatch => Error::crypto_mismatch(),
856 RecognitionFailure::CompressionError => Error::compression_error(),
857 RecognitionFailure::TokenError => Error::token_error(),
858 }
859}
860
861/// Parses a single AU3 record at the reader's current position.
862///
863/// Reads and decrypts the `FILE` marker, the subtype and name strings, the
864/// size/checksum/timestamp header, and the encrypted payload. EA05/EA06 verify
865/// an Adler-32 checksum against the decrypted data; EA04/JB01 have no checksum
866/// field and are treated as integrity-validated. The payload is decompressed
867/// when the record's compressed flag is set.
868///
869/// # Arguments
870///
871/// * `reader` - The [`Reader`] positioned at the start of the record.
872/// * `profile` - The encoding [`Profile`] supplying keys and layout flags.
873/// * `index` - The zero-based index to assign to the parsed record.
874/// * `limits` - Parser [`Limits`] bounding string and blob sizes.
875/// * `data_key_salt` - The per-stream salt folded into the payload data key.
876///
877/// # Returns
878///
879/// `Some` with the parsed [`Record`] when a `FILE` marker is present, or `None`
880/// when the reader is exhausted or the next marker does not decrypt to `FILE`
881/// (signalling a clean end of stream).
882///
883/// # Errors
884///
885/// Returns [`Error::truncated`] when the input ends mid-record,
886/// [`Error::crypto_mismatch`] when decryption fails, [`Error::limit_exceeded`]
887/// when a size exceeds the configured limits or overflows `usize`, and the
888/// errors propagated from [`read_string`] and [`Profile::decrypt`].
889fn parse_one(
890 reader: &mut Reader<'_>,
891 profile: Profile,
892 index: usize,
893 limits: Limits,
894 data_key_salt: u32,
895) -> Result<Option<Record>, Error> {
896 if reader.remaining() == 0 {
897 return Ok(None);
898 }
899 let offset = reader.position();
900 let marker = reader.read_bytes(FILE_MARKER_LEN)?;
901 let decrypted_marker = profile.decrypt(marker, profile.file_key)?;
902 if decrypted_marker != b"FILE" {
903 return Ok(None);
904 }
905
906 let subtype_len = reader.read_u32_le()? ^ profile.subtype_len_xor;
907 let subtype = read_string(
908 reader,
909 profile,
910 subtype_len,
911 profile.subtype_key_base,
912 limits.max_metadata_string_bytes,
913 )?;
914
915 let name_len = reader.read_u32_le()? ^ profile.name_len_xor;
916 let name = read_string(
917 reader,
918 profile,
919 name_len,
920 profile.name_key_base,
921 limits.max_metadata_string_bytes,
922 )?;
923
924 let compressed = reader.read_u8()? != 0;
925 let compressed_size = reader.read_u32_le()? ^ profile.size_xor;
926 let uncompressed_size = reader.read_u32_le()? ^ profile.size_xor;
927 // EA04 records have no checksum field; EA05/EA06 store an Adler-32 here.
928 let checksum = if profile.has_checksum {
929 reader.read_u32_le()? ^ profile.checksum_xor
930 } else {
931 0
932 };
933 let creation_time = reader.read_u64_le()?;
934 let last_write_time = reader.read_u64_le()?;
935
936 let encrypted_len = usize::try_from(compressed_size).map_err(|_err| Error::limit_exceeded())?;
937 if encrypted_len > limits.max_encrypted_blob_size {
938 return Err(Error::limit_exceeded());
939 }
940 let encrypted_data = reader.read_bytes(encrypted_len)?.to_vec();
941 let decrypted_data =
942 profile.decrypt(encrypted_data.as_slice(), profile.data_key(data_key_salt))?;
943 // Without a stored checksum (EA04) there is nothing to fail against, so the
944 // record is treated as integrity-validated.
945 let checksum_valid = !profile.has_checksum
946 || adler32(decrypted_data.as_slice()).is_some_and(|actual| actual == checksum);
947 let (decompressed_data, decompression_status) = maybe_decompress(
948 decrypted_data.as_slice(),
949 compressed,
950 limits.max_decompressed_blob_size,
951 );
952 let record_profile = RecordProfile {
953 encoding: profile.encoding,
954 encryption: profile.encryption_profile(),
955 compression: compression_profile(decrypted_data.as_slice(), compressed),
956 };
957
958 Ok(Some(Record {
959 index,
960 offset,
961 subtype,
962 name,
963 compressed,
964 compressed_size,
965 uncompressed_size,
966 checksum,
967 checksum_valid,
968 creation_time,
969 last_write_time,
970 encrypted_data,
971 decrypted_data,
972 decompressed_data,
973 decompression_status,
974 profile: record_profile,
975 }))
976}
977
978/// Decompresses record data when it is marked compressed.
979///
980/// When `compressed` is false this is a no-op. Otherwise the data is passed to
981/// the decompressor; a failure is mapped to its [`RecognitionFailure`] (defaulting
982/// to [`RecognitionFailure::CompressionError`] when none is associated) and
983/// surfaced via [`DecompressionStatus::Failed`] rather than returned as an error.
984///
985/// # Arguments
986///
987/// * `data` - The decrypted (possibly compressed) payload bytes.
988/// * `compressed` - Whether the record was marked compressed.
989/// * `max_output_size` - Maximum allowed decompressed size.
990///
991/// # Returns
992///
993/// A tuple of the optional decompressed bytes and the resulting
994/// [`DecompressionStatus`].
995fn maybe_decompress(
996 data: &[u8],
997 compressed: bool,
998 max_output_size: usize,
999) -> (Option<Vec<u8>>, DecompressionStatus) {
1000 if !compressed {
1001 return (None, DecompressionStatus::NotCompressed);
1002 }
1003 match decompress::decompress(data, decompress::Limits { max_output_size }) {
1004 Ok(bytes) => (Some(bytes), DecompressionStatus::Decompressed),
1005 Err(err) => {
1006 let reason = match err.recognition_failure() {
1007 Some(reason) => reason,
1008 None => RecognitionFailure::CompressionError,
1009 };
1010 (None, DecompressionStatus::Failed { reason })
1011 }
1012 }
1013}
1014
1015/// Computes the EA05 payload data-key salt from bytes preceding the record stream.
1016///
1017/// The salt is the unsigned byte sum of the 16 bytes immediately before the first
1018/// record, but only when an `EA05` marker sits in the four bytes just before those
1019/// 16. It is fixed for the whole stream, so it is computed once and reused.
1020///
1021/// # Arguments
1022///
1023/// * `reader` - The [`Reader`] over the input, used for bounds-checked range reads.
1024/// * `record_offset` - Absolute offset of the first record.
1025///
1026/// # Returns
1027///
1028/// The byte-sum salt, or `0` when the preceding `EA05` marker or salt bytes are
1029/// absent or out of bounds.
1030fn ea05_data_key_salt(reader: &Reader<'_>, record_offset: usize) -> u32 {
1031 let Some(marker_start) = record_offset.checked_sub(20) else {
1032 return 0;
1033 };
1034 let Some(marker_end) = marker_start.checked_add(4) else {
1035 return 0;
1036 };
1037 if reader.range(marker_start, marker_end) != Some(b"EA05") {
1038 return 0;
1039 }
1040 let Some(salt_start) = record_offset.checked_sub(16) else {
1041 return 0;
1042 };
1043 reader
1044 .range(salt_start, record_offset)
1045 .map_or(0, |bytes| bytes.iter().map(|byte| u32::from(*byte)).sum())
1046}
1047
1048/// Identifies the compression wrapper from the decrypted payload's leading magic.
1049///
1050/// Inspects the first four bytes for a known wrapper magic (`EA04`, `EA05`,
1051/// `EA06`, `JB00`, `JB01`). An unrecognized or too-short magic on a record marked
1052/// compressed yields [`CompressionProfile::Unknown`].
1053///
1054/// # Arguments
1055///
1056/// * `data` - The decrypted payload bytes.
1057/// * `compressed` - Whether the record was marked compressed.
1058///
1059/// # Returns
1060///
1061/// The matching [`CompressionProfile`]; [`CompressionProfile::None`] when the
1062/// record is not compressed.
1063fn compression_profile(data: &[u8], compressed: bool) -> CompressionProfile {
1064 if !compressed {
1065 return CompressionProfile::None;
1066 }
1067 match data.get(0..4) {
1068 Some(magic) if magic == b"EA04" => CompressionProfile::Ea04,
1069 Some(magic) if magic == b"EA05" => CompressionProfile::Ea05,
1070 Some(magic) if magic == b"EA06" => CompressionProfile::Ea06,
1071 Some(magic) if magic == b"JB00" => CompressionProfile::Jb00,
1072 Some(magic) if magic == b"JB01" => CompressionProfile::Jb01,
1073 _ => CompressionProfile::Unknown,
1074 }
1075}
1076
1077/// Reads, decrypts, and decodes a length-prefixed metadata string.
1078///
1079/// The byte length is `char_len` times the encoding's character width. The
1080/// decryption key is `key_base` plus `char_len` (wrapping). EA04/EA05/JB01 decode
1081/// the bytes as lossy UTF-8; EA06 decodes them as lossy UTF-16.
1082///
1083/// # Arguments
1084///
1085/// * `reader` - The [`Reader`] positioned at the encrypted string bytes.
1086/// * `profile` - The encoding [`Profile`] supplying the cipher and character width.
1087/// * `char_len` - The character count (already XOR-decoded) of the string.
1088/// * `key_base` - The base decryption key, combined with `char_len`.
1089/// * `max_bytes` - Maximum allowed decoded byte length.
1090///
1091/// # Returns
1092///
1093/// A [`DecodedString`] holding the raw decrypted bytes and decoded text.
1094///
1095/// # Errors
1096///
1097/// Returns [`Error::limit_exceeded`] when the byte length overflows `usize` or
1098/// exceeds `max_bytes`, [`Error::truncated`] when the input ends early, the error
1099/// from [`Profile::decrypt`] on a crypto failure, and the [`decode_utf16_lossy`]
1100/// error for malformed EA06 UTF-16 lengths.
1101fn read_string(
1102 reader: &mut Reader<'_>,
1103 profile: Profile,
1104 char_len: u32,
1105 key_base: u32,
1106 max_bytes: usize,
1107) -> Result<DecodedString, Error> {
1108 let chars = usize::try_from(char_len).map_err(|_err| Error::limit_exceeded())?;
1109 let byte_len = chars
1110 .checked_mul(profile.character_width())
1111 .ok_or_else(Error::limit_exceeded)?;
1112 if byte_len > max_bytes {
1113 return Err(Error::limit_exceeded());
1114 }
1115 let encrypted = reader.read_bytes(byte_len)?;
1116 let key = key_base.wrapping_add(char_len);
1117 let raw = profile.decrypt(encrypted, key)?;
1118 let text = match profile.encoding {
1119 Encoding::Ea04 | Encoding::Ea05 | Encoding::Jb01 => {
1120 String::from_utf8_lossy(raw.as_slice()).into_owned()
1121 }
1122 Encoding::Ea06 => decode_utf16_lossy(raw.as_slice())?,
1123 };
1124 Ok(DecodedString::new(raw, text))
1125}
1126
1127/// Decodes little-endian UTF-16 bytes into a string, replacing invalid units.
1128///
1129/// # Arguments
1130///
1131/// * `data` - The UTF-16LE bytes to decode; its length must be even.
1132///
1133/// # Returns
1134///
1135/// The decoded string, with unpaired surrogates replaced by U+FFFD.
1136///
1137/// # Errors
1138///
1139/// Returns [`Error::truncated`] when `data` has an odd number of bytes (a
1140/// dangling half code unit).
1141fn decode_utf16_lossy(data: &[u8]) -> Result<String, Error> {
1142 let chunks = data.chunks_exact(2);
1143 if !chunks.remainder().is_empty() {
1144 return Err(Error::truncated());
1145 }
1146 let code_units: Vec<u16> = chunks
1147 .map(|chunk| {
1148 let bytes: [u8; 2] = chunk.try_into().map_err(|_err| Error::truncated())?;
1149 Ok(u16::from_le_bytes(bytes))
1150 })
1151 .collect::<Result<Vec<_>, Error>>()?;
1152 Ok(String::from_utf16_lossy(code_units.as_slice()))
1153}
1154
1155/// Computes the Adler-32 checksum of a byte slice.
1156///
1157/// Used to validate EA05/EA06 record payloads against their stored checksum. The
1158/// two running sums are reduced modulo 65521 and combined as `(b << 16) | a`.
1159///
1160/// # Arguments
1161///
1162/// * `data` - The bytes to checksum.
1163///
1164/// # Returns
1165///
1166/// `Some` with the Adler-32 value, or `None` if an internal accumulator addition
1167/// would overflow.
1168fn adler32(data: &[u8]) -> Option<u32> {
1169 const MOD_ADLER: u32 = 65_521;
1170 let mut a = 1u32;
1171 let mut b = 0u32;
1172 for byte in data {
1173 a = a.checked_add(u32::from(*byte))? % MOD_ADLER;
1174 b = b.checked_add(a)? % MOD_ADLER;
1175 }
1176 Some((b << 16) | a)
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181 use super::*;
1182
1183 #[test]
1184 fn parses_ea06_record() -> Result<(), String> {
1185 let mut data = Vec::new();
1186 append_ea06_record(
1187 &mut data,
1188 ">>>AUTOIT SCRIPT<<<",
1189 "main.au3",
1190 false,
1191 0x0102_0304_0506_0708,
1192 0x1112_1314_1516_1718,
1193 b"payload",
1194 )?;
1195
1196 let records = parse_records(data.as_slice(), 0, Encoding::Ea06, Limits::default())
1197 .map_err(|err| err.to_string())?;
1198 let record = records
1199 .first()
1200 .ok_or_else(|| "missing first record".to_string())?;
1201
1202 check_eq(records.len(), 1, "record count")?;
1203 check_eq(record.index(), 0, "record index")?;
1204 check_eq(record.offset(), 0, "record offset")?;
1205 check_eq(record.subtype(), ">>>AUTOIT SCRIPT<<<", "subtype")?;
1206 check_eq(record.name(), "main.au3", "name")?;
1207 check_eq(record.compressed(), false, "compressed")?;
1208 check_eq(record.compressed_size(), 7, "compressed size")?;
1209 check_eq(record.uncompressed_size(), 7, "uncompressed size")?;
1210 check_eq(record.checksum_valid(), true, "checksum")?;
1211 check_eq(record.creation_time(), 0x0102_0304_0506_0708, "created")?;
1212 check_eq(record.last_write_time(), 0x1112_1314_1516_1718, "modified")?;
1213 check_eq(
1214 record.profile(),
1215 RecordProfile {
1216 encoding: Encoding::Ea06,
1217 encryption: EncryptionProfile::Ea06Lame,
1218 compression: CompressionProfile::None,
1219 },
1220 "profile",
1221 )?;
1222 check_eq(record.decrypted_data(), b"payload".as_slice(), "data")
1223 }
1224
1225 #[test]
1226 fn parses_ea05_record() -> Result<(), String> {
1227 let mut data = Vec::new();
1228 append_ea05_record(
1229 &mut data,
1230 ">AUTOIT SCRIPT<",
1231 "legacy.au3",
1232 false,
1233 0,
1234 0,
1235 b"legacy",
1236 )?;
1237
1238 let records = parse_records(data.as_slice(), 0, Encoding::Ea05, Limits::default())
1239 .map_err(|err| err.to_string())?;
1240 let record = records
1241 .first()
1242 .ok_or_else(|| "missing first record".to_string())?;
1243
1244 check_eq(records.len(), 1, "record count")?;
1245 check_eq(record.subtype(), ">AUTOIT SCRIPT<", "subtype")?;
1246 check_eq(record.name(), "legacy.au3", "name")?;
1247 check_eq(record.compressed(), false, "compressed")?;
1248 check_eq(record.checksum_valid(), true, "checksum")?;
1249 check_eq(
1250 record.profile(),
1251 RecordProfile {
1252 encoding: Encoding::Ea05,
1253 encryption: EncryptionProfile::Ea05Mt,
1254 compression: CompressionProfile::None,
1255 },
1256 "profile",
1257 )?;
1258 check_eq(record.decrypted_data(), b"legacy".as_slice(), "data")
1259 }
1260
1261 #[test]
1262 fn stores_decompressed_payload_for_compressed_record() -> Result<(), String> {
1263 let compressed = ea06_literal_blob(b"ABC")?;
1264 let mut data = Vec::new();
1265 append_ea06_record(
1266 &mut data,
1267 ">AUTOIT SCRIPT<",
1268 "compressed.au3",
1269 true,
1270 0,
1271 0,
1272 compressed.as_slice(),
1273 )?;
1274
1275 let records = parse_records(data.as_slice(), 0, Encoding::Ea06, Limits::default())
1276 .map_err(|err| err.to_string())?;
1277 let record = records
1278 .first()
1279 .ok_or_else(|| "missing first record".to_string())?;
1280
1281 check_eq(
1282 record.decompression_status(),
1283 DecompressionStatus::Decompressed,
1284 "decompression status",
1285 )?;
1286 check_eq(
1287 record.decompressed_data(),
1288 Some(b"ABC".as_slice()),
1289 "decompressed data",
1290 )?;
1291 check_eq(
1292 record.profile().compression,
1293 CompressionProfile::Ea06,
1294 "compression profile",
1295 )?;
1296 check_eq(record.payload_data(), b"ABC".as_slice(), "payload data")
1297 }
1298
1299 #[test]
1300 fn partial_parser_preserves_records_before_truncated_record() -> Result<(), String> {
1301 let mut data = Vec::new();
1302 append_ea06_record(&mut data, ">AUTOIT SCRIPT<", "ok.au3", false, 0, 0, b"ok")?;
1303 let truncated_offset = data.len();
1304 append_encrypted(&mut data, b"FILE", Encoding::Ea06, 0x18ee)?;
1305
1306 let report = parse_records_partial(data.as_slice(), 0, Encoding::Ea06, Limits::default())
1307 .map_err(|err| err.to_string())?;
1308
1309 check_eq(report.records().len(), 1, "record count")?;
1310 check_eq(
1311 report.diagnostic(),
1312 Some(RecordParseDiagnostic {
1313 record_index: 1,
1314 offset: truncated_offset,
1315 reason: RecognitionFailure::Truncated,
1316 }),
1317 "diagnostic",
1318 )?;
1319
1320 let Err(err) = parse_records(data.as_slice(), 0, Encoding::Ea06, Limits::default()) else {
1321 return Err("strict parser unexpectedly succeeded".to_string());
1322 };
1323 check_eq(
1324 err.recognition_failure(),
1325 Some(RecognitionFailure::Truncated),
1326 "strict error",
1327 )
1328 }
1329
1330 fn append_ea06_record(
1331 out: &mut Vec<u8>,
1332 subtype: &str,
1333 name: &str,
1334 compressed: bool,
1335 creation_time: u64,
1336 last_write_time: u64,
1337 data: &[u8],
1338 ) -> Result<(), String> {
1339 append_encrypted(out, b"FILE", Encoding::Ea06, 0x18ee)?;
1340 append_xored_u32(out, utf16_len(subtype)?, 0xadbc);
1341 append_encrypted_utf16(out, subtype, 0xb33f)?;
1342 append_xored_u32(out, utf16_len(name)?, 0xf820);
1343 append_encrypted_utf16(out, name, 0xf479)?;
1344 out.push(if compressed { 1 } else { 0 });
1345 let data_len = u32::try_from(data.len()).map_err(|err| err.to_string())?;
1346 append_xored_u32(out, data_len, 0x87bc);
1347 append_xored_u32(out, data_len, 0x87bc);
1348 append_xored_u32(
1349 out,
1350 adler32(data).ok_or_else(|| "adler failed".to_string())?,
1351 0xa685,
1352 );
1353 append_u64(out, creation_time);
1354 append_u64(out, last_write_time);
1355 append_encrypted(out, data, Encoding::Ea06, 0x2477)
1356 }
1357
1358 fn ea06_literal_blob(data: &[u8]) -> Result<Vec<u8>, String> {
1359 let mut blob = Vec::from(*b"EA06");
1360 let len = u32::try_from(data.len()).map_err(|err| err.to_string())?;
1361 blob.extend_from_slice(&len.to_be_bytes());
1362 let mut bits = Vec::new();
1363 for byte in data {
1364 bits.push(1);
1365 for shift in (0..8u8).rev() {
1366 bits.push((byte >> shift) & 1);
1367 }
1368 }
1369 blob.extend_from_slice(pack_bits(bits.as_slice())?.as_slice());
1370 Ok(blob)
1371 }
1372
1373 fn pack_bits(bits: &[u8]) -> Result<Vec<u8>, String> {
1374 let mut out = Vec::new();
1375 let mut cursor = 0usize;
1376 while cursor < bits.len() {
1377 let mut byte = 0u8;
1378 for bit_index in 0..8usize {
1379 let source_index = cursor
1380 .checked_add(bit_index)
1381 .ok_or_else(|| "bit offset overflow".to_string())?;
1382 let bit = bits
1383 .get(source_index)
1384 .copied()
1385 .map_or(0, core::convert::identity);
1386 byte = (byte << 1) | bit;
1387 }
1388 out.push(byte);
1389 cursor = cursor
1390 .checked_add(8)
1391 .ok_or_else(|| "bit offset overflow".to_string())?;
1392 }
1393 Ok(out)
1394 }
1395
1396 fn append_ea05_record(
1397 out: &mut Vec<u8>,
1398 subtype: &str,
1399 name: &str,
1400 compressed: bool,
1401 creation_time: u64,
1402 last_write_time: u64,
1403 data: &[u8],
1404 ) -> Result<(), String> {
1405 append_encrypted(out, b"FILE", Encoding::Ea05, 0x16fa)?;
1406 append_xored_u32(out, byte_len(subtype)?, 0x29bc);
1407 append_encrypted(
1408 out,
1409 subtype.as_bytes(),
1410 Encoding::Ea05,
1411 0xa25e_u32.wrapping_add(byte_len(subtype)?),
1412 )?;
1413 append_xored_u32(out, byte_len(name)?, 0x29ac);
1414 append_encrypted(
1415 out,
1416 name.as_bytes(),
1417 Encoding::Ea05,
1418 0xf25e_u32.wrapping_add(byte_len(name)?),
1419 )?;
1420 out.push(if compressed { 1 } else { 0 });
1421 let data_len = u32::try_from(data.len()).map_err(|err| err.to_string())?;
1422 append_xored_u32(out, data_len, 0x45aa);
1423 append_xored_u32(out, data_len, 0x45aa);
1424 append_xored_u32(
1425 out,
1426 adler32(data).ok_or_else(|| "adler failed".to_string())?,
1427 0xc3d2,
1428 );
1429 append_u64(out, creation_time);
1430 append_u64(out, last_write_time);
1431 append_encrypted(out, data, Encoding::Ea05, 0x22af)
1432 }
1433
1434 fn append_encrypted_utf16(out: &mut Vec<u8>, value: &str, key_base: u32) -> Result<(), String> {
1435 let char_len = utf16_len(value)?;
1436 let key = key_base.wrapping_add(char_len);
1437 let mut bytes = Vec::new();
1438 for unit in value.encode_utf16() {
1439 bytes.extend_from_slice(&unit.to_le_bytes());
1440 }
1441 append_encrypted(out, bytes.as_slice(), Encoding::Ea06, key)
1442 }
1443
1444 fn append_encrypted(
1445 out: &mut Vec<u8>,
1446 plain: &[u8],
1447 encoding: Encoding,
1448 key: u32,
1449 ) -> Result<(), String> {
1450 let encrypted = match encoding {
1451 Encoding::Ea04 | Encoding::Ea05 | Encoding::Jb01 => {
1452 crate::crypto::mt::decrypt(plain, key)
1453 }
1454 Encoding::Ea06 => crate::crypto::lame::decrypt(plain, key),
1455 }
1456 .ok_or_else(|| "encryption failed".to_string())?;
1457 out.extend_from_slice(encrypted.as_slice());
1458 Ok(())
1459 }
1460
1461 fn append_xored_u32(out: &mut Vec<u8>, value: u32, mask: u32) {
1462 append_u32(out, value ^ mask);
1463 }
1464
1465 fn append_u32(out: &mut Vec<u8>, value: u32) {
1466 out.extend_from_slice(&value.to_le_bytes());
1467 }
1468
1469 fn append_u64(out: &mut Vec<u8>, value: u64) {
1470 out.extend_from_slice(&value.to_le_bytes());
1471 }
1472
1473 fn utf16_len(value: &str) -> Result<u32, String> {
1474 u32::try_from(value.encode_utf16().count()).map_err(|err| err.to_string())
1475 }
1476
1477 fn byte_len(value: &str) -> Result<u32, String> {
1478 u32::try_from(value.len()).map_err(|err| err.to_string())
1479 }
1480
1481 fn check_eq<T>(actual: T, expected: T, context: &str) -> Result<(), String>
1482 where
1483 T: core::fmt::Debug + PartialEq,
1484 {
1485 if actual == expected {
1486 Ok(())
1487 } else {
1488 Err(format!("{context}: got {actual:?}, expected {expected:?}"))
1489 }
1490 }
1491}