Skip to main content

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}