Skip to main content

soldb_ethdebug/
source_map.rs

1//! The legacy `srcmap` debug format.
2//!
3//! Solidity compilers can emit source mappings as a compact `s:l:f:j:m` string alongside
4//! the bytecode, with fields inherited from the previous entry when omitted. This module
5//! parses that format and builds the program-counter index for it, which requires walking
6//! the bytecode so `PUSH` immediates are not mistaken for opcodes.
7//!
8//! This is a fallback path. ETHDebug is the primary source of debug information and
9//! carries strictly more of it; prefer it whenever the compiler can produce it.
10
11use std::collections::BTreeMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use revm_bytecode::OpCode;
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Value};
18use soldb_core::{SoldbError, SoldbResult};
19
20use crate::{EthdebugInfo, Instruction};
21
22/// The bytecode environment described by a legacy source map.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum SourceMapEnvironment {
25    Creation,
26    Runtime,
27}
28
29impl SourceMapEnvironment {
30    fn fields(self) -> (&'static str, &'static str, &'static str) {
31        match self {
32            Self::Creation => ("srcmap", "bin", "create"),
33            Self::Runtime => ("srcmap-runtime", "bin-runtime", "call"),
34        }
35    }
36}
37
38/// A legacy source-map program adapted to the common debug-info model.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct SourceMapProgram {
41    pub info: EthdebugInfo,
42    pub resources: Value,
43    pub source_contents: BTreeMap<u64, String>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct SourceMapEntry {
48    pub offset: i64,
49    pub length: i64,
50    pub file_index: i64,
51    pub jump_type: String,
52    pub modifier_depth: i64,
53}
54
55impl SourceMapEntry {
56    #[must_use]
57    pub fn is_valid(&self) -> bool {
58        self.file_index >= 0 && self.offset >= 0
59    }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct SourceMapInfo {
64    pub contract_name: String,
65    pub sources: Vec<String>,
66    pub bytecode: Vec<u8>,
67    pub srcmap_entries: Vec<SourceMapEntry>,
68    pub pc_to_instruction_index: BTreeMap<usize, usize>,
69    pub compiler_version: Option<String>,
70}
71
72impl SourceMapInfo {
73    #[must_use]
74    pub fn source_entry_at_pc(&self, pc: usize) -> Option<&SourceMapEntry> {
75        let instruction_index = self.pc_to_instruction_index.get(&pc)?;
76        self.srcmap_entries.get(*instruction_index)
77    }
78
79    #[must_use]
80    pub fn source_info(&self, pc: usize) -> Option<(&str, i64, i64)> {
81        let entry = self.source_entry_at_pc(pc)?;
82        if !entry.is_valid() {
83            return None;
84        }
85
86        let source = self.sources.get(usize::try_from(entry.file_index).ok()?)?;
87        Some((source, entry.offset, entry.length))
88    }
89}
90
91pub fn build_pc_to_instruction_map(bytecode: &[u8]) -> BTreeMap<usize, usize> {
92    let mut pc_to_index = BTreeMap::new();
93    let mut pc = 0;
94    let mut instruction_index = 0;
95
96    while pc < bytecode.len() {
97        pc_to_index.insert(pc, instruction_index);
98        pc += 1 + push_data_size(bytecode[pc]).unwrap_or(0);
99        instruction_index += 1;
100    }
101
102    pc_to_index
103}
104
105pub fn parse_srcmap(srcmap: &str) -> SoldbResult<Vec<SourceMapEntry>> {
106    if srcmap.is_empty() {
107        return Ok(Vec::new());
108    }
109
110    let mut entries = Vec::new();
111    let mut previous = SourceMapEntry {
112        offset: 0,
113        length: 0,
114        file_index: -1,
115        jump_type: "-".to_owned(),
116        modifier_depth: 0,
117    };
118
119    for part in srcmap.split(';') {
120        if part.is_empty() {
121            entries.push(previous.clone());
122            continue;
123        }
124
125        let fields = part.split(':').collect::<Vec<_>>();
126        let entry = SourceMapEntry {
127            offset: parse_inherited_i64(fields.first().copied(), previous.offset, "offset")?,
128            length: parse_inherited_i64(fields.get(1).copied(), previous.length, "length")?,
129            file_index: parse_inherited_i64(
130                fields.get(2).copied(),
131                previous.file_index,
132                "file index",
133            )?,
134            jump_type: parse_inherited_string(fields.get(3).copied(), &previous.jump_type),
135            modifier_depth: parse_inherited_i64(
136                fields.get(4).copied(),
137                previous.modifier_depth,
138                "modifier depth",
139            )?,
140        };
141
142        previous = entry.clone();
143        entries.push(entry);
144    }
145
146    Ok(entries)
147}
148
149/// Loads one program from `combined.json` when the requested source map exists.
150///
151/// `Ok(None)` means the directory has no legacy artifact, or the selected contract
152/// does not contain that creation/runtime map. A present but malformed artifact is
153/// always an error so callers cannot silently lose source attribution.
154pub fn load_source_map_program(
155    root: &Path,
156    contract_name: &str,
157    environment: SourceMapEnvironment,
158) -> SoldbResult<Option<SourceMapProgram>> {
159    let path = root.join("combined.json");
160    if !path.exists() {
161        return Ok(None);
162    }
163
164    let input = fs::read_to_string(&path).map_err(|error| {
165        SoldbError::Message(format!("failed to read `{}`: {error}", path.display()))
166    })?;
167    let combined = serde_json::from_str::<Value>(&input).map_err(|error| {
168        SoldbError::Message(format!("invalid JSON in `{}`: {error}", path.display()))
169    })?;
170    source_map_program_from_combined(root, &path, &combined, contract_name, environment)
171}
172
173fn source_map_program_from_combined(
174    root: &Path,
175    artifact_path: &Path,
176    combined: &Value,
177    contract_name: &str,
178    environment: SourceMapEnvironment,
179) -> SoldbResult<Option<SourceMapProgram>> {
180    let contracts = combined
181        .get("contracts")
182        .and_then(Value::as_object)
183        .ok_or_else(|| {
184            SoldbError::Message(format!(
185                "legacy artifact `{}` does not contain a contracts object",
186                artifact_path.display()
187            ))
188        })?;
189    let Some((contract_key, contract)) = find_contract(contracts, contract_name)? else {
190        return Ok(None);
191    };
192    let (source_map_field, bytecode_field, environment_name) = environment.fields();
193    let Some(source_map) = contract.get(source_map_field).and_then(Value::as_str) else {
194        return Ok(None);
195    };
196    let bytecode = contract
197        .get(bytecode_field)
198        .and_then(Value::as_str)
199        .ok_or_else(|| {
200            SoldbError::Message(format!(
201                "legacy artifact `{}` has `{source_map_field}` for `{contract_key}` but no \
202                 `{bytecode_field}`; recompile with bytecode output enabled",
203                artifact_path.display()
204            ))
205        })?;
206    let bytecode = decode_bytecode(bytecode, artifact_path, contract_key, bytecode_field)?;
207    let source_map = parse_srcmap(source_map)?;
208    let source_list = combined
209        .get("sourceList")
210        .and_then(Value::as_array)
211        .ok_or_else(|| {
212            SoldbError::Message(format!(
213                "legacy artifact `{}` does not contain a sourceList array",
214                artifact_path.display()
215            ))
216        })?;
217
218    let mut sources = BTreeMap::new();
219    let mut source_contents = BTreeMap::new();
220    let mut compilation_sources = Vec::with_capacity(source_list.len());
221    for (source_id, source) in source_list.iter().enumerate() {
222        let source_path = source.as_str().ok_or_else(|| {
223            SoldbError::Message(format!(
224                "legacy artifact `{}` has a non-string sourceList entry at index {source_id}",
225                artifact_path.display()
226            ))
227        })?;
228        let source_id = u64::try_from(source_id)
229            .map_err(|_| SoldbError::Message("source index does not fit in `u64`".to_owned()))?;
230        sources.insert(source_id, source_path.to_owned());
231
232        let contents = read_source(root, source_path)?;
233        if let Some(contents) = &contents {
234            source_contents.insert(source_id, contents.clone());
235        }
236        let mut source = json!({
237            "id": source_id,
238            "path": source_path,
239            "language": "Solidity",
240        });
241        if let Some(contents) = contents {
242            source["contents"] = Value::String(contents);
243        }
244        compilation_sources.push(source);
245    }
246
247    let pc_to_instruction_index = build_pc_to_instruction_map(&bytecode);
248    if source_map.len() > pc_to_instruction_index.len() {
249        return Err(SoldbError::Message(format!(
250            "legacy source map for `{contract_key}` has {} entries but `{bytecode_field}` has \
251             only {} instructions",
252            source_map.len(),
253            pc_to_instruction_index.len()
254        )));
255    }
256
257    let mut instructions = Vec::with_capacity(source_map.len());
258    for (pc, instruction_index) in pc_to_instruction_index {
259        let Some(entry) = source_map.get(instruction_index) else {
260            break;
261        };
262        instructions.push(Instruction {
263            offset: u64::try_from(pc).map_err(|_| {
264                SoldbError::Message("program counter does not fit in `u64`".to_owned())
265            })?,
266            operation: opcode_operation(bytecode[pc]),
267            context: source_context(
268                entry,
269                sources.len(),
270                &source_contents,
271                instruction_index,
272                contract_key,
273            )?,
274        });
275    }
276
277    let compiler_version = combined
278        .get("version")
279        .and_then(Value::as_str)
280        .unwrap_or("unknown");
281    let compilation = json!({
282        "id": format!("legacy-{compiler_version}"),
283        "compiler": {
284            "name": "Solidity compiler",
285            "version": compiler_version,
286        },
287        "sources": compilation_sources,
288    });
289    let resources = json!({
290        "compilation": compilation,
291        "types": {},
292        "pointers": {},
293    });
294    let resolved_contract_name = contract_key
295        .rsplit_once(':')
296        .map_or(contract_key, |(_, name)| name);
297    let info = EthdebugInfo {
298        compilation,
299        contract_name: resolved_contract_name.to_owned(),
300        environment: environment_name.to_owned(),
301        instructions,
302        sources,
303        variable_locations: BTreeMap::new(),
304    };
305
306    Ok(Some(SourceMapProgram {
307        info,
308        resources,
309        source_contents,
310    }))
311}
312
313fn find_contract<'a>(
314    contracts: &'a serde_json::Map<String, Value>,
315    contract_name: &str,
316) -> SoldbResult<Option<(&'a str, &'a Value)>> {
317    if contract_name.is_empty() {
318        let mut contracts = contracts.iter();
319        let Some((name, contract)) = contracts.next() else {
320            return Ok(None);
321        };
322        if contracts.next().is_some() {
323            return Err(SoldbError::Message(
324                "legacy combined JSON contains multiple contracts; provide `contractName`"
325                    .to_owned(),
326            ));
327        }
328        return Ok(Some((name, contract)));
329    }
330    if let Some((name, contract)) = contracts.get_key_value(contract_name) {
331        return Ok(Some((name, contract)));
332    }
333
334    let suffix = format!(":{contract_name}");
335    let mut matches = contracts.iter().filter(|(name, _)| name.ends_with(&suffix));
336    let Some((name, contract)) = matches.next() else {
337        return Ok(None);
338    };
339    if matches.next().is_some() {
340        return Err(SoldbError::Message(format!(
341            "contract name `{contract_name}` is ambiguous in legacy combined JSON"
342        )));
343    }
344    Ok(Some((name, contract)))
345}
346
347fn decode_bytecode(
348    bytecode: &str,
349    artifact_path: &Path,
350    contract_name: &str,
351    bytecode_field: &str,
352) -> SoldbResult<Vec<u8>> {
353    let bytecode = bytecode.strip_prefix("0x").unwrap_or(bytecode);
354    let (byte_pairs, remainder) = bytecode.as_bytes().as_chunks::<2>();
355    if !remainder.is_empty() {
356        return Err(SoldbError::Message(format!(
357            "`{bytecode_field}` for `{contract_name}` in `{}` has an odd number of hex digits",
358            artifact_path.display()
359        )));
360    }
361
362    byte_pairs
363        .iter()
364        .map(|digits| {
365            let high = decode_hex_digit(digits[0]);
366            let low = decode_hex_digit(digits[1]);
367            match (high, low) {
368                (Some(high), Some(low)) => Ok(high << 4 | low),
369                _ => Err(SoldbError::Message(format!(
370                    "`{bytecode_field}` for `{contract_name}` in `{}` is not valid hex",
371                    artifact_path.display()
372                ))),
373            }
374        })
375        .collect()
376}
377
378fn decode_hex_digit(digit: u8) -> Option<u8> {
379    match digit {
380        b'0'..=b'9' => Some(digit - b'0'),
381        b'a'..=b'f' => Some(digit - b'a' + 10),
382        b'A'..=b'F' => Some(digit - b'A' + 10),
383        _ => None,
384    }
385}
386
387fn opcode_operation(opcode: u8) -> Value {
388    let mnemonic = OpCode::new(opcode).map_or_else(
389        || format!("UNKNOWN(0x{opcode:02x})"),
390        |opcode| opcode.as_str().to_owned(),
391    );
392    json!({"mnemonic": mnemonic})
393}
394
395fn source_context(
396    entry: &SourceMapEntry,
397    source_count: usize,
398    source_contents: &BTreeMap<u64, String>,
399    instruction_index: usize,
400    contract_name: &str,
401) -> SoldbResult<Option<Value>> {
402    if entry.file_index < 0 {
403        return Ok(None);
404    }
405    if entry.offset < 0 || entry.length < 0 {
406        return Err(SoldbError::Message(format!(
407            "legacy source-map entry {instruction_index} for `{contract_name}` has a negative \
408             source range"
409        )));
410    }
411    let source_id = u64::try_from(entry.file_index).map_err(|_| {
412        SoldbError::Message(format!(
413            "legacy source-map entry {instruction_index} for `{contract_name}` has an invalid \
414             source index"
415        ))
416    })?;
417    if usize::try_from(source_id).map_or(true, |source_id| source_id >= source_count) {
418        return Err(SoldbError::Message(format!(
419            "legacy source-map entry {instruction_index} for `{contract_name}` references \
420             missing source {source_id}"
421        )));
422    }
423    let offset = u64::try_from(entry.offset).map_err(|_| {
424        SoldbError::Message(format!(
425            "legacy source-map entry {instruction_index} for `{contract_name}` has an invalid \
426             offset"
427        ))
428    })?;
429    let length = u64::try_from(entry.length).map_err(|_| {
430        SoldbError::Message(format!(
431            "legacy source-map entry {instruction_index} for `{contract_name}` has an invalid \
432             length"
433        ))
434    })?;
435    let end = offset.checked_add(length).ok_or_else(|| {
436        SoldbError::Message(format!(
437            "legacy source-map entry {instruction_index} for `{contract_name}` overflows its \
438             source range"
439        ))
440    })?;
441    if let Some(source) = source_contents.get(&source_id) {
442        let source_len = u64::try_from(source.len())
443            .map_err(|_| SoldbError::Message("source length does not fit in `u64`".to_owned()))?;
444        if end > source_len {
445            return Err(SoldbError::Message(format!(
446                "legacy source-map entry {instruction_index} for `{contract_name}` ends at byte \
447                 {end}, beyond source {source_id} length {source_len}"
448            )));
449        }
450    }
451
452    Ok(Some(json!({
453        "code": {
454            "source": {"id": source_id},
455            "range": {
456                "offset": offset,
457                "length": length,
458            },
459        },
460    })))
461}
462
463fn read_source(root: &Path, source_path: &str) -> SoldbResult<Option<String>> {
464    let source_path = Path::new(source_path);
465    let mut candidates = Vec::<PathBuf>::new();
466    if source_path.is_absolute() {
467        candidates.push(source_path.to_path_buf());
468    } else {
469        candidates.push(root.join(source_path));
470        if let Some(parent) = root.parent() {
471            candidates.push(parent.join(source_path));
472        }
473        candidates.push(source_path.to_path_buf());
474    }
475
476    for candidate in candidates {
477        if candidate.exists() {
478            return fs::read_to_string(&candidate).map(Some).map_err(|error| {
479                SoldbError::Message(format!(
480                    "failed to read source `{}`: {error}",
481                    candidate.display()
482                ))
483            });
484        }
485    }
486    Ok(None)
487}
488
489#[must_use]
490pub fn is_legacy_compiler(version: &str) -> bool {
491    if version.is_empty() {
492        return true;
493    }
494
495    let version_core = version.split('+').next().unwrap_or(version);
496    let parts = version_core.split('.').collect::<Vec<_>>();
497    if parts.len() < 3 {
498        return true;
499    }
500
501    let Ok(major) = parts[0].parse::<u64>() else {
502        return true;
503    };
504    let Ok(minor) = parts[1].parse::<u64>() else {
505        return true;
506    };
507    let Ok(patch) = parts[2].parse::<u64>() else {
508        return true;
509    };
510
511    major == 0 && (minor < 8 || (minor == 8 && patch < 29))
512}
513
514fn push_data_size(opcode: u8) -> Option<usize> {
515    if (0x60..=0x7f).contains(&opcode) {
516        Some(usize::from(opcode - 0x5f))
517    } else {
518        None
519    }
520}
521
522fn parse_inherited_i64(field: Option<&str>, previous: i64, label: &str) -> SoldbResult<i64> {
523    let Some(field) = field else {
524        return Ok(previous);
525    };
526
527    let trimmed = field.trim();
528    if trimmed.is_empty() {
529        return Ok(previous);
530    }
531
532    trimmed.parse::<i64>().map_err(|error| {
533        SoldbError::Message(format!("Invalid source map {label} '{trimmed}': {error}"))
534    })
535}
536
537fn parse_inherited_string(field: Option<&str>, previous: &str) -> String {
538    field
539        .filter(|value| !value.trim().is_empty())
540        .unwrap_or(previous)
541        .to_owned()
542}
543
544#[cfg(test)]
545mod tests {
546    use std::fs;
547    use std::path::PathBuf;
548    use std::time::{SystemTime, UNIX_EPOCH};
549
550    use serde_json::json;
551
552    use crate::Instruction;
553
554    use super::{
555        build_pc_to_instruction_map, is_legacy_compiler, load_source_map_program, parse_srcmap,
556        SourceMapEnvironment, SourceMapInfo,
557    };
558
559    fn temp_dir(label: &str) -> PathBuf {
560        let unique = SystemTime::now()
561            .duration_since(UNIX_EPOCH)
562            .expect("system time")
563            .as_nanos();
564        let dir = std::env::temp_dir().join(format!("soldb-source-map-{label}-{unique}"));
565        fs::create_dir_all(&dir).expect("create temp dir");
566        dir
567    }
568
569    #[test]
570    fn maps_program_counters_around_push_data() {
571        let bytecode = [0x60, 0xff, 0x61, 0x01, 0x02, 0x00];
572        let pc_map = build_pc_to_instruction_map(&bytecode);
573
574        assert_eq!(pc_map.get(&0), Some(&0));
575        assert_eq!(pc_map.get(&2), Some(&1));
576        assert_eq!(pc_map.get(&5), Some(&2));
577        assert!(!pc_map.contains_key(&1));
578        assert!(!pc_map.contains_key(&3));
579    }
580
581    #[test]
582    fn parses_source_maps_with_inherited_fields() {
583        let entries = parse_srcmap("0:10:0:-:0;:5::i:;20::1").expect("source map");
584
585        assert_eq!(entries[0].offset, 0);
586        assert_eq!(entries[1].offset, 0);
587        assert_eq!(entries[1].length, 5);
588        assert_eq!(entries[1].file_index, 0);
589        assert_eq!(entries[1].jump_type, "i");
590        assert_eq!(entries[2].offset, 20);
591        assert_eq!(entries[2].length, 5);
592        assert_eq!(entries[2].file_index, 1);
593    }
594
595    #[test]
596    fn rejects_invalid_source_map_numbers() {
597        let error = parse_srcmap("abc:10:0").expect_err("invalid number");
598        assert!(error.to_string().contains("offset"));
599    }
600
601    #[test]
602    fn reports_source_info_for_valid_pcs() {
603        let bytecode = vec![0x60, 0xff, 0x00];
604        let srcmap_entries = parse_srcmap("0:4:0;10:2:1").expect("source map");
605        let info = SourceMapInfo {
606            contract_name: "Counter".to_owned(),
607            sources: vec!["A.sol".to_owned(), "B.sol".to_owned()],
608            bytecode: bytecode.clone(),
609            srcmap_entries,
610            pc_to_instruction_index: build_pc_to_instruction_map(&bytecode),
611            compiler_version: Some("0.8.16".to_owned()),
612        };
613
614        assert_eq!(info.source_info(0), Some(("A.sol", 0, 4)));
615        assert_eq!(info.source_info(2), Some(("B.sol", 10, 2)));
616        assert_eq!(info.source_info(1), None);
617    }
618
619    #[test]
620    fn detects_legacy_compiler_versions() {
621        assert!(is_legacy_compiler(""));
622        assert!(is_legacy_compiler("0.8.16"));
623        assert!(is_legacy_compiler("0.8.28+commit.deadbeef"));
624        assert!(!is_legacy_compiler("0.8.29"));
625        assert!(!is_legacy_compiler("0.9.0"));
626    }
627
628    #[test]
629    fn loads_runtime_combined_json_as_debug_info() {
630        let dir = temp_dir("runtime");
631        let source = "contract Counter { function add() external {} }";
632        fs::write(dir.join("Counter.sol"), source).expect("write source");
633        fs::write(
634            dir.join("combined.json"),
635            json!({
636                "version": "0.8.36+commit.test",
637                "sourceList": ["Counter.sol"],
638                "contracts": {
639                    "Counter.sol:Counter": {
640                        "bin-runtime": "600100",
641                        "srcmap-runtime": "0:8:0:-:0;9:3:0"
642                    }
643                }
644            })
645            .to_string(),
646        )
647        .expect("write combined JSON");
648
649        let program = load_source_map_program(&dir, "Counter", SourceMapEnvironment::Runtime)
650            .expect("load source map")
651            .expect("runtime program");
652
653        assert_eq!(program.info.contract_name, "Counter");
654        assert_eq!(program.info.environment, "call");
655        assert_eq!(program.info.source_info(0), Some(("Counter.sol", 0, 8)));
656        assert_eq!(program.info.source_info(2), Some(("Counter.sol", 9, 3)));
657        assert_eq!(
658            program
659                .info
660                .instruction_at_pc(0)
661                .and_then(Instruction::mnemonic),
662            Some("PUSH1")
663        );
664        assert_eq!(
665            program
666                .info
667                .instruction_at_pc(2)
668                .and_then(Instruction::mnemonic),
669            Some("STOP")
670        );
671        assert_eq!(
672            program.source_contents.get(&0).map(String::as_str),
673            Some(source)
674        );
675        assert_eq!(
676            program.resources["compilation"]["compiler"]["version"],
677            "0.8.36+commit.test"
678        );
679    }
680
681    #[test]
682    fn loads_creation_map_and_infers_single_contract() {
683        let dir = temp_dir("creation");
684        fs::write(
685            dir.join("combined.json"),
686            json!({
687                "sourceList": ["Missing.sol"],
688                "contracts": {
689                    "Missing.sol:Created": {
690                        "bin": "00",
691                        "srcmap": "0:1:0"
692                    }
693                }
694            })
695            .to_string(),
696        )
697        .expect("write combined JSON");
698
699        let program = load_source_map_program(&dir, "", SourceMapEnvironment::Creation)
700            .expect("load source map")
701            .expect("creation program");
702
703        assert_eq!(program.info.contract_name, "Created");
704        assert_eq!(program.info.environment, "create");
705        assert!(program.source_contents.is_empty());
706    }
707
708    #[test]
709    fn requires_bytecode_for_program_counter_mapping() {
710        let dir = temp_dir("missing-bytecode");
711        fs::write(
712            dir.join("combined.json"),
713            json!({
714                "sourceList": ["Counter.sol"],
715                "contracts": {
716                    "Counter.sol:Counter": {"srcmap-runtime": "0:1:0"}
717                }
718            })
719            .to_string(),
720        )
721        .expect("write combined JSON");
722
723        let error = load_source_map_program(&dir, "Counter", SourceMapEnvironment::Runtime)
724            .expect_err("missing bytecode");
725        assert!(error.to_string().contains("`bin-runtime`"));
726        assert!(error.to_string().contains("bytecode output"));
727    }
728}