Skip to main content

symbolic_il2cpp/line_mapping/
from_object.rs

1use std::collections::BTreeMap;
2use std::io::Write;
3use std::iter::Enumerate;
4use std::str::Lines;
5
6use symbolic_common::{ByteView, DebugId};
7use symbolic_debuginfo::{DebugSession, ObjectLike};
8
9/// A line mapping extracted from an object.
10///
11/// This is only intended as an intermediate structure for serialization,
12/// not for lookups.
13pub struct ObjectLineMapping {
14    mapping: BTreeMap<String, BTreeMap<String, BTreeMap<u32, u32>>>,
15    debug_id: DebugId,
16}
17
18impl ObjectLineMapping {
19    /// Create a line mapping from the given `object`.
20    ///
21    /// The mapping is constructed by iterating over all the source files referenced by `object` and
22    /// parsing Il2cpp `source_info` records from each. The referenced C++ source files are read
23    /// from the local filesystem.
24    pub fn from_object<'data, 'object, O, E>(object: &'object O) -> Result<Self, E>
25    where
26        O: ObjectLike<'data, 'object, Error = E>,
27    {
28        // Read the referenced source files from the local filesystem.
29        Self::from_object_with_provider(object, |path| ByteView::open(path).ok())
30    }
31
32    /// Create a line mapping from the given `object`, obtaining the referenced
33    /// C++ source file contents from `provider`.
34    ///
35    /// This is the filesystem-free counterpart of [`Self::from_object`], for
36    /// environments without filesystem access (e.g. WebAssembly): the object's
37    /// referenced source paths are enumerated via its debug session, and each is
38    /// passed to `provider`, which returns the file's bytes (or `None` to skip
39    /// it). Only files containing Il2cpp `source_info` records contribute to the
40    /// mapping.
41    pub fn from_object_with_provider<'data, 'object, O, E, B, P>(
42        object: &'object O,
43        mut provider: P,
44    ) -> Result<Self, E>
45    where
46        O: ObjectLike<'data, 'object, Error = E>,
47        B: AsRef<[u8]>,
48        P: FnMut(&str) -> Option<B>,
49    {
50        let session = object.debug_session()?;
51        let debug_id = object.debug_id();
52
53        let mut mapping = BTreeMap::new();
54
55        for cpp_file in session.files() {
56            let cpp_file_path = cpp_file?.abs_path_str();
57            if mapping.contains_key(&cpp_file_path) {
58                continue;
59            }
60
61            if let Some(cpp_source) = provider(&cpp_file_path) {
62                let cpp_mapping = Self::parse_source_file(cpp_source.as_ref());
63                if !cpp_mapping.is_empty() {
64                    mapping.insert(cpp_file_path, cpp_mapping);
65                }
66            }
67        }
68
69        Ok(Self { mapping, debug_id })
70    }
71
72    /// Create a line mapping from the source file.
73    ///
74    /// The mapping is constructed by parsing Il2cpp `source_info` records in the given source file.
75    pub(crate) fn parse_source_file(cpp_source: &[u8]) -> BTreeMap<String, BTreeMap<u32, u32>> {
76        let mut cpp_mapping = BTreeMap::new();
77
78        for SourceInfo {
79            cpp_line,
80            cs_file,
81            cs_line,
82        } in SourceInfos::new(cpp_source)
83        {
84            let cs_mapping = cpp_mapping
85                .entry(cs_file.to_string())
86                .or_insert_with(BTreeMap::new);
87            cs_mapping.insert(cpp_line, cs_line);
88        }
89
90        cpp_mapping
91    }
92
93    /// Serializes the line mapping to the given writer as JSON.
94    ///
95    /// The mapping is serialized in the form of nested objects:
96    /// C++ file => C# file => C++ line => C# line
97    ///
98    /// Returns `false` if the resulting JSON did not contain any mappings.
99    pub fn to_writer<W: Write>(mut self, writer: &mut W) -> std::io::Result<bool> {
100        let is_empty = self.mapping.is_empty();
101
102        // This is a big hack: We need the files for different architectures to be different.
103        // To achieve this, we put the debug-id of the file (which is different between architectures)
104        // into the same structure as the normal map, like so:
105        // `"__debug-id__": {"00000000-0000-0000-0000-000000000000": {}}`
106        // When parsing via `LineMapping::parse`, this *looks like* a valid entry, but we will
107        // most likely never have a C++ file named `__debug-id__` ;-)
108        let value = BTreeMap::from([(self.debug_id.to_string(), Default::default())]);
109        self.mapping.insert("__debug-id__".to_owned(), value);
110
111        serde_json::to_writer(writer, &self.mapping)?;
112        Ok(!is_empty)
113    }
114}
115
116/// An Il2cpp `source_info` record.
117#[derive(Debug, PartialEq, Eq)]
118pub(crate) struct SourceInfo<'data> {
119    /// The C++ source line the `source_info` was parsed from.
120    pub(crate) cpp_line: u32,
121    /// The corresponding C# source file.
122    cs_file: &'data str,
123    /// The corresponding C# source line.
124    pub(crate) cs_line: u32,
125}
126
127/// An iterator over Il2cpp `source_info` markers.
128///
129/// The Iterator yields `SourceInfo`s.
130pub(crate) struct SourceInfos<'data> {
131    lines: Enumerate<Lines<'data>>,
132    current: Option<(&'data str, u32)>,
133}
134
135impl<'data> SourceInfos<'data> {
136    /// Parses the `source` leniently, yielding an empty Iterator for non-utf8 data.
137    pub(crate) fn new(source: &'data [u8]) -> Self {
138        let lines = std::str::from_utf8(source)
139            .ok()
140            .unwrap_or_default()
141            .lines()
142            .enumerate();
143        Self {
144            lines,
145            current: None,
146        }
147    }
148}
149
150impl<'data> Iterator for SourceInfos<'data> {
151    type Item = SourceInfo<'data>;
152
153    fn next(&mut self) -> Option<Self::Item> {
154        for (cpp_line_nr, cpp_src_line) in &mut self.lines {
155            match parse_line(cpp_src_line) {
156                // A new source info record. Emit the previously found one, if there is one.
157                Some((cs_file, cs_line)) => {
158                    if let Some((cs_file, cs_line)) = self.current.replace((cs_file, cs_line)) {
159                        return Some(SourceInfo {
160                            cpp_line: cpp_line_nr as u32,
161                            cs_file,
162                            cs_line,
163                        });
164                    }
165                }
166
167                // A comment. Just continue.
168                None if cpp_src_line.trim_start().starts_with("//") => continue,
169                // A source line. Emit the previously found source info record, if there is one.
170                None => {
171                    if let Some((cs_file, cs_line)) = self.current.take() {
172                        return Some(SourceInfo {
173                            cpp_line: (cpp_line_nr + 1) as u32,
174                            cs_file,
175                            cs_line,
176                        });
177                    }
178                }
179            }
180        }
181        None
182    }
183}
184
185/// Extracts the `(file, line)` information
186///
187/// For example, `//<source_info:main.cs:17>`
188/// would be parsed as `("main.cs", 17)`.
189fn parse_line(line: &str) -> Option<(&str, u32)> {
190    let line = line.trim();
191    let source_ref = line.strip_prefix("//<source_info:")?;
192    let source_ref = source_ref.strip_suffix('>')?;
193    let (file, line) = source_ref.rsplit_once(':')?;
194    let line = line.parse().ok()?;
195    Some((file, line))
196}
197
198#[cfg(test)]
199mod tests {
200    use symbolic_common::ByteView;
201    use symbolic_debuginfo::Object;
202    use symbolic_testutils::fixture;
203
204    use super::*;
205
206    #[test]
207    fn one_mapping() {
208        let cpp_source = b"
209            Lorem ipsum dolor sit amet
210            //<source_info:main.cs:17>
211            // some
212            // more
213            // comments
214            actual source code";
215
216        let source_infos: Vec<_> = SourceInfos::new(cpp_source).collect();
217
218        assert_eq!(
219            source_infos,
220            vec![SourceInfo {
221                cpp_line: 7,
222                cs_file: "main.cs",
223                cs_line: 17,
224            }]
225        )
226    }
227
228    #[test]
229    fn several_mappings() {
230        let cpp_source = b"
231            Lorem ipsum dolor sit amet
232            //<source_info:main.cs:17>
233            // some
234            // comments
235            actual source code 1
236            actual source code 2
237
238            //<source_info:main.cs:29>
239            actual source code 3
240
241            //<source_info:main.cs:46>
242            // more
243            // comments
244            actual source code 4";
245
246        let source_infos: Vec<_> = SourceInfos::new(cpp_source).collect();
247
248        assert_eq!(
249            source_infos,
250            vec![
251                SourceInfo {
252                    cpp_line: 6,
253                    cs_file: "main.cs",
254                    cs_line: 17,
255                },
256                SourceInfo {
257                    cpp_line: 10,
258                    cs_file: "main.cs",
259                    cs_line: 29,
260                },
261                SourceInfo {
262                    cpp_line: 15,
263                    cs_file: "main.cs",
264                    cs_line: 46,
265                }
266            ]
267        )
268    }
269
270    #[test]
271    fn missing_source_line() {
272        let cpp_source = b"
273            Lorem ipsum dolor sit amet
274            //<source_info:main.cs:17>
275            // some
276            // comments
277            //<source_info:main.cs:29>
278            actual source code";
279
280        let source_infos: Vec<_> = SourceInfos::new(cpp_source).collect();
281
282        // The first source info has no source line to attach to, so it should use the line
283        // immediately before the second source_info.
284        assert_eq!(
285            source_infos,
286            vec![
287                SourceInfo {
288                    cpp_line: 5,
289                    cs_file: "main.cs",
290                    cs_line: 17,
291                },
292                SourceInfo {
293                    cpp_line: 7,
294                    cs_file: "main.cs",
295                    cs_line: 29,
296                },
297            ]
298        )
299    }
300
301    #[test]
302    fn broken() {
303        let cpp_source = b"
304            Lorem ipsum dolor sit amet
305            //<source_info:main.cs:17>
306            // some
307            // more
308            // comments";
309
310        // Since there is no non-comment line for the source info to attach to,
311        // no source infos should be returned.
312        assert_eq!(SourceInfos::new(cpp_source).count(), 0);
313    }
314
315    /// Synthetic Il2cpp C++: a `source_info` marker followed by a code line maps the
316    /// generated C++ line 2 to `Game.cs` line 42.
317    const SYNTHETIC_SOURCE: &[u8] = b"//<source_info:Game.cs:42>\nint generated = 0;\n";
318
319    #[test]
320    fn test_object_line_mapping_parses_source_info() {
321        let data = ByteView::open(fixture("windows/Sentry.Samples.Console.Basic.pdb")).unwrap();
322        let object = Object::parse(&data).unwrap();
323
324        let mut calls = 0usize;
325        let mapping = ObjectLineMapping::from_object_with_provider(&object, |path| {
326            assert!(!path.is_empty());
327            calls += 1;
328            Some(SYNTHETIC_SOURCE.to_vec())
329        })
330        .unwrap();
331
332        assert!(calls > 0);
333
334        let mut buf = Vec::new();
335        assert!(mapping.to_writer(&mut buf).unwrap());
336
337        let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
338        insta::assert_json_snapshot!(json, @r#"
339        {
340          "C:\\dev\\sentry-dotnet\\samples\\Sentry.Samples.Console.Basic\\Program.cs": {
341            "Game.cs": {
342              "2": 42
343            }
344          },
345          "C:\\dev\\sentry-dotnet\\samples\\Sentry.Samples.Console.Basic\\obj\\release\\net6.0\\.NETCoreApp,Version=v6.0.AssemblyAttributes.cs": {
346            "Game.cs": {
347              "2": 42
348            }
349          },
350          "C:\\dev\\sentry-dotnet\\samples\\Sentry.Samples.Console.Basic\\obj\\release\\net6.0\\Sentry.Samples.Console.Basic.AssemblyInfo.cs": {
351            "Game.cs": {
352              "2": 42
353            }
354          },
355          "C:\\dev\\sentry-dotnet\\samples\\Sentry.Samples.Console.Basic\\obj\\release\\net6.0\\Sentry.Samples.Console.Basic.GlobalUsings.g.cs": {
356            "Game.cs": {
357              "2": 42
358            }
359          },
360          "__debug-id__": {
361            "526f365f-4d8d-4fa8-b370-eae9a9136de4-a39453e5": {}
362          }
363        }
364        "#);
365    }
366
367    #[test]
368    fn test_object_line_mapping_no_sources() {
369        let view = ByteView::open(fixture("windows/Sentry.Samples.Console.Basic.pdb")).unwrap();
370        let object = Object::parse(&view).unwrap();
371
372        let mapping =
373            ObjectLineMapping::from_object_with_provider(&object, |_| None::<Vec<u8>>).unwrap();
374
375        let mut buf = Vec::new();
376        assert!(!mapping.to_writer(&mut buf).unwrap());
377    }
378}