Skip to main content

symbolic_ppdb/format/
mod.rs

1mod metadata;
2mod raw;
3mod sequence_points;
4mod streams;
5mod utils;
6
7use std::{borrow::Cow, collections::BTreeMap, fmt, io::Read};
8
9use flate2::read::DeflateDecoder;
10use serde::Deserialize;
11use thiserror::Error;
12use watto::Pod;
13
14use symbolic_common::{DebugId, Language, SourceLinkMappings, Uuid};
15
16use metadata::{
17    CustomDebugInformation, CustomDebugInformationIterator, CustomDebugInformationTag,
18    MetadataStream, Table, TableType,
19};
20use streams::{BlobStream, GuidStream, PdbStream, StringStream, UsStream};
21
22/// The kind of a [`FormatError`].
23#[derive(Debug, Clone, Copy, Error)]
24#[non_exhaustive]
25pub enum FormatErrorKind {
26    /// The header of the Portable PDB file could not be read.
27    #[error("invalid header")]
28    InvalidHeader,
29    #[error("invalid signature")]
30    /// The header of the Portable PDB does not contain the correct signature.
31    InvalidSignature,
32    /// The file ends prematurely.
33    #[error("invalid length")]
34    InvalidLength,
35    /// The file does not contain a valid version string.
36    #[error("invalid version string")]
37    InvalidVersionString,
38    /// A stream header could not be read.
39    #[error("invalid stream header")]
40    InvalidStreamHeader,
41    /// A stream's name could not be read.
42    #[error("invalid stream name")]
43    InvalidStreamName,
44    /// String data was requested, but the file does not contain a `#Strings` stream.
45    #[error("file does not contain a #Strings stream")]
46    NoStringsStream,
47    /// The given offset is out of bounds for the string heap.
48    #[error("invalid string offset")]
49    InvalidStringOffset,
50    /// Tried to read invalid string data.
51    #[error("invalid string data")]
52    InvalidStringData,
53    /// An unrecognized stream name was encountered.
54    #[error("unknown stream")]
55    UnknownStream,
56    /// GUID data was requested, but the file does not contain a `#GUID` stream.
57    #[error("file does not contain a #Guid stream")]
58    NoGuidStream,
59    /// The given index is out of bounds for the GUID heap.
60    #[error("invalid guid index")]
61    InvalidGuidIndex,
62    /// The table stream is too small to hold all claimed tables.
63    #[error(
64        "insufficient table data: {0} bytes required, but table stream only contains {1} bytes"
65    )]
66    InsufficientTableData(usize, usize),
67    /// The given offset is out of bounds for the `#Blob` heap.
68    #[error("invalid blob offset")]
69    InvalidBlobOffset,
70    /// The given offset points to invalid blob data.
71    #[error("invalid blob data")]
72    InvalidBlobData,
73    /// Blob data was requested, but the file does not contain a `#Blob` stream.
74    #[error("file does not contain a #Blob stream")]
75    NoBlobStream,
76    /// Tried to read an invalid compressed unsigned number.
77    #[error("invalid compressed unsigned number")]
78    InvalidCompressedUnsigned,
79    /// Tried to read an invalid compressed signed number.
80    #[error("invalid compressed signed number")]
81    InvalidCompressedSigned,
82    /// Could not read a document name.
83    #[error("invalid document name")]
84    InvalidDocumentName,
85    /// Failed to parse a sequence point.
86    #[error("invalid sequence point")]
87    InvalidSequencePoint,
88    /// Table data was requested, but the file does not contain a `#~` stream.
89    #[error("file does not contain a #~ stream")]
90    NoMetadataStream,
91    /// The given row index is out of bounds for the table.
92    #[error("row index {1} is out of bounds for table {0:?}")]
93    RowIndexOutOfBounds(TableType, usize),
94    /// The given column index is out of bounds for the table.
95    #[error("column index {1} is out of bounds for table {0:?}")]
96    ColIndexOutOfBounds(TableType, usize),
97    /// The given column in the table has an incompatible width.
98    #[error("column {1} in table {0:?} has incompatible width {2}")]
99    ColumnWidth(TableType, usize, usize),
100    /// Tried to read an custom debug information table item tag.
101    #[error("invalid custom debug information table item tag {0}")]
102    InvalidCustomDebugInformationTag(u32),
103    /// Tried to read contents of a blob in an unknown format.
104    #[error("invalid blob format {0}")]
105    InvalidBlobFormat(u32),
106    /// Failed to parse Source Link JSON
107    #[error("invalid source link JSON")]
108    InvalidSourceLinkJson,
109    /// An embedded source file exceeded the configured size limit.
110    #[error("embedded source file size ({0}) exceeds maximum")]
111    EmbeddedSourceFileSizeExceeded(usize),
112}
113
114/// An error encountered while parsing a [`PortablePdb`] file.
115#[derive(Debug, Error)]
116#[error("{kind}")]
117pub struct FormatError {
118    pub(crate) kind: FormatErrorKind,
119    #[source]
120    pub(crate) source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
121}
122
123impl FormatError {
124    /// Creates a new FormatError error from a known kind of error as well as an
125    /// arbitrary error payload.
126    pub(crate) fn new<E>(kind: FormatErrorKind, source: E) -> Self
127    where
128        E: Into<Box<dyn std::error::Error + Send + Sync>>,
129    {
130        let source = Some(source.into());
131        Self { kind, source }
132    }
133
134    /// Returns the corresponding [`FormatErrorKind`] for this error.
135    pub fn kind(&self) -> FormatErrorKind {
136        self.kind
137    }
138}
139
140impl From<FormatErrorKind> for FormatError {
141    fn from(kind: FormatErrorKind) -> Self {
142        Self { kind, source: None }
143    }
144}
145
146/// A parsed Portable PDB file.
147///
148/// This can be converted to a [`PortablePdbCache`](crate::PortablePdbCache) using the
149/// [`PortablePdbCacheConverter::process_portable_pdb`](crate::PortablePdbCacheConverter::process_portable_pdb)
150/// method.
151#[derive(Clone)]
152pub struct PortablePdb<'data> {
153    /// First part of the metadata header.
154    header: &'data raw::Header,
155    /// The version string.
156    version_string: &'data str,
157    /// Second part of the metadata header.
158    header2: &'data raw::HeaderPart2,
159    /// The file's #PDB stream, if it exists.
160    pdb_stream: Option<PdbStream<'data>>,
161    /// The file's #~ stream, if it exists.
162    metadata_stream: Option<MetadataStream<'data>>,
163    /// The file's #Strings stream, if it exists.
164    string_stream: Option<StringStream<'data>>,
165    /// The file's #US stream, if it exists.
166    us_stream: Option<UsStream<'data>>,
167    /// The file's #Blob stream, if it exists.
168    blob_stream: Option<BlobStream<'data>>,
169    /// The file's #GUID stream, if it exists.
170    guid_stream: Option<GuidStream<'data>>,
171    /// Source link mappings
172    source_link_mappings: SourceLinkMappings,
173}
174
175impl fmt::Debug for PortablePdb<'_> {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        f.debug_struct("PortablePdb")
178            .field("header", &self.header)
179            .field("version_string", &self.version_string)
180            .field("header2", &self.header2)
181            .field("has_pdb_stream", &self.pdb_stream.is_some())
182            .field("has_table_stream", &self.metadata_stream.is_some())
183            .field("has_string_stream", &self.string_stream.is_some())
184            .field("has_us_stream", &self.us_stream.is_some())
185            .field("has_blob_stream", &self.blob_stream.is_some())
186            .field("has_guid_stream", &self.guid_stream.is_some())
187            .finish()
188    }
189}
190
191impl<'data> PortablePdb<'data> {
192    /// Checks whether the provided buffer could potentially be a Portable PDB file,
193    /// without fully parsing it.
194    pub fn peek(buf: &[u8]) -> bool {
195        if let Some((header, _)) = raw::Header::ref_from_prefix(buf) {
196            return header.signature == raw::METADATA_SIGNATURE;
197        }
198        false
199    }
200
201    /// Parses the provided buffer into a Portable PDB file.
202    pub fn parse(buf: &'data [u8]) -> Result<Self, FormatError> {
203        let (header, rest) =
204            raw::Header::ref_from_prefix(buf).ok_or(FormatErrorKind::InvalidHeader)?;
205
206        if header.signature != raw::METADATA_SIGNATURE {
207            return Err(FormatErrorKind::InvalidSignature.into());
208        }
209
210        // TODO: verify major/minor version
211        // TODO: verify reserved
212        let version_length = header.version_length as usize;
213        let version_buf = rest
214            .get(..version_length)
215            .ok_or(FormatErrorKind::InvalidLength)?;
216        let version_buf = version_buf
217            .split(|c| *c == 0)
218            .next()
219            .ok_or(FormatErrorKind::InvalidVersionString)?;
220        let version = std::str::from_utf8(version_buf)
221            .map_err(|e| FormatError::new(FormatErrorKind::InvalidVersionString, e))?;
222
223        // We already know that buf is long enough.
224        let streams_buf = &rest[version_length..];
225        let (header2, mut streams_buf) =
226            raw::HeaderPart2::ref_from_prefix(streams_buf).ok_or(FormatErrorKind::InvalidHeader)?;
227
228        // TODO: validate flags
229
230        let stream_count = header2.streams;
231
232        let mut result = Self {
233            header,
234            version_string: version,
235            header2,
236            pdb_stream: None,
237            metadata_stream: None,
238            string_stream: None,
239            us_stream: None,
240            blob_stream: None,
241            guid_stream: None,
242            source_link_mappings: SourceLinkMappings::default(),
243        };
244
245        let mut metadata_stream = None;
246        for _ in 0..stream_count {
247            let (header, after_header_buf) = raw::StreamHeader::ref_from_prefix(streams_buf)
248                .ok_or(FormatErrorKind::InvalidStreamHeader)?;
249
250            let name_buf = after_header_buf.get(..32).unwrap_or(after_header_buf);
251            let name_buf = name_buf
252                .split(|c| *c == 0)
253                .next()
254                .ok_or(FormatErrorKind::InvalidStreamName)?;
255            let name = std::str::from_utf8(name_buf)
256                .map_err(|e| FormatError::new(FormatErrorKind::InvalidStreamName, e))?;
257
258            let mut rounded_name_len = name.len() + 1;
259            rounded_name_len = match rounded_name_len % 4 {
260                0 => rounded_name_len,
261                r => rounded_name_len + (4 - r),
262            };
263            streams_buf = after_header_buf
264                .get(rounded_name_len..)
265                .ok_or(FormatErrorKind::InvalidLength)?;
266
267            let offset = header.offset as usize;
268            let size = header.size as usize;
269            let stream_buf = buf
270                .get(offset..offset + size)
271                .ok_or(FormatErrorKind::InvalidLength)?;
272
273            match name {
274                "#Pdb" => result.pdb_stream = Some(PdbStream::parse(stream_buf)?),
275                // Save the #~ stream for last; it definitely must be parsed after the #Pdb stream.
276                "#~" => metadata_stream = Some(stream_buf),
277                "#Strings" => result.string_stream = Some(StringStream::new(stream_buf)),
278                "#US" => result.us_stream = Some(UsStream::new(stream_buf)),
279                "#Blob" => result.blob_stream = Some(BlobStream::new(stream_buf)),
280                "#GUID" => result.guid_stream = Some(GuidStream::parse(stream_buf)?),
281                _ => return Err(FormatErrorKind::UnknownStream.into()),
282            }
283        }
284
285        if let Some(stream_buf) = metadata_stream {
286            result.metadata_stream = Some(MetadataStream::parse(
287                stream_buf,
288                result
289                    .pdb_stream
290                    .as_ref()
291                    .map_or([0; 64], |s| s.referenced_table_sizes),
292            )?)
293        }
294
295        // Read source link mappings.
296        // https://github.com/dotnet/runtime/blob/main/docs/design/specs/PortablePdb-Metadata.md#source-link-c-and-vb-compilers
297        const SOURCE_LINK_KIND: Uuid = uuid::uuid!("CC110556-A091-4D38-9FEC-25AB9A351A6A");
298
299        #[derive(Debug, Clone, Deserialize)]
300        struct SourceLinkDocuments {
301            documents: BTreeMap<String, String>,
302        }
303
304        for cdi in CustomDebugInformationIterator::new(&result, SOURCE_LINK_KIND)? {
305            let cdi = cdi?;
306            // Note: only handle module #1 (do we actually handle multiple modules in any way??)
307            if let (CustomDebugInformationTag::Module, 1) = (cdi.tag, cdi.value) {
308                let docs: SourceLinkDocuments = serde_json::from_slice(result.get_blob(cdi.blob)?)
309                    .map_err(|e| FormatError::new(FormatErrorKind::InvalidSourceLinkJson, e))?;
310                result
311                    .source_link_mappings
312                    .extend(docs.documents.iter().map(|(k, v)| (&k[..], &v[..])));
313            }
314        }
315
316        Ok(result)
317    }
318
319    /// Reads the string starting at the given offset from this file's string heap.
320    #[allow(unused)]
321    fn get_string(&self, offset: u32) -> Result<&'data str, FormatError> {
322        self.string_stream
323            .as_ref()
324            .ok_or(FormatErrorKind::NoStringsStream)?
325            .get_string(offset)
326    }
327
328    /// Reads the GUID with the given index from this file's GUID heap.
329    ///
330    /// Note that the index is 1-based!
331    fn get_guid(&self, idx: u32) -> Result<Uuid, FormatError> {
332        self.guid_stream
333            .as_ref()
334            .ok_or(FormatErrorKind::NoGuidStream)?
335            .get_guid(idx)
336            .ok_or_else(|| FormatErrorKind::InvalidGuidIndex.into())
337    }
338
339    /// Reads the blob starting at the given offset from this file's blob heap.
340    fn get_blob(&self, offset: u32) -> Result<&'data [u8], FormatError> {
341        self.blob_stream
342            .as_ref()
343            .ok_or(FormatErrorKind::NoBlobStream)?
344            .get_blob(offset)
345    }
346
347    /// Reads this file's PDB ID from its #PDB stream.
348    pub fn pdb_id(&self) -> Option<DebugId> {
349        self.pdb_stream.as_ref().map(|stream| stream.id())
350    }
351
352    /// Reads the `(row, col)` cell in the given table as a `u32`.
353    ///
354    /// This returns an error if the indices are out of bounds for the table
355    /// or the cell is too wide for a `u32`.
356    ///
357    /// Note that row and column indices are 1-based!
358    pub(crate) fn get_table(&self, table: TableType) -> Result<Table<'_>, FormatError> {
359        let md_stream = self
360            .metadata_stream
361            .as_ref()
362            .ok_or(FormatErrorKind::NoMetadataStream)?;
363        Ok(md_stream[table])
364    }
365
366    /// Returns true if this portable pdb file contains method debug information.
367    pub fn has_debug_info(&self) -> bool {
368        self.metadata_stream
369            .as_ref()
370            .is_some_and(|md_stream| md_stream[TableType::MethodDebugInformation].rows > 0)
371    }
372
373    /// Get source file referenced by this PDB.
374    ///
375    /// Given index must be between 1 and get_documents_count().
376    pub fn get_document(&self, idx: usize) -> Result<Document, FormatError> {
377        let table = self.get_table(TableType::Document)?;
378        let row = table.get_row(idx)?;
379        let name_offset = row.get_col_u32(1)?;
380        let lang_offset = row.get_col_u32(4)?;
381
382        let name = self.get_document_name(name_offset)?;
383        let lang = self.get_document_lang(lang_offset)?;
384
385        Ok(Document { name, lang })
386    }
387
388    /// Get the number of source files referenced by this PDB.
389    pub fn get_documents_count(&self) -> Result<usize, FormatError> {
390        let table = self.get_table(TableType::Document)?;
391        Ok(table.rows)
392    }
393
394    /// An iterator over source files contents' embedded in this PDB.
395    pub fn get_embedded_sources(&self) -> Result<EmbeddedSourceIterator<'_, 'data>, FormatError> {
396        EmbeddedSourceIterator::new(self)
397    }
398
399    /// Whether this PPDB contains source-link mappings.
400    pub fn has_source_links(&self) -> Result<bool, FormatError> {
401        Ok(!self.source_link_mappings.is_empty() && self.get_documents_count()? > 0)
402    }
403
404    /// Tries to resolve given document as a source link (URL).
405    /// Make sure to try [Self::get_embedded_sources] first when looking for a source file, because
406    /// function may return a link that actually doesn't exist (e.g. file is in .gitignore).
407    /// In that case, it's usually the case that the file is embedded in the PPDB instead.
408    pub fn get_source_link(&self, document: &Document) -> Option<Cow<'_, str>> {
409        self.source_link_mappings
410            .resolve(&document.name)
411            .map(Cow::Owned)
412    }
413}
414
415/// Represents a source file that is referenced by this PDB.
416#[derive(Debug, Clone)]
417pub struct Document {
418    /// Document names are usually normalized full paths.
419    pub name: String,
420    pub(crate) lang: Language,
421}
422
423/// An iterator over Embedded Sources.
424#[derive(Debug, Clone)]
425pub struct EmbeddedSourceIterator<'object, 'data> {
426    ppdb: &'object PortablePdb<'data>,
427    inner_it: CustomDebugInformationIterator<'data>,
428}
429
430impl<'object, 'data> EmbeddedSourceIterator<'object, 'data> {
431    fn new(ppdb: &'object PortablePdb<'data>) -> Result<Self, FormatError> {
432        // https://github.com/dotnet/runtime/blob/main/docs/design/specs/PortablePdb-Metadata.md#embedded-source-c-and-vb-compilers
433        const EMBEDDED_SOURCES_KIND: Uuid = uuid::uuid!("0E8A571B-6926-466E-B4AD-8AB04611F5FE");
434        let inner_it = CustomDebugInformationIterator::new(ppdb, EMBEDDED_SOURCES_KIND)?;
435        Ok(EmbeddedSourceIterator { ppdb, inner_it })
436    }
437
438    fn get_source(
439        &mut self,
440        info: CustomDebugInformation,
441    ) -> Result<EmbeddedSource<'data>, FormatError> {
442        let document = self.ppdb.get_document(info.value as usize)?;
443        let blob = self.ppdb.get_blob(info.blob)?;
444        Ok(EmbeddedSource { document, blob })
445    }
446}
447
448impl<'data> Iterator for EmbeddedSourceIterator<'_, 'data> {
449    type Item = Result<EmbeddedSource<'data>, FormatError>;
450
451    fn next(&mut self) -> Option<Self::Item> {
452        // Skip rows that are not "Document". From the specs, it should always be the case but we've
453        // had a MethodDef (with an invalid 0 row index...) in the field so there's a test for it.
454        while let Some(row) = self.inner_it.next() {
455            match row {
456                Err(e) => return Some(Err(e)),
457                Ok(info) => {
458                    if let CustomDebugInformationTag::Document = info.tag {
459                        return Some(self.get_source(info));
460                    }
461                }
462            }
463        }
464        None
465    }
466}
467
468/// Lazy Embedded Source file reader.
469#[derive(Debug, Clone)]
470pub struct EmbeddedSource<'data> {
471    document: Document,
472    blob: &'data [u8],
473}
474
475impl<'data, 'object> EmbeddedSource<'data> {
476    /// Returns the build-time path associated with this source file.
477    pub fn get_path(&'object self) -> &'object str {
478        self.document.name.as_str()
479    }
480
481    /// Reads the source file contents from the Portable PDB.
482    pub fn get_contents(&self) -> Result<Cow<'data, [u8]>, FormatError> {
483        self.get_contents_bounded(None)
484    }
485
486    /// Reads the source file contents from the Portable PDB.
487    ///
488    /// If the contents are compressed, the decompressed size
489    /// can be bounded with `max_decompressed_size`.
490    pub fn get_contents_bounded(
491        &self,
492        max_decompressed_size: Option<usize>,
493    ) -> Result<Cow<'data, [u8]>, FormatError> {
494        // The blob has the following structure: `Blob ::= format content`
495        // - format - int32 - Indicates how the content is serialized.
496        //     0 = raw bytes, uncompressed.
497        //     Positive value = compressed by deflate algorithm and value indicates uncompressed size.
498        //     Negative values reserved for future formats.
499        // - content - format-specific - The text of the document in the specified format. The length is implied by the length of the blob minus four bytes for the format.
500        if self.blob.len() < 4 {
501            return Err(FormatErrorKind::InvalidBlobData.into());
502        }
503        let (format_blob, data_blob) = self.blob.split_at(4);
504        let format = u32::from_ne_bytes(format_blob.try_into().unwrap());
505
506        // Per the above comment, the format is really an `i32`.
507        // None-negative values are currently valid, negative ones are reserved.
508        // We can't trivially change this to an `i32`, though, because of
509        // `FormatErrorKind::InvalidBlobFormat`, which expects a `u32`.
510        // Therefore, we manually bound the format here to the largest
511        // positive `i32`.
512        const MAX_VALID_FORMAT: u32 = i32::MAX as u32;
513
514        match format {
515            0 => Ok(Cow::Borrowed(data_blob)),
516            1..=MAX_VALID_FORMAT => {
517                let size = format as usize;
518                if max_decompressed_size.is_some_and(|max| size > max) {
519                    return Err(FormatErrorKind::EmbeddedSourceFileSizeExceeded(size).into());
520                }
521                self.inflate_contents(size, data_blob)
522            }
523            _ => Err(FormatErrorKind::InvalidBlobFormat(format).into()),
524        }
525    }
526
527    fn inflate_contents(
528        &self,
529        size: usize,
530        data: &'data [u8],
531    ) -> Result<Cow<'data, [u8]>, FormatError> {
532        let decoder = DeflateDecoder::new(data);
533        let mut output = Vec::with_capacity(size + 1);
534        let read_size = decoder
535            .take(size as u64 + 1)
536            .read_to_end(&mut output)
537            .map_err(|e| FormatError::new(FormatErrorKind::InvalidBlobData, e))?;
538        if read_size != size {
539            return Err(FormatErrorKind::InvalidLength.into());
540        }
541        Ok(Cow::Owned(output))
542    }
543}