Skip to main content

munin_msbuild/
reader.rs

1// Copyright (c) Michael Grier
2
3//! Sequential binlog reader — reads a `.binlog` stream record by record,
4//! ingesting auxiliary data (string table, name-value lists, embedded
5//! archives) and yielding typed build events.
6//!
7//! The reader uses a buffer-per-record approach: each record's payload is
8//! read into a `Vec<u8>`, then deserialized via a `Cursor`. This gives
9//! forward-compatibility for free — if a newer MSBuild version appends
10//! trailing fields to a known record, the extra bytes are harmlessly
11//! discarded when the `Cursor` is dropped. Unknown record kinds are
12//! skipped entirely.
13
14use std::io::{BufReader, Cursor, Read};
15
16use flate2::read::GzDecoder;
17
18use crate::{
19    error::MuninError,
20    events::*,
21    header::{BinlogHeader, open_binlog},
22    nvl_table::{NameValueListTable, NameValuePair},
23    primitives::{read_7bit_count, read_7bit_int, read_7bit_length},
24    record_kind::BinaryLogRecordKind,
25    string_table::StringTable,
26};
27
28// ---------------------------------------------------------------------------
29// BinlogEvent enum — one variant per event type
30// ---------------------------------------------------------------------------
31
32/// A typed build event read from the binlog stream.
33///
34/// Each variant wraps the corresponding event struct from [`crate::events`].
35/// The variant name preserves the record kind, so `BuildCheckMessage` and
36/// `Message` are distinct even though they share the same struct layout.
37#[derive(Debug, Clone)]
38pub enum BinlogEvent {
39    BuildStarted(BuildStartedEvent),
40    BuildFinished(BuildFinishedEvent),
41    ProjectStarted(ProjectStartedEvent),
42    ProjectFinished(ProjectFinishedEvent),
43    TargetStarted(TargetStartedEvent),
44    TargetFinished(TargetFinishedEvent),
45    TargetSkipped(TargetSkippedEvent),
46    TaskStarted(TaskStartedEvent),
47    TaskFinished(TaskFinishedEvent),
48    TaskCommandLine(TaskCommandLineEvent),
49    TaskParameter(TaskParameterEvent),
50    Error(BuildErrorEvent),
51    Warning(BuildWarningEvent),
52    Message(BuildMessageEvent),
53    CriticalBuildMessage(CriticalBuildMessageEvent),
54    ProjectEvaluationStarted(ProjectEvaluationStartedEvent),
55    ProjectEvaluationFinished(ProjectEvaluationFinishedEvent),
56    PropertyReassignment(PropertyReassignmentEvent),
57    UninitializedPropertyRead(UninitializedPropertyReadEvent),
58    PropertyInitialValueSet(PropertyInitialValueSetEvent),
59    EnvironmentVariableRead(EnvironmentVariableReadEvent),
60    ResponseFileUsed(ResponseFileUsedEvent),
61    AssemblyLoad(AssemblyLoadEvent),
62    ProjectImported(ProjectImportedEvent),
63    BuildCheckMessage(BuildCheckMessageEvent),
64    BuildCheckWarning(BuildCheckWarningEvent),
65    BuildCheckError(BuildCheckErrorEvent),
66    BuildCheckTracing(BuildCheckTracingEvent),
67    BuildCheckAcquisition(BuildCheckAcquisitionEvent),
68    BuildSubmissionStarted(BuildSubmissionStartedEvent),
69    BuildCanceled(BuildCanceledEvent),
70}
71
72impl BinlogEvent {
73    /// Returns the record kind that produced this event.
74    pub fn record_kind(&self) -> BinaryLogRecordKind {
75        match self {
76            Self::BuildStarted(_) => BinaryLogRecordKind::BuildStarted,
77            Self::BuildFinished(_) => BinaryLogRecordKind::BuildFinished,
78            Self::ProjectStarted(_) => BinaryLogRecordKind::ProjectStarted,
79            Self::ProjectFinished(_) => BinaryLogRecordKind::ProjectFinished,
80            Self::TargetStarted(_) => BinaryLogRecordKind::TargetStarted,
81            Self::TargetFinished(_) => BinaryLogRecordKind::TargetFinished,
82            Self::TargetSkipped(_) => BinaryLogRecordKind::TargetSkipped,
83            Self::TaskStarted(_) => BinaryLogRecordKind::TaskStarted,
84            Self::TaskFinished(_) => BinaryLogRecordKind::TaskFinished,
85            Self::TaskCommandLine(_) => BinaryLogRecordKind::TaskCommandLine,
86            Self::TaskParameter(_) => BinaryLogRecordKind::TaskParameter,
87            Self::Error(_) => BinaryLogRecordKind::Error,
88            Self::Warning(_) => BinaryLogRecordKind::Warning,
89            Self::Message(_) => BinaryLogRecordKind::Message,
90            Self::CriticalBuildMessage(_) => BinaryLogRecordKind::CriticalBuildMessage,
91            Self::ProjectEvaluationStarted(_) => BinaryLogRecordKind::ProjectEvaluationStarted,
92            Self::ProjectEvaluationFinished(_) => BinaryLogRecordKind::ProjectEvaluationFinished,
93            Self::PropertyReassignment(_) => BinaryLogRecordKind::PropertyReassignment,
94            Self::UninitializedPropertyRead(_) => BinaryLogRecordKind::UninitializedPropertyRead,
95            Self::PropertyInitialValueSet(_) => BinaryLogRecordKind::PropertyInitialValueSet,
96            Self::EnvironmentVariableRead(_) => BinaryLogRecordKind::EnvironmentVariableRead,
97            Self::ResponseFileUsed(_) => BinaryLogRecordKind::ResponseFileUsed,
98            Self::AssemblyLoad(_) => BinaryLogRecordKind::AssemblyLoad,
99            Self::ProjectImported(_) => BinaryLogRecordKind::ProjectImported,
100            Self::BuildCheckMessage(_) => BinaryLogRecordKind::BuildCheckMessage,
101            Self::BuildCheckWarning(_) => BinaryLogRecordKind::BuildCheckWarning,
102            Self::BuildCheckError(_) => BinaryLogRecordKind::BuildCheckError,
103            Self::BuildCheckTracing(_) => BinaryLogRecordKind::BuildCheckTracing,
104            Self::BuildCheckAcquisition(_) => BinaryLogRecordKind::BuildCheckAcquisition,
105            Self::BuildSubmissionStarted(_) => BinaryLogRecordKind::BuildSubmissionStarted,
106            Self::BuildCanceled(_) => BinaryLogRecordKind::BuildCanceled,
107        }
108    }
109}
110
111// ---------------------------------------------------------------------------
112// BinlogReader
113// ---------------------------------------------------------------------------
114
115/// Sequential reader over a `.binlog` stream.
116///
117/// Opens the binlog header, decompresses the GZip body, and yields typed
118/// [`BinlogEvent`] values one at a time via [`next_event`](Self::next_event).
119///
120/// Auxiliary records (string table entries, name-value lists, embedded
121/// archives) are ingested automatically and are accessible through the
122/// corresponding accessor methods.
123#[derive(Debug)]
124pub struct BinlogReader<R: Read> {
125    reader: BufReader<GzDecoder<R>>,
126    header: BinlogHeader,
127    strings: StringTable,
128    nvl_table: NameValueListTable,
129    /// Embedded zip archives from ProjectImportArchive records.
130    archives: Vec<Vec<u8>>,
131    /// Whether we have reached EndOfFile.
132    finished: bool,
133}
134
135impl<R: Read> BinlogReader<R> {
136    /// Open a binlog stream and prepare for sequential reading.
137    ///
138    /// Reads the uncompressed header, validates the format version, and sets
139    /// up GZip decompression for the record body.
140    pub fn open(reader: R) -> Result<Self, MuninError> {
141        let (header, gz_reader) = open_binlog(reader)?;
142        Ok(Self {
143            reader: gz_reader,
144            header,
145            strings: StringTable::new(),
146            nvl_table: NameValueListTable::new(),
147            archives: Vec::new(),
148            finished: false,
149        })
150    }
151
152    /// The parsed binlog file header.
153    pub fn header(&self) -> &BinlogHeader {
154        &self.header
155    }
156
157    /// The string table accumulated so far.
158    pub fn strings(&self) -> &StringTable {
159        &self.strings
160    }
161
162    /// The name-value list table accumulated so far.
163    pub fn nvl_table(&self) -> &NameValueListTable {
164        &self.nvl_table
165    }
166
167    /// Embedded zip archives read from ProjectImportArchive records.
168    pub fn archives(&self) -> &[Vec<u8>] {
169        &self.archives
170    }
171
172    /// Extract all files from all embedded ProjectImportArchive zip archives.
173    ///
174    /// Returns a list of `(path, contents)` pairs. Paths are as stored in the
175    /// zip (typically relative project file paths). If a zip entry cannot be
176    /// read (e.g. unsupported compression), that entry is silently skipped.
177    pub fn extract_archives(&self) -> Result<Vec<ArchiveEntry>, MuninError> {
178        let mut entries = Vec::new();
179        for archive_bytes in &self.archives {
180            extract_zip_entries(archive_bytes, &mut entries)?;
181        }
182        Ok(entries)
183    }
184
185    /// Read the next build event from the stream.
186    ///
187    /// Returns `Ok(None)` when the `EndOfFile` record is reached.
188    ///
189    /// Auxiliary records (String, NameValueList, ProjectImportArchive) are
190    /// ingested internally and do not produce events. Unknown record kinds
191    /// are silently skipped (forward-compatibility).
192    pub fn next_event(&mut self) -> Result<Option<BinlogEvent>, MuninError> {
193        if self.finished {
194            return Ok(None);
195        }
196
197        let version = self.header.file_format_version;
198
199        loop {
200            // Record kind: 7-bit variable-length encoded i32.
201            let kind_raw = read_7bit_int(&mut self.reader)?;
202
203            if kind_raw == BinaryLogRecordKind::EndOfFile as i32 {
204                self.finished = true;
205                return Ok(None);
206            }
207
208            // Record length (bytes of payload). Always present for v18+,
209            // and we only support v18+.
210            let record_length = read_7bit_length(&mut self.reader, "record length")?;
211
212            // Read the entire payload into a buffer. This gives us
213            // forward-compatibility: if the payload is longer than what
214            // the deserializer consumes, the extra bytes are discarded.
215            let mut payload = vec![0u8; record_length];
216            self.reader.read_exact(&mut payload)?;
217
218            let kind = BinaryLogRecordKind::from_raw(kind_raw);
219
220            match kind {
221                // --- Auxiliary: string table entry ---
222                // The payload is the raw UTF-8 string bytes; the record
223                // length provides the string length (no dotnet length prefix).
224                Some(BinaryLogRecordKind::String) => {
225                    let s = String::from_utf8(payload).map_err(|_| MuninError::InvalidUtf8)?;
226                    self.strings.add(s);
227                }
228
229                // --- Auxiliary: name-value list entry ---
230                Some(BinaryLogRecordKind::NameValueList) => {
231                    let mut cursor = Cursor::new(&payload);
232                    let count = read_7bit_count(&mut cursor, "name-value list count")?;
233                    let mut pairs = Vec::with_capacity(count);
234                    for _ in 0..count {
235                        let key_index = read_7bit_int(&mut cursor)?;
236                        let value_index = read_7bit_int(&mut cursor)?;
237                        pairs.push(NameValuePair {
238                            key_index,
239                            value_index,
240                        });
241                    }
242                    self.nvl_table.add(pairs);
243                }
244
245                // --- Auxiliary: embedded archive ---
246                Some(BinaryLogRecordKind::ProjectImportArchive) => {
247                    self.archives.push(payload);
248                }
249
250                // --- Known event record ---
251                Some(record_kind) => {
252                    let mut cursor = Cursor::new(&payload);
253                    let event = dispatch_event(
254                        &mut cursor,
255                        record_kind,
256                        &self.strings,
257                        &self.nvl_table,
258                        version,
259                    )?;
260                    return Ok(Some(event));
261                }
262
263                // --- Unknown record kind (forward-compatibility) ---
264                None => {
265                    // Payload already consumed into `payload`; discard it.
266                }
267            }
268        }
269    }
270
271    /// Collect all remaining events into a `Vec`.
272    ///
273    /// Reads events until `EndOfFile` is reached. Auxiliary records are
274    /// ingested along the way.
275    pub fn read_all_events(&mut self) -> Result<Vec<BinlogEvent>, MuninError> {
276        let mut events = Vec::new();
277        while let Some(event) = self.next_event()? {
278            events.push(event);
279        }
280        Ok(events)
281    }
282}
283
284// ---------------------------------------------------------------------------
285// Event dispatch
286// ---------------------------------------------------------------------------
287
288/// Dispatch a record payload to the appropriate typed event reader.
289///
290/// The caller has already consumed the record kind and length from the
291/// outer stream; `reader` is positioned at the start of the payload.
292pub(crate) fn dispatch_event(
293    reader: &mut impl Read,
294    kind: BinaryLogRecordKind,
295    strings: &StringTable,
296    nvl_table: &NameValueListTable,
297    version: i32,
298) -> Result<BinlogEvent, MuninError> {
299    match kind {
300        BinaryLogRecordKind::BuildStarted => {
301            let e = read_build_started(reader, strings, nvl_table, version)?;
302            Ok(BinlogEvent::BuildStarted(e))
303        }
304        BinaryLogRecordKind::BuildFinished => {
305            let e = read_build_finished(reader, strings, version)?;
306            Ok(BinlogEvent::BuildFinished(e))
307        }
308        BinaryLogRecordKind::ProjectStarted => {
309            let e = read_project_started(reader, strings, nvl_table, version)?;
310            Ok(BinlogEvent::ProjectStarted(e))
311        }
312        BinaryLogRecordKind::ProjectFinished => {
313            let e = read_project_finished(reader, strings, version)?;
314            Ok(BinlogEvent::ProjectFinished(e))
315        }
316        BinaryLogRecordKind::TargetStarted => {
317            let e = read_target_started(reader, strings, version)?;
318            Ok(BinlogEvent::TargetStarted(e))
319        }
320        BinaryLogRecordKind::TargetFinished => {
321            let e = read_target_finished(reader, strings, nvl_table, version)?;
322            Ok(BinlogEvent::TargetFinished(e))
323        }
324        BinaryLogRecordKind::TargetSkipped => {
325            let e = read_target_skipped(reader, strings, version)?;
326            Ok(BinlogEvent::TargetSkipped(e))
327        }
328        BinaryLogRecordKind::TaskStarted => {
329            let e = read_task_started(reader, strings, version)?;
330            Ok(BinlogEvent::TaskStarted(e))
331        }
332        BinaryLogRecordKind::TaskFinished => {
333            let e = read_task_finished(reader, strings, version)?;
334            Ok(BinlogEvent::TaskFinished(e))
335        }
336        BinaryLogRecordKind::TaskCommandLine => {
337            let e = read_task_command_line(reader, strings, version)?;
338            Ok(BinlogEvent::TaskCommandLine(e))
339        }
340        BinaryLogRecordKind::TaskParameter => {
341            let e = read_task_parameter(reader, strings, nvl_table, version)?;
342            Ok(BinlogEvent::TaskParameter(e))
343        }
344        BinaryLogRecordKind::Error => {
345            let e = read_build_error(reader, strings, version)?;
346            Ok(BinlogEvent::Error(e))
347        }
348        BinaryLogRecordKind::Warning => {
349            let e = read_build_warning(reader, strings, version)?;
350            Ok(BinlogEvent::Warning(e))
351        }
352        BinaryLogRecordKind::Message => {
353            let e = read_build_message(reader, strings, version)?;
354            Ok(BinlogEvent::Message(e))
355        }
356        BinaryLogRecordKind::CriticalBuildMessage => {
357            let e = read_critical_build_message(reader, strings, version)?;
358            Ok(BinlogEvent::CriticalBuildMessage(e))
359        }
360        BinaryLogRecordKind::ProjectEvaluationStarted => {
361            let e = read_project_evaluation_started(reader, strings, version)?;
362            Ok(BinlogEvent::ProjectEvaluationStarted(e))
363        }
364        BinaryLogRecordKind::ProjectEvaluationFinished => {
365            let e = read_project_evaluation_finished(reader, strings, nvl_table, version)?;
366            Ok(BinlogEvent::ProjectEvaluationFinished(e))
367        }
368        BinaryLogRecordKind::PropertyReassignment => {
369            let e = read_property_reassignment(reader, strings, version)?;
370            Ok(BinlogEvent::PropertyReassignment(e))
371        }
372        BinaryLogRecordKind::UninitializedPropertyRead => {
373            let e = read_uninitialized_property_read(reader, strings, version)?;
374            Ok(BinlogEvent::UninitializedPropertyRead(e))
375        }
376        BinaryLogRecordKind::PropertyInitialValueSet => {
377            let e = read_property_initial_value_set(reader, strings, version)?;
378            Ok(BinlogEvent::PropertyInitialValueSet(e))
379        }
380        BinaryLogRecordKind::EnvironmentVariableRead => {
381            let e = read_environment_variable_read(reader, strings, version)?;
382            Ok(BinlogEvent::EnvironmentVariableRead(e))
383        }
384        BinaryLogRecordKind::ResponseFileUsed => {
385            let e = read_response_file_used(reader, strings, version)?;
386            Ok(BinlogEvent::ResponseFileUsed(e))
387        }
388        BinaryLogRecordKind::AssemblyLoad => {
389            let e = read_assembly_load(reader, strings, version)?;
390            Ok(BinlogEvent::AssemblyLoad(e))
391        }
392        BinaryLogRecordKind::ProjectImported => {
393            let e = read_project_imported(reader, strings, version)?;
394            Ok(BinlogEvent::ProjectImported(e))
395        }
396        BinaryLogRecordKind::BuildCheckMessage => {
397            let e = read_build_message(reader, strings, version)?;
398            Ok(BinlogEvent::BuildCheckMessage(e))
399        }
400        BinaryLogRecordKind::BuildCheckWarning => {
401            let e = read_build_warning(reader, strings, version)?;
402            Ok(BinlogEvent::BuildCheckWarning(e))
403        }
404        BinaryLogRecordKind::BuildCheckError => {
405            let e = read_build_error(reader, strings, version)?;
406            Ok(BinlogEvent::BuildCheckError(e))
407        }
408        BinaryLogRecordKind::BuildCheckTracing => {
409            let e = read_build_check_tracing(reader, strings, nvl_table, version)?;
410            Ok(BinlogEvent::BuildCheckTracing(e))
411        }
412        BinaryLogRecordKind::BuildCheckAcquisition => {
413            let e = read_build_check_acquisition(reader, strings, version)?;
414            Ok(BinlogEvent::BuildCheckAcquisition(e))
415        }
416        BinaryLogRecordKind::BuildSubmissionStarted => {
417            let e = read_build_submission_started(reader, strings, nvl_table, version)?;
418            Ok(BinlogEvent::BuildSubmissionStarted(e))
419        }
420        BinaryLogRecordKind::BuildCanceled => {
421            let e = read_build_canceled(reader, strings, version)?;
422            Ok(BinlogEvent::BuildCanceled(e))
423        }
424
425        // Auxiliary records are handled in next_event() before dispatch.
426        BinaryLogRecordKind::EndOfFile
427        | BinaryLogRecordKind::String
428        | BinaryLogRecordKind::NameValueList
429        | BinaryLogRecordKind::ProjectImportArchive => {
430            unreachable!("auxiliary records handled before dispatch")
431        }
432    }
433}
434
435// ---------------------------------------------------------------------------
436// Archive extraction
437// ---------------------------------------------------------------------------
438
439/// A file extracted from a ProjectImportArchive zip.
440#[derive(Debug, Clone)]
441pub struct ArchiveEntry {
442    /// Path as stored in the zip (typically a relative project file path).
443    pub path: String,
444    /// UTF-8 file contents.
445    pub contents: String,
446}
447
448/// Extract all entries from a single zip archive into `out`.
449fn extract_zip_entries(
450    archive_bytes: &[u8],
451    out: &mut Vec<ArchiveEntry>,
452) -> Result<(), MuninError> {
453    let cursor = Cursor::new(archive_bytes);
454    let mut archive = zip::ZipArchive::new(cursor)
455        .map_err(|e| MuninError::InvalidFormat(format!("invalid ProjectImportArchive zip: {e}")))?;
456
457    for i in 0..archive.len() {
458        let mut file = archive
459            .by_index(i)
460            .map_err(|e| MuninError::InvalidFormat(format!("cannot read zip entry {i}: {e}")))?;
461
462        // Skip directories.
463        if file.is_dir() {
464            continue;
465        }
466
467        let path = file.name().to_string();
468        let mut contents = String::new();
469        // Best-effort read: if the file is not valid UTF-8, skip it.
470        match file.read_to_string(&mut contents) {
471            Ok(_) => {
472                out.push(ArchiveEntry { path, contents });
473            }
474            Err(_) => {
475                // Non-UTF-8 binary content — skip silently.
476            }
477        }
478    }
479    Ok(())
480}
481
482#[cfg(test)]
483mod tests;