Skip to main content

autoit/
lib.rs

1//! Extract and inspect compiled AutoIt payloads.
2//!
3//! This crate is intended to recover information from AutoIt2Exe and `.a3x`
4//! inputs: container details, compiled resource records, scripts, token streams,
5//! timestamps, checksums, and embedded file payloads.
6//!
7//! It does not execute AutoIt code, emulate expressions, or classify script
8//! behavior. Decoded source text and token streams are exposed as recovered
9//! information for downstream consumers.
10//!
11//! # Extraction Model
12//!
13//! `AutoItBinary::try_parse` performs container discovery first, then parses the
14//! AU3 record stream when an encoding and stream offset are available. The
15//! high-level object keeps every recovered view additive:
16//!
17//! - [`ContainerInfo`] summarizes PE, `.a3x`, raw stream, offset, and resource
18//!   facts.
19//! - [`ObservationLog`] records concrete recognition observations.
20//! - [`Record`] exposes raw record metadata plus encrypted, decrypted, and best
21//!   available payload bytes.
22//! - [`Script`] exposes script bytes, decoded source text when available, and
23//!   token streams for tokenized EA06 scripts.
24//! - [`Artifact`] preserves non-script record payloads.
25//! - [`StringFinding`] reports raw strings found in recovered payload bytes.
26//!
27//! Record parsing is tolerant at the high level: records recovered before a
28//! later malformed record remain available, while [`AutoItBinary::record_diagnostics`]
29//! reports the failing record index, offset, and reason. Lower-level
30//! [`au3::parse_records`] remains strict for callers that need all-or-nothing
31//! parsing.
32//!
33//! # Common Failure Modes
34//!
35//! - Packed or protected PE stubs may hide the AutoIt resource until an external
36//!   unpacking step has been performed.
37//! - Legacy encodings outside the implemented EA05/EA06 record profiles are
38//!   reported as unsupported rather than guessed.
39//! - Tokenized source rendering is source-like reconstruction, not guaranteed to
40//!   be byte-for-byte identical to the original script text.
41//! - Decompression failures preserve decrypted bytes and attach structured
42//!   decompression status to the source record.
43//!
44//! # Quick Start
45//!
46//! ```no_run
47//! use autoit::{AutoItBinary, RecognitionFailure};
48//!
49//! let data = std::fs::read("sample.exe")?;
50//! match AutoItBinary::try_parse(&data) {
51//!     Ok(binary) => {
52//!         println!("{:?}", binary.input_kind());
53//!         println!("{:?}", binary.encoding());
54//!     }
55//!     Err(err) if err.recognition_failure() == Some(RecognitionFailure::NotRecognized) => {
56//!         println!("not an AutoIt payload");
57//!     }
58//!     Err(err) => return Err(Box::<dyn std::error::Error>::from(err)),
59//! }
60//! # Ok::<(), Box<dyn std::error::Error>>(())
61//! ```
62
63pub mod error;
64pub mod format;
65
66pub mod au3;
67pub mod decompress;
68pub mod script;
69pub mod strings;
70pub mod token;
71
72mod container;
73mod crypto;
74mod util;
75
76pub use au3::{
77    CompressionProfile, DecompressionStatus, EncryptionProfile, Record, RecordParseDiagnostic,
78    RecordParseReport, RecordProfile,
79};
80pub use error::{Error, RecognitionFailure};
81pub use format::{
82    ContainerInfo, KnownSubtype, Observation, ObservationLog, PackedMarkerInfo,
83    PeScriptResourceInfo, VersionMarkerInfo,
84};
85pub use script::{Script, ScriptDecodeError, ScriptKind, ScriptText, ScriptTextEncoding};
86pub use strings::{StringEncoding, StringFinding};
87pub use token::{Token, TokenError, TokenStream};
88
89/// High-level view of an AutoIt payload.
90///
91/// Produced by [`AutoItBinary::try_parse`], this type owns every recovered view:
92/// the recognized container facts and analysis observations, the parsed AU3 records,
93/// any recovered scripts and non-script artifacts, and raw strings found in the
94/// payload bytes. Each view is exposed through an accessor and kept additive, so
95/// partial recovery from a malformed sample still yields everything decoded
96/// before the failure.
97#[derive(Debug, Clone)]
98pub struct AutoItBinary {
99    input_kind: InputKind,
100    encoding: Option<Encoding>,
101    container: ContainerInfo,
102    observations: ObservationLog,
103    records: Vec<Record>,
104    record_diagnostics: Vec<RecordParseDiagnostic>,
105    scripts: Vec<Script>,
106    artifacts: Vec<Artifact>,
107    strings: Vec<StringFinding>,
108}
109
110impl AutoItBinary {
111    /// Attempts to parse an AutoIt payload from bytes.
112    ///
113    /// Recognizes the container (PE, `.a3x`, or a carved record stream) and its
114    /// encoding, then parses the AU3 record stream when one is located:
115    /// decrypting and decompressing record payloads, recovering scripts
116    /// (including EA06 detokenization), preserving non-script artifacts, and
117    /// extracting raw strings. Record parsing is tolerant — records recovered
118    /// before a later malformed record remain available via [`Self::records`],
119    /// with the failure reported in [`Self::record_diagnostics`].
120    ///
121    /// # Errors
122    ///
123    /// Returns [`Error`] when the input is not recognized as AutoIt, or when a
124    /// recognized container is too malformed to begin record parsing. Use
125    /// [`Error::recognition_failure`] to distinguish the cause.
126    pub fn try_parse(data: &[u8]) -> Result<Self, Error> {
127        let raw = container::raw::scan(data);
128        let pe_resource = container::pe::find_script_resource(data);
129        let loose_version = loose_autoit_version_marker(data);
130        let packed_marker = packed_marker(data);
131        // EA04 (AutoIt v3.1.x) and JB01 (AutoHotkey-classic / AutoIt v2) carry
132        // neither an `AU3!` signature nor a PE script resource, so fall back to
133        // scanning for their appended streams.
134        let (ea04_stream_offset, jb01_stream_offset) = if raw.is_none() && pe_resource.is_none() {
135            (
136                container::raw::scan_ea04_stream(data),
137                container::raw::scan_jb01_stream(data),
138            )
139        } else {
140            (None, None)
141        };
142        if raw.is_none()
143            && pe_resource.is_none()
144            && ea04_stream_offset.is_none()
145            && jb01_stream_offset.is_none()
146            && !(util::has_mz_header(data) && packed_marker.is_some() && loose_version.is_some())
147        {
148            return Err(Error::not_recognized());
149        };
150
151        let input_kind = if util::has_mz_header(data) {
152            InputKind::Pe
153        } else if raw.is_some_and(|candidate| candidate.signature_offset() == 0) {
154            InputKind::A3x
155        } else {
156            InputKind::RawStream
157        };
158
159        let mut observations = ObservationLog::new();
160        if input_kind == InputKind::Pe {
161            observations.push(Observation::OuterPeHeaderFound);
162        }
163        if let Some(marker) = packed_marker {
164            observations.push(Observation::PackedMarkerFound {
165                name: marker.name,
166                offset: marker.offset,
167            });
168        }
169        if let Some(resource) = pe_resource {
170            observations.push(Observation::PeScriptResourceFound {
171                type_id: resource.resource_type_id(),
172                type_name: resource.resource_type_name(),
173                name: resource.resource_name(),
174                language_id: resource.language_id(),
175                rva: resource.rva(),
176                offset: resource.file_offset(),
177                size: resource.size(),
178            });
179            if let Some(offset) = resource.stream_offset() {
180                observations.push(Observation::PayloadStreamStartFound { offset });
181                if first_file_record_matches(data, offset, Encoding::Ea06) {
182                    observations.push(Observation::FirstFileRecordDecrypted {
183                        encoding: Encoding::Ea06,
184                        offset,
185                    });
186                }
187            }
188        }
189        if let Some(raw) = raw {
190            observations.push(Observation::AutoItSignatureFound {
191                offset: raw.signature_offset(),
192            });
193            observations.push(Observation::VersionMarkerFound {
194                encoding: raw.encoding(),
195                offset: raw.version_offset(),
196            });
197            observations.push(Observation::PayloadStreamStartFound {
198                offset: raw.payload_offset(),
199            });
200            if first_file_record_matches(data, raw.payload_offset(), raw.encoding()) {
201                observations.push(Observation::FirstFileRecordDecrypted {
202                    encoding: raw.encoding(),
203                    offset: raw.payload_offset(),
204                });
205            }
206        } else if let Some(offset) = jb01_stream_offset {
207            // Checked before EA04: JB01's GUID + marker byte is a specific
208            // signature, whereas the EA04 scan also matches a JB01 stream (same
209            // encrypted `FILE` marker and shared subtype validation).
210            observations.push(Observation::PayloadStreamStartFound { offset });
211            observations.push(Observation::FirstFileRecordDecrypted {
212                encoding: Encoding::Jb01,
213                offset,
214            });
215        } else if let Some(offset) = ea04_stream_offset {
216            observations.push(Observation::PayloadStreamStartFound { offset });
217            observations.push(Observation::FirstFileRecordDecrypted {
218                encoding: Encoding::Ea04,
219                offset,
220            });
221        } else if let Some(version) = loose_version {
222            observations.push(Observation::VersionMarkerFound {
223                encoding: version.encoding,
224                offset: version.offset,
225            });
226        }
227
228        let encoding = raw
229            .map(container::raw::RawScan::encoding)
230            .or_else(|| first_file_encoding(observations.entries()))
231            .or_else(|| loose_version.map(|version| version.encoding));
232
233        let (records, record_diagnostics) = encoding
234            .zip(first_stream_offset(observations.entries()))
235            .map_or_else(
236                || Ok::<(Vec<Record>, Vec<RecordParseDiagnostic>), Error>((Vec::new(), Vec::new())),
237                |(record_encoding, offset)| {
238                    let record_report = au3::parse_records_partial(
239                        data,
240                        offset,
241                        record_encoding,
242                        au3::Limits::default(),
243                    )?;
244                    let diagnostics = match record_report.diagnostic() {
245                        Some(diagnostic) => vec![diagnostic],
246                        None => Vec::new(),
247                    };
248                    Ok((record_report.into_records(), diagnostics))
249                },
250            )?;
251        if let Some(record) = records.first()
252            && let Some(subtype) = known_subtype(record.subtype())
253        {
254            observations.push(Observation::KnownSubtypeDecrypted {
255                encoding: record.profile().encoding,
256                offset: record.offset(),
257                subtype,
258            });
259        }
260
261        let scripts = script::recover_scripts(records.as_slice());
262        let artifacts = recover_artifacts(records.as_slice(), scripts.as_slice());
263        let strings = strings::extract_from_records(records.as_slice(), strings::Limits::default());
264
265        Ok(Self {
266            input_kind,
267            encoding,
268            container: ContainerInfo::from_observations(
269                input_kind,
270                encoding,
271                observations.entries(),
272            ),
273            scripts,
274            artifacts,
275            strings,
276            observations,
277            records,
278            record_diagnostics,
279        })
280    }
281
282    /// Returns the recognized input container kind.
283    ///
284    /// # Returns
285    ///
286    /// The [`InputKind`] determined during analysis ([`InputKind::Pe`],
287    /// [`InputKind::A3x`], or [`InputKind::RawStream`]).
288    #[must_use]
289    pub const fn input_kind(&self) -> InputKind {
290        self.input_kind
291    }
292
293    /// Returns the recognized AutoIt payload encoding, if known.
294    ///
295    /// # Returns
296    ///
297    /// `Some` with the recognized [`Encoding`], or `None` when no encoding could be
298    /// determined from the container.
299    #[must_use]
300    pub const fn encoding(&self) -> Option<Encoding> {
301        self.encoding
302    }
303
304    /// Returns summarized container-level facts.
305    ///
306    /// # Returns
307    ///
308    /// A reference to the [`ContainerInfo`] built from the recorded observations.
309    #[must_use]
310    pub const fn container(&self) -> &ContainerInfo {
311        &self.container
312    }
313
314    /// Returns the analysis observations.
315    ///
316    /// # Returns
317    ///
318    /// A reference to the [`ObservationLog`] holding the concrete observations
319    /// recorded during analysis.
320    #[must_use]
321    pub const fn observations(&self) -> &ObservationLog {
322        &self.observations
323    }
324
325    /// Returns parsed AU3 records.
326    ///
327    /// Records recovered before any later malformed record remain present here;
328    /// the failure is reported separately via [`Self::record_diagnostics`].
329    ///
330    /// # Returns
331    ///
332    /// A slice of the parsed [`Record`] values, empty when no record stream was
333    /// located.
334    #[must_use]
335    pub fn records(&self) -> &[Record] {
336        self.records.as_slice()
337    }
338
339    /// Returns diagnostics from tolerant AU3 record parsing.
340    ///
341    /// # Returns
342    ///
343    /// A slice of [`RecordParseDiagnostic`] entries describing records that failed
344    /// to parse; empty when every record parsed cleanly.
345    #[must_use]
346    pub fn record_diagnostics(&self) -> &[RecordParseDiagnostic] {
347        self.record_diagnostics.as_slice()
348    }
349
350    /// Returns recovered script records.
351    ///
352    /// # Returns
353    ///
354    /// A slice of recovered [`Script`] views; empty when no script records were
355    /// found.
356    #[must_use]
357    pub fn scripts(&self) -> &[Script] {
358        self.scripts.as_slice()
359    }
360
361    /// Returns recovered non-script artifact records.
362    ///
363    /// # Returns
364    ///
365    /// A slice of [`Artifact`] views for every record that was not recovered as a
366    /// [`Script`]; empty when there are none.
367    #[must_use]
368    pub fn artifacts(&self) -> &[Artifact] {
369        self.artifacts.as_slice()
370    }
371
372    /// Returns raw strings found in recovered record payload bytes.
373    ///
374    /// # Returns
375    ///
376    /// A slice of [`StringFinding`] values extracted from record payloads; empty
377    /// when none were found.
378    #[must_use]
379    pub fn strings(&self) -> &[StringFinding] {
380        self.strings.as_slice()
381    }
382}
383
384/// Recovered non-script artifact view.
385#[derive(Debug, Clone, PartialEq, Eq)]
386pub struct Artifact {
387    record_index: usize,
388    subtype: String,
389    name: String,
390    bytes: Vec<u8>,
391    creation_time: u64,
392    last_write_time: u64,
393    checksum_valid: bool,
394    decompression_status: DecompressionStatus,
395}
396
397impl Artifact {
398    /// Returns the source record index.
399    ///
400    /// # Returns
401    ///
402    /// The index of the [`Record`] this artifact was recovered from.
403    #[must_use]
404    pub const fn record_index(&self) -> usize {
405        self.record_index
406    }
407
408    /// Returns the record subtype.
409    ///
410    /// # Returns
411    ///
412    /// The decoded subtype string of the source record.
413    #[must_use]
414    pub fn subtype(&self) -> &str {
415        self.subtype.as_str()
416    }
417
418    /// Returns the stored record name/path.
419    ///
420    /// # Returns
421    ///
422    /// The decoded name/path string of the source record.
423    #[must_use]
424    pub fn name(&self) -> &str {
425        self.name.as_str()
426    }
427
428    /// Returns recovered artifact bytes.
429    ///
430    /// # Returns
431    ///
432    /// The best available payload bytes for the source record.
433    #[must_use]
434    pub fn bytes(&self) -> &[u8] {
435        self.bytes.as_slice()
436    }
437
438    /// Returns creation timestamp as raw Windows FILETIME.
439    ///
440    /// # Returns
441    ///
442    /// The raw 64-bit Windows FILETIME creation timestamp from the source record.
443    #[must_use]
444    pub const fn creation_time(&self) -> u64 {
445        self.creation_time
446    }
447
448    /// Returns last-write timestamp as raw Windows FILETIME.
449    ///
450    /// # Returns
451    ///
452    /// The raw 64-bit Windows FILETIME last-write timestamp from the source
453    /// record.
454    #[must_use]
455    pub const fn last_write_time(&self) -> u64 {
456        self.last_write_time
457    }
458
459    /// Returns checksum validation status from the source record.
460    ///
461    /// # Returns
462    ///
463    /// `true` if the source record's stored checksum validated, `false` otherwise.
464    #[must_use]
465    pub const fn checksum_valid(&self) -> bool {
466        self.checksum_valid
467    }
468
469    /// Returns decompression status from the source record.
470    ///
471    /// # Returns
472    ///
473    /// The [`DecompressionStatus`] recorded for the source record.
474    #[must_use]
475    pub const fn decompression_status(&self) -> DecompressionStatus {
476        self.decompression_status
477    }
478}
479
480/// Builds artifact views for every record not already recovered as a script.
481///
482/// # Arguments
483///
484/// * `records` - Parsed AU3 records to convert into artifacts.
485/// * `scripts` - Scripts already recovered; their source records are excluded.
486///
487/// # Returns
488///
489/// A vector of [`Artifact`] values, one per record whose index does not match
490/// any recovered [`Script`].
491fn recover_artifacts(records: &[Record], scripts: &[Script]) -> Vec<Artifact> {
492    records
493        .iter()
494        .filter(|record| {
495            !scripts
496                .iter()
497                .any(|script| script.record_index() == record.index())
498        })
499        .map(|record| Artifact {
500            record_index: record.index(),
501            subtype: record.subtype().to_string(),
502            name: record.name().to_string(),
503            bytes: record.payload_data().to_vec(),
504            creation_time: record.creation_time(),
505            last_write_time: record.last_write_time(),
506            checksum_valid: record.checksum_valid(),
507            decompression_status: record.decompression_status(),
508        })
509        .collect()
510}
511
512/// Checks whether the first record marker at `offset` decrypts to `FILE`.
513///
514/// Reads the four bytes at `offset` and decrypts them with the field key for the
515/// given encoding, confirming a genuine AU3 record stream begins there.
516///
517/// # Arguments
518///
519/// * `data` - The full input bytes.
520/// * `offset` - File offset where the encrypted record-type marker is expected.
521/// * `encoding` - Encoding whose decryption profile and key to apply.
522///
523/// # Returns
524///
525/// `true` if the four bytes at `offset` decrypt to `FILE`, `false` when the slice
526/// is out of bounds, the offset arithmetic overflows, or decryption does not yield
527/// `FILE`.
528fn first_file_record_matches(data: &[u8], offset: usize, encoding: Encoding) -> bool {
529    let Some(end) = offset.checked_add(4) else {
530        return false;
531    };
532    let Some(marker) = data.get(offset..end) else {
533        return false;
534    };
535    let decrypted = match encoding {
536        Encoding::Ea04 | Encoding::Ea05 | Encoding::Jb01 => crypto::mt::decrypt(marker, 0x16fa),
537        Encoding::Ea06 => crypto::lame::decrypt(marker, 0x18ee),
538    };
539    decrypted.is_some_and(|bytes| bytes == b"FILE")
540}
541
542/// Returns the offset where the AU3 record stream is expected to begin.
543///
544/// # Arguments
545///
546/// * `observations` - Observations collected in [`ObservationLog`], scanned in
547///   recording order.
548///
549/// # Returns
550///
551/// The offset from the first [`Observation::PayloadStreamStartFound`] observation,
552/// or `None` when no payload stream start was recorded.
553fn first_stream_offset(observations: &[Observation]) -> Option<usize> {
554    observations
555        .iter()
556        .find_map(|observation| match observation {
557            Observation::PayloadStreamStartFound { offset } => Some(*offset),
558            _ => None,
559        })
560}
561
562/// Returns the encoding proven by the first decrypted `FILE` record marker.
563///
564/// # Arguments
565///
566/// * `observations` - Observations collected in [`ObservationLog`], scanned in
567///   recording order.
568///
569/// # Returns
570///
571/// The encoding from the first [`Observation::FirstFileRecordDecrypted`]
572/// observation, or `None` if no `FILE` marker was decrypted during analysis.
573fn first_file_encoding(observations: &[Observation]) -> Option<Encoding> {
574    observations
575        .iter()
576        .find_map(|observation| match observation {
577            Observation::FirstFileRecordDecrypted { encoding, .. } => Some(*encoding),
578            _ => None,
579        })
580}
581
582/// Maps a record subtype string to its known script category.
583///
584/// # Arguments
585///
586/// * `subtype` - Decoded record subtype string to classify.
587///
588/// # Returns
589///
590/// `Some` with the matching [`KnownSubtype`] for a recognized AutoIt script
591/// subtype, or `None` for any other subtype.
592fn known_subtype(subtype: &str) -> Option<KnownSubtype> {
593    match subtype {
594        ">>>AUTOIT SCRIPT<<<" => Some(KnownSubtype::TokenizedScript),
595        ">AUTOIT UNICODE SCRIPT<" => Some(KnownSubtype::UnicodeScript),
596        ">AUTOIT SCRIPT<" => Some(KnownSubtype::PlainScript),
597        _ => None,
598    }
599}
600
601/// A version marker located by a loose textual scan rather than a full container.
602#[derive(Debug, Clone, Copy)]
603struct LooseVersionMarker {
604    /// Encoding represented by the located marker.
605    encoding: Encoding,
606    /// File offset where the marker begins.
607    offset: usize,
608}
609
610/// A packer marker located in the outer input.
611#[derive(Debug, Clone, Copy)]
612struct PackedMarker {
613    /// Marker name.
614    name: &'static str,
615    /// File offset where the marker begins.
616    offset: usize,
617}
618
619/// Scans for a loose AutoIt version marker when no full container was recognized.
620///
621/// Requires at least one of the `AU3!`, `AutoIt`, or `SCRIPT` text anchors to be
622/// present before reporting a marker, then returns the first encoding marker
623/// found.
624///
625/// # Arguments
626///
627/// * `data` - The full input bytes to scan.
628///
629/// # Returns
630///
631/// `Some` with the first [`LooseVersionMarker`] (`EA06`, `EA05`, then `JB01`
632/// order) when an anchor and a marker are both present, or `None` otherwise.
633fn loose_autoit_version_marker(data: &[u8]) -> Option<LooseVersionMarker> {
634    if find_bytes(data, b"AU3!").is_none()
635        && find_bytes(data, b"AutoIt").is_none()
636        && find_bytes(data, b"SCRIPT").is_none()
637    {
638        return None;
639    }
640
641    [
642        (Encoding::Ea06, b"EA06".as_slice()),
643        (Encoding::Ea05, b"EA05".as_slice()),
644        (Encoding::Jb01, b"JB01".as_slice()),
645    ]
646    .into_iter()
647    .find_map(|(encoding, marker)| {
648        find_bytes(data, marker).map(|offset| LooseVersionMarker { encoding, offset })
649    })
650}
651
652/// Scans for a known packer marker in the outer input.
653///
654/// # Arguments
655///
656/// * `data` - The full input bytes to scan.
657///
658/// # Returns
659///
660/// `Some` with a [`PackedMarker`] when a `UPX` marker is found, or `None`
661/// otherwise.
662fn packed_marker(data: &[u8]) -> Option<PackedMarker> {
663    find_bytes(data, b"UPX").map(|offset| PackedMarker {
664        name: "UPX",
665        offset,
666    })
667}
668
669/// Finds the first occurrence of a byte sequence within the input.
670///
671/// # Arguments
672///
673/// * `data` - The bytes to search.
674/// * `needle` - The byte sequence to locate.
675///
676/// # Returns
677///
678/// The offset of the first match, or `None` when `needle` is empty, longer than
679/// `data`, or absent.
680fn find_bytes(data: &[u8], needle: &[u8]) -> Option<usize> {
681    if needle.is_empty() || needle.len() > data.len() {
682        return None;
683    }
684    data.windows(needle.len())
685        .position(|candidate| candidate == needle)
686}
687
688/// Recognized outer input kind.
689#[derive(Debug, Clone, Copy, PartialEq, Eq)]
690pub enum InputKind {
691    /// A Windows PE executable containing AutoIt payload data.
692    Pe,
693    /// A compiled `.a3x` AutoIt payload.
694    A3x,
695    /// A carved AutoIt record stream without a full original container.
696    RawStream,
697}
698
699/// Recognized AutoIt payload encoding family.
700#[derive(Debug, Clone, Copy, PartialEq, Eq)]
701pub enum Encoding {
702    /// AutoIt v3.1.x EA04 payload format. Predates EA05: MT-encrypted with the
703    /// same field keys, but the record header carries no per-record checksum
704    /// field and the compression wrapper magic is `EA04`.
705    Ea04,
706    /// AutoIt v3 EA05 payload format.
707    Ea05,
708    /// AutoIt v3 EA06 payload format.
709    Ea06,
710    /// AutoIt v2 / related JB01 payload format.
711    Jb01,
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    const PREFIX: [u8; 20] = [
719        0xa3, 0x48, 0x4b, 0xbe, 0x98, 0x6c, 0x4a, 0xa9, 0x99, 0x4c, 0x53, 0x0a, 0x86, 0xd6, 0x48,
720        0x7d, 0x41, 0x55, 0x33, 0x21,
721    ];
722
723    #[test]
724    fn scans_raw_ea06_signature() -> Result<(), String> {
725        let mut data = Vec::from(PREFIX);
726        data.extend_from_slice(b"EA06");
727
728        let binary = AutoItBinary::try_parse(&data).map_err(|err| err.to_string())?;
729
730        check_eq(binary.input_kind(), InputKind::A3x, "input kind")?;
731        check_eq(binary.encoding(), Some(Encoding::Ea06), "encoding")?;
732        check_eq(
733            binary.container().autoit_signature_offset(),
734            Some(0),
735            "signature offset",
736        )?;
737        check_eq(
738            binary.container().version_marker(),
739            Some(VersionMarkerInfo {
740                encoding: Encoding::Ea06,
741                offset: 20,
742            }),
743            "version marker",
744        )?;
745        check_eq(
746            binary.container().payload_stream_offsets(),
747            [24usize].as_slice(),
748            "payload offsets",
749        )?;
750        check_eq(
751            binary.observations().entries().len(),
752            3,
753            "observation count",
754        )
755    }
756
757    #[test]
758    fn validates_raw_ea06_first_file_record() -> Result<(), String> {
759        let mut data = Vec::from(PREFIX);
760        data.extend_from_slice(b"EA06");
761        append_ea06_record(&mut data, ">>>AUTOIT SCRIPT<<<", "main.au3", b"payload")?;
762
763        let binary = AutoItBinary::try_parse(&data).map_err(|err| err.to_string())?;
764
765        check_eq(binary.encoding(), Some(Encoding::Ea06), "encoding")?;
766        check_eq(binary.records().len(), 1, "record count")?;
767        let found = binary.observations().entries().iter().any(|observation| {
768            matches!(
769                observation,
770                Observation::FirstFileRecordDecrypted {
771                    encoding: Encoding::Ea06,
772                    offset: 24
773                }
774            )
775        });
776        if found {
777            let subtype_found = binary.observations().entries().iter().any(|observation| {
778                matches!(
779                    observation,
780                    Observation::KnownSubtypeDecrypted {
781                        encoding: Encoding::Ea06,
782                        offset: 24,
783                        subtype: KnownSubtype::TokenizedScript
784                    }
785                )
786            });
787            if subtype_found {
788                Ok(())
789            } else {
790                Err("missing known subtype observation".to_string())
791            }
792        } else {
793            Err("missing first FILE record observation".to_string())
794        }
795    }
796
797    #[test]
798    fn preserves_records_when_later_record_is_truncated() -> Result<(), String> {
799        let mut data = Vec::from(PREFIX);
800        data.extend_from_slice(b"EA06");
801        append_ea06_record(&mut data, ">AUTOIT SCRIPT<", "main.au3", b"payload")?;
802        let truncated_offset = data.len();
803        append_encrypted(&mut data, b"FILE", 0x18ee)?;
804
805        let binary = AutoItBinary::try_parse(&data).map_err(|err| err.to_string())?;
806
807        check_eq(binary.records().len(), 1, "record count")?;
808        check_eq(binary.scripts().len(), 1, "script count")?;
809        check_eq(
810            binary.record_diagnostics(),
811            [RecordParseDiagnostic {
812                record_index: 1,
813                offset: truncated_offset,
814                reason: RecognitionFailure::Truncated,
815            }]
816            .as_slice(),
817            "diagnostics",
818        )
819    }
820
821    #[test]
822    fn scans_pe_wrapped_signature() -> Result<(), String> {
823        let mut data = Vec::from(b"MZ");
824        data.extend_from_slice(&[0; 8]);
825        data.extend_from_slice(&PREFIX);
826        data.extend_from_slice(b"EA05");
827
828        let binary = AutoItBinary::try_parse(&data).map_err(|err| err.to_string())?;
829
830        check_eq(binary.input_kind(), InputKind::Pe, "input kind")?;
831        check_eq(binary.encoding(), Some(Encoding::Ea05), "encoding")?;
832        check_eq(
833            binary.observations().entries().len(),
834            4,
835            "observation count",
836        )
837    }
838
839    #[test]
840    fn rejects_unknown_input() -> Result<(), String> {
841        let Err(err) = AutoItBinary::try_parse(b"not autoit") else {
842            return Err("unexpected parse success".to_string());
843        };
844
845        check_eq(
846            err.recognition_failure(),
847            Some(RecognitionFailure::NotRecognized),
848            "recognition failure",
849        )
850    }
851
852    fn check_eq<T>(actual: T, expected: T, context: &str) -> Result<(), String>
853    where
854        T: core::fmt::Debug + PartialEq,
855    {
856        if actual == expected {
857            Ok(())
858        } else {
859            Err(format!("{context}: got {actual:?}, expected {expected:?}"))
860        }
861    }
862
863    fn append_ea06_record(
864        out: &mut Vec<u8>,
865        subtype: &str,
866        name: &str,
867        data: &[u8],
868    ) -> Result<(), String> {
869        append_encrypted(out, b"FILE", 0x18ee)?;
870        append_xored_u32(out, utf16_len(subtype)?, 0xadbc);
871        append_encrypted_utf16(out, subtype, 0xb33f)?;
872        append_xored_u32(out, utf16_len(name)?, 0xf820);
873        append_encrypted_utf16(out, name, 0xf479)?;
874        out.push(0);
875        let data_len = u32::try_from(data.len()).map_err(|err| err.to_string())?;
876        append_xored_u32(out, data_len, 0x87bc);
877        append_xored_u32(out, data_len, 0x87bc);
878        append_xored_u32(out, 0, 0xa685);
879        append_u64(out, 0);
880        append_u64(out, 0);
881        append_encrypted(out, data, 0x2477)
882    }
883
884    fn append_encrypted_utf16(out: &mut Vec<u8>, value: &str, key_base: u32) -> Result<(), String> {
885        let char_len = utf16_len(value)?;
886        let key = key_base.wrapping_add(char_len);
887        let mut bytes = Vec::new();
888        for unit in value.encode_utf16() {
889            bytes.extend_from_slice(&unit.to_le_bytes());
890        }
891        append_encrypted(out, bytes.as_slice(), key)
892    }
893
894    fn append_encrypted(out: &mut Vec<u8>, plain: &[u8], key: u32) -> Result<(), String> {
895        let encrypted = crypto::lame::decrypt(plain, key)
896            .ok_or_else(|| "EA06 encryption failed".to_string())?;
897        out.extend_from_slice(encrypted.as_slice());
898        Ok(())
899    }
900
901    fn append_xored_u32(out: &mut Vec<u8>, value: u32, mask: u32) {
902        append_u32(out, value ^ mask);
903    }
904
905    fn append_u32(out: &mut Vec<u8>, value: u32) {
906        out.extend_from_slice(&value.to_le_bytes());
907    }
908
909    fn append_u64(out: &mut Vec<u8>, value: u64) {
910        out.extend_from_slice(&value.to_le_bytes());
911    }
912
913    fn utf16_len(value: &str) -> Result<u32, String> {
914        u32::try_from(value.encode_utf16().count()).map_err(|err| err.to_string())
915    }
916}