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 serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17use soldb_core::{SoldbError, SoldbResult};
18
19use crate::opcodes;
20use crate::storage_layout::StorageLayout;
21use crate::{EthdebugInfo, Instruction};
22
23/// The bytecode environment described by a legacy source map.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum SourceMapEnvironment {
26    Creation,
27    Runtime,
28}
29
30impl SourceMapEnvironment {
31    fn fields(self) -> (&'static str, &'static str, &'static str) {
32        match self {
33            Self::Creation => ("srcmap", "bin", "create"),
34            Self::Runtime => ("srcmap-runtime", "bin-runtime", "call"),
35        }
36    }
37}
38
39/// A legacy source-map program adapted to the common debug-info model.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SourceMapProgram {
42    pub info: EthdebugInfo,
43    pub resources: Value,
44    pub source_contents: BTreeMap<u64, String>,
45    /// The file each source was read from, which may be above the artifact directory.
46    pub source_files: BTreeMap<u64, PathBuf>,
47    /// The contract's `storage-layout` entry, when the artifact was compiled with it.
48    pub storage_layout: Option<StorageLayout>,
49    /// The contract's `transient-storage-layout` entry, when compiled with it.
50    pub transient_storage_layout: Option<StorageLayout>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct SourceMapEntry {
55    pub offset: i64,
56    pub length: i64,
57    pub file_index: i64,
58    pub jump_type: String,
59    pub modifier_depth: i64,
60}
61
62impl SourceMapEntry {
63    #[must_use]
64    pub fn is_valid(&self) -> bool {
65        self.file_index >= 0 && self.offset >= 0
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct SourceMapInfo {
71    pub contract_name: String,
72    pub sources: Vec<String>,
73    pub bytecode: Vec<u8>,
74    pub srcmap_entries: Vec<SourceMapEntry>,
75    pub pc_to_instruction_index: BTreeMap<usize, usize>,
76    pub compiler_version: Option<String>,
77}
78
79impl SourceMapInfo {
80    #[must_use]
81    pub fn source_entry_at_pc(&self, pc: usize) -> Option<&SourceMapEntry> {
82        let instruction_index = self.pc_to_instruction_index.get(&pc)?;
83        self.srcmap_entries.get(*instruction_index)
84    }
85
86    #[must_use]
87    pub fn source_info(&self, pc: usize) -> Option<(&str, i64, i64)> {
88        let entry = self.source_entry_at_pc(pc)?;
89        if !entry.is_valid() {
90            return None;
91        }
92
93        let source = self.sources.get(usize::try_from(entry.file_index).ok()?)?;
94        Some((source, entry.offset, entry.length))
95    }
96}
97
98pub fn build_pc_to_instruction_map(bytecode: &[u8]) -> BTreeMap<usize, usize> {
99    let mut pc_to_index = BTreeMap::new();
100    let mut pc = 0;
101    let mut instruction_index = 0;
102
103    while pc < bytecode.len() {
104        pc_to_index.insert(pc, instruction_index);
105        pc += 1 + opcodes::immediate_size(bytecode[pc]);
106        instruction_index += 1;
107    }
108
109    pc_to_index
110}
111
112pub fn parse_srcmap(srcmap: &str) -> SoldbResult<Vec<SourceMapEntry>> {
113    if srcmap.is_empty() {
114        return Ok(Vec::new());
115    }
116
117    let mut entries = Vec::new();
118    let mut previous = SourceMapEntry {
119        offset: 0,
120        length: 0,
121        file_index: -1,
122        jump_type: "-".to_owned(),
123        modifier_depth: 0,
124    };
125
126    for part in srcmap.split(';') {
127        if part.is_empty() {
128            entries.push(previous.clone());
129            continue;
130        }
131
132        let fields = part.split(':').collect::<Vec<_>>();
133        let entry = SourceMapEntry {
134            offset: parse_inherited_i64(fields.first().copied(), previous.offset, "offset")?,
135            length: parse_inherited_i64(fields.get(1).copied(), previous.length, "length")?,
136            file_index: parse_inherited_i64(
137                fields.get(2).copied(),
138                previous.file_index,
139                "file index",
140            )?,
141            jump_type: parse_inherited_string(fields.get(3).copied(), &previous.jump_type),
142            modifier_depth: parse_inherited_i64(
143                fields.get(4).copied(),
144                previous.modifier_depth,
145                "modifier depth",
146            )?,
147        };
148
149        previous = entry.clone();
150        entries.push(entry);
151    }
152
153    Ok(entries)
154}
155
156/// Loads one program from `combined.json` when the requested source map exists.
157///
158/// `Ok(None)` means the directory has no legacy artifact, or the selected contract
159/// does not contain that creation/runtime map. A present but malformed artifact is
160/// always an error so callers cannot silently lose source attribution.
161pub fn load_source_map_program(
162    root: &Path,
163    contract_name: &str,
164    environment: SourceMapEnvironment,
165) -> SoldbResult<Option<SourceMapProgram>> {
166    load_source_map_program_with_sources(root, &[], contract_name, environment)
167}
168
169/// The same, also looking for the sources it names in `source_roots`.
170pub fn load_source_map_program_with_sources(
171    root: &Path,
172    source_roots: &[PathBuf],
173    contract_name: &str,
174    environment: SourceMapEnvironment,
175) -> SoldbResult<Option<SourceMapProgram>> {
176    let path = root.join("combined.json");
177    if !path.exists() {
178        return Ok(None);
179    }
180
181    let input = fs::read_to_string(&path).map_err(|error| {
182        SoldbError::Message(format!("failed to read `{}`: {error}", path.display()))
183    })?;
184    let combined = serde_json::from_str::<Value>(&input).map_err(|error| {
185        SoldbError::Message(format!("invalid JSON in `{}`: {error}", path.display()))
186    })?;
187    source_map_program_from_combined(
188        root,
189        source_roots,
190        &path,
191        &combined,
192        contract_name,
193        environment,
194    )
195}
196
197fn source_map_program_from_combined(
198    root: &Path,
199    source_roots: &[PathBuf],
200    artifact_path: &Path,
201    combined: &Value,
202    contract_name: &str,
203    environment: SourceMapEnvironment,
204) -> SoldbResult<Option<SourceMapProgram>> {
205    let contracts = combined
206        .get("contracts")
207        .and_then(Value::as_object)
208        .ok_or_else(|| {
209            SoldbError::Message(format!(
210                "legacy artifact `{}` does not contain a contracts object",
211                artifact_path.display()
212            ))
213        })?;
214    let Some((contract_key, contract)) = find_contract(contracts, contract_name)? else {
215        return Ok(None);
216    };
217    let (source_map_field, bytecode_field, environment_name) = environment.fields();
218    let Some(source_map) = contract.get(source_map_field).and_then(Value::as_str) else {
219        return Ok(None);
220    };
221    let bytecode = contract
222        .get(bytecode_field)
223        .and_then(Value::as_str)
224        .ok_or_else(|| {
225            SoldbError::Message(format!(
226                "legacy artifact `{}` has `{source_map_field}` for `{contract_key}` but no \
227                 `{bytecode_field}`; recompile with bytecode output enabled",
228                artifact_path.display()
229            ))
230        })?;
231    let bytecode = decode_bytecode(bytecode, artifact_path, contract_key, bytecode_field)?;
232    let source_map = parse_srcmap(source_map)?;
233    let source_list = combined
234        .get("sourceList")
235        .and_then(Value::as_array)
236        .ok_or_else(|| {
237            SoldbError::Message(format!(
238                "legacy artifact `{}` does not contain a sourceList array",
239                artifact_path.display()
240            ))
241        })?;
242
243    let mut sources = BTreeMap::new();
244    let mut source_contents = BTreeMap::new();
245    let mut source_files = BTreeMap::new();
246    let mut compilation_sources = Vec::with_capacity(source_list.len());
247    for (source_id, source) in source_list.iter().enumerate() {
248        let source_path = source.as_str().ok_or_else(|| {
249            SoldbError::Message(format!(
250                "legacy artifact `{}` has a non-string sourceList entry at index {source_id}",
251                artifact_path.display()
252            ))
253        })?;
254        let source_id = u64::try_from(source_id)
255            .map_err(|_| SoldbError::Message("source index does not fit in `u64`".to_owned()))?;
256        sources.insert(source_id, source_path.to_owned());
257
258        let mut source = json!({
259            "id": source_id,
260            "path": source_path,
261            "language": "Solidity",
262        });
263        if let Some((file, contents)) = read_source(root, source_roots, source_path)? {
264            source_contents.insert(source_id, contents.clone());
265            source_files.insert(source_id, file);
266            source["contents"] = Value::String(contents);
267        }
268        compilation_sources.push(source);
269    }
270
271    let pc_to_instruction_index = build_pc_to_instruction_map(&bytecode);
272    if source_map.len() > pc_to_instruction_index.len() {
273        return Err(SoldbError::Message(format!(
274            "legacy source map for `{contract_key}` has {} entries but `{bytecode_field}` has \
275             only {} instructions",
276            source_map.len(),
277            pc_to_instruction_index.len()
278        )));
279    }
280
281    let mut instructions = Vec::with_capacity(source_map.len());
282    for (pc, instruction_index) in pc_to_instruction_index {
283        let Some(entry) = source_map.get(instruction_index) else {
284            break;
285        };
286        instructions.push(Instruction {
287            offset: u64::try_from(pc).map_err(|_| {
288                SoldbError::Message("program counter does not fit in `u64`".to_owned())
289            })?,
290            operation: opcode_operation(bytecode[pc]),
291            context: source_context(
292                entry,
293                sources.len(),
294                &source_contents,
295                instruction_index,
296                contract_key,
297            )?,
298        });
299    }
300
301    let compiler_version = combined
302        .get("version")
303        .and_then(Value::as_str)
304        .unwrap_or("unknown");
305    let compilation = json!({
306        "id": format!("legacy-{compiler_version}"),
307        "compiler": {
308            "name": "Solidity compiler",
309            "version": compiler_version,
310        },
311        "sources": compilation_sources,
312    });
313    let resources = json!({
314        "compilation": compilation,
315        "types": {},
316        "pointers": {},
317    });
318    let resolved_contract_name = contract_key
319        .rsplit_once(':')
320        .map_or(contract_key, |(_, name)| name);
321    let info = EthdebugInfo {
322        compilation,
323        contract_name: resolved_contract_name.to_owned(),
324        environment: environment_name.to_owned(),
325        instructions,
326        sources,
327        variable_locations: BTreeMap::new(),
328    };
329
330    let storage_layout = combined_layout(contract, "storage-layout", artifact_path, contract_key)?;
331    let transient_storage_layout = combined_layout(
332        contract,
333        "transient-storage-layout",
334        artifact_path,
335        contract_key,
336    )?;
337
338    Ok(Some(SourceMapProgram {
339        info,
340        resources,
341        source_contents,
342        source_files,
343        storage_layout,
344        transient_storage_layout,
345    }))
346}
347
348/// A contract's layout entry `key` in a `combined.json`, when it was compiled with it.
349fn combined_layout(
350    contract: &Value,
351    key: &str,
352    artifact_path: &Path,
353    contract_key: &str,
354) -> SoldbResult<Option<StorageLayout>> {
355    contract
356        .get(key)
357        .map(StorageLayout::parse)
358        .transpose()
359        .map_err(|error| {
360            SoldbError::Message(format!(
361                "legacy artifact `{}` has an invalid `{key}` for `{contract_key}`: {error}",
362                artifact_path.display()
363            ))
364        })
365}
366
367fn find_contract<'a>(
368    contracts: &'a serde_json::Map<String, Value>,
369    contract_name: &str,
370) -> SoldbResult<Option<(&'a str, &'a Value)>> {
371    if contract_name.is_empty() {
372        let mut contracts = contracts.iter();
373        let Some((name, contract)) = contracts.next() else {
374            return Ok(None);
375        };
376        if contracts.next().is_some() {
377            return Err(SoldbError::Message(
378                "legacy combined JSON contains multiple contracts; provide `contractName`"
379                    .to_owned(),
380            ));
381        }
382        return Ok(Some((name, contract)));
383    }
384    if let Some((name, contract)) = contracts.get_key_value(contract_name) {
385        return Ok(Some((name, contract)));
386    }
387
388    let suffix = format!(":{contract_name}");
389    let mut matches = contracts.iter().filter(|(name, _)| name.ends_with(&suffix));
390    let Some((name, contract)) = matches.next() else {
391        return Ok(None);
392    };
393    if matches.next().is_some() {
394        return Err(SoldbError::Message(format!(
395            "contract name `{contract_name}` is ambiguous in legacy combined JSON"
396        )));
397    }
398    Ok(Some((name, contract)))
399}
400
401fn decode_bytecode(
402    bytecode: &str,
403    artifact_path: &Path,
404    contract_name: &str,
405    bytecode_field: &str,
406) -> SoldbResult<Vec<u8>> {
407    let bytecode = bytecode.strip_prefix("0x").unwrap_or(bytecode);
408    let (byte_pairs, remainder) = bytecode.as_bytes().as_chunks::<2>();
409    if !remainder.is_empty() {
410        return Err(SoldbError::Message(format!(
411            "`{bytecode_field}` for `{contract_name}` in `{}` has an odd number of hex digits",
412            artifact_path.display()
413        )));
414    }
415
416    byte_pairs
417        .iter()
418        .map(|digits| {
419            let high = decode_hex_digit(digits[0]);
420            let low = decode_hex_digit(digits[1]);
421            match (high, low) {
422                (Some(high), Some(low)) => Ok(high << 4 | low),
423                _ => Err(SoldbError::Message(format!(
424                    "`{bytecode_field}` for `{contract_name}` in `{}` is not valid hex",
425                    artifact_path.display()
426                ))),
427            }
428        })
429        .collect()
430}
431
432fn decode_hex_digit(digit: u8) -> Option<u8> {
433    match digit {
434        b'0'..=b'9' => Some(digit - b'0'),
435        b'a'..=b'f' => Some(digit - b'a' + 10),
436        b'A'..=b'F' => Some(digit - b'A' + 10),
437        _ => None,
438    }
439}
440
441fn opcode_operation(opcode: u8) -> Value {
442    let mnemonic =
443        opcodes::mnemonic(opcode).map_or_else(|| format!("UNKNOWN(0x{opcode:02x})"), str::to_owned);
444    json!({"mnemonic": mnemonic})
445}
446
447/// The ETHDebug-shaped context of one legacy entry: its source range, and the jump
448/// markers ETHDebug defines (`invoke` for a jump into a function, `return` for a jump
449/// out of one) when the map's jump type carries them. Generated code has no range but
450/// may still jump.
451fn source_context(
452    entry: &SourceMapEntry,
453    source_count: usize,
454    source_contents: &BTreeMap<u64, String>,
455    instruction_index: usize,
456    contract_name: &str,
457) -> SoldbResult<Option<Value>> {
458    let mut context = serde_json::Map::new();
459    context.insert("modifierDepth".to_owned(), json!(entry.modifier_depth));
460    if let Some(code) = source_code(
461        entry,
462        source_count,
463        source_contents,
464        instruction_index,
465        contract_name,
466    )? {
467        context.insert("code".to_owned(), code);
468    }
469    match entry.jump_type.as_str() {
470        "i" => {
471            context.insert("invoke".to_owned(), json!({}));
472        }
473        "o" => {
474            context.insert("return".to_owned(), json!({}));
475        }
476        _ => {}
477    }
478    Ok(Some(Value::Object(context)))
479}
480
481/// The `code` part of an entry's context: the source range it maps to, if any.
482fn source_code(
483    entry: &SourceMapEntry,
484    source_count: usize,
485    source_contents: &BTreeMap<u64, String>,
486    instruction_index: usize,
487    contract_name: &str,
488) -> SoldbResult<Option<Value>> {
489    if entry.file_index < 0 {
490        return Ok(None);
491    }
492    if entry.offset < 0 || entry.length < 0 {
493        return Err(SoldbError::Message(format!(
494            "legacy source-map entry {instruction_index} for `{contract_name}` has a negative \
495             source range"
496        )));
497    }
498    let source_id = u64::try_from(entry.file_index).map_err(|_| {
499        SoldbError::Message(format!(
500            "legacy source-map entry {instruction_index} for `{contract_name}` has an invalid \
501             source index"
502        ))
503    })?;
504    // solc attributes the Yul it generates (ABI decoding, checked arithmetic, panics) to a
505    // utility source whose index is one past the last entry in `sourceList`, and that
506    // source is never in the artifact. Such an instruction has no user source, the same
507    // as an entry with index -1; treating it as an error would reject every map a modern
508    // legacy-format compiler emits.
509    if usize::try_from(source_id).map_or(true, |source_id| source_id >= source_count) {
510        return Ok(None);
511    }
512    let offset = u64::try_from(entry.offset).map_err(|_| {
513        SoldbError::Message(format!(
514            "legacy source-map entry {instruction_index} for `{contract_name}` has an invalid \
515             offset"
516        ))
517    })?;
518    let length = u64::try_from(entry.length).map_err(|_| {
519        SoldbError::Message(format!(
520            "legacy source-map entry {instruction_index} for `{contract_name}` has an invalid \
521             length"
522        ))
523    })?;
524    let end = offset.checked_add(length).ok_or_else(|| {
525        SoldbError::Message(format!(
526            "legacy source-map entry {instruction_index} for `{contract_name}` overflows its \
527             source range"
528        ))
529    })?;
530    if let Some(source) = source_contents.get(&source_id) {
531        let source_len = u64::try_from(source.len())
532            .map_err(|_| SoldbError::Message("source length does not fit in `u64`".to_owned()))?;
533        if end > source_len {
534            return Err(SoldbError::Message(format!(
535                "legacy source-map entry {instruction_index} for `{contract_name}` ends at byte \
536                 {end}, beyond source {source_id} length {source_len}"
537            )));
538        }
539    }
540
541    Ok(Some(json!({
542        "source": {"id": source_id},
543        "range": {
544            "offset": offset,
545            "length": length,
546        },
547    })))
548}
549
550fn read_source(
551    root: &Path,
552    source_roots: &[PathBuf],
553    source_path: &str,
554) -> SoldbResult<Option<(PathBuf, String)>> {
555    let candidates = source_roots
556        .iter()
557        .flat_map(|extra| crate::artifacts::source_candidates(extra, source_path))
558        .chain(crate::artifacts::source_candidates(root, source_path));
559    for candidate in candidates {
560        if !candidate.exists() {
561            continue;
562        }
563        let contents = fs::read_to_string(&candidate).map_err(|error| {
564            SoldbError::Message(format!(
565                "failed to read source `{}`: {error}",
566                candidate.display()
567            ))
568        })?;
569        return Ok(Some((candidate, contents)));
570    }
571    Ok(None)
572}
573
574#[must_use]
575pub fn is_legacy_compiler(version: &str) -> bool {
576    if version.is_empty() {
577        return true;
578    }
579
580    let version_core = version.split('+').next().unwrap_or(version);
581    let parts = version_core.split('.').collect::<Vec<_>>();
582    if parts.len() < 3 {
583        return true;
584    }
585
586    let Ok(major) = parts[0].parse::<u64>() else {
587        return true;
588    };
589    let Ok(minor) = parts[1].parse::<u64>() else {
590        return true;
591    };
592    let Ok(patch) = parts[2].parse::<u64>() else {
593        return true;
594    };
595
596    major == 0 && (minor < 8 || (minor == 8 && patch < 29))
597}
598
599fn parse_inherited_i64(field: Option<&str>, previous: i64, label: &str) -> SoldbResult<i64> {
600    let Some(field) = field else {
601        return Ok(previous);
602    };
603
604    let trimmed = field.trim();
605    if trimmed.is_empty() {
606        return Ok(previous);
607    }
608
609    trimmed.parse::<i64>().map_err(|error| {
610        SoldbError::Message(format!("Invalid source map {label} '{trimmed}': {error}"))
611    })
612}
613
614fn parse_inherited_string(field: Option<&str>, previous: &str) -> String {
615    field
616        .filter(|value| !value.trim().is_empty())
617        .unwrap_or(previous)
618        .to_owned()
619}
620
621#[cfg(test)]
622mod tests {
623    use crate::metadata::FunctionExit;
624    use std::fs;
625    use std::path::PathBuf;
626    use std::time::{SystemTime, UNIX_EPOCH};
627
628    use serde_json::json;
629
630    use crate::Instruction;
631
632    use super::{
633        build_pc_to_instruction_map, is_legacy_compiler, load_source_map_program, opcode_operation,
634        parse_srcmap, SourceMapEnvironment, SourceMapInfo,
635    };
636
637    fn temp_dir(label: &str) -> PathBuf {
638        let unique = SystemTime::now()
639            .duration_since(UNIX_EPOCH)
640            .expect("system time")
641            .as_nanos();
642        let dir = std::env::temp_dir().join(format!("soldb-source-map-{label}-{unique}"));
643        fs::create_dir_all(&dir).expect("create temp dir");
644        dir
645    }
646
647    #[test]
648    fn names_instructions_and_marks_invalid_bytes() {
649        // The names are what a trace records for the same steps, so a `JUMPDEST` in an
650        // artifact is a `JUMPDEST` in a trace; a byte that is no instruction says so.
651        assert_eq!(opcode_operation(0x5b)["mnemonic"], "JUMPDEST");
652        assert_eq!(opcode_operation(0x60)["mnemonic"], "PUSH1");
653        assert_eq!(opcode_operation(0xf1)["mnemonic"], "CALL");
654        assert_eq!(opcode_operation(0x0c)["mnemonic"], "UNKNOWN(0x0c)");
655    }
656
657    #[test]
658    fn maps_program_counters_around_push_data() {
659        let bytecode = [0x60, 0xff, 0x61, 0x01, 0x02, 0x00];
660        let pc_map = build_pc_to_instruction_map(&bytecode);
661
662        assert_eq!(pc_map.get(&0), Some(&0));
663        assert_eq!(pc_map.get(&2), Some(&1));
664        assert_eq!(pc_map.get(&5), Some(&2));
665        assert!(!pc_map.contains_key(&1));
666        assert!(!pc_map.contains_key(&3));
667    }
668
669    #[test]
670    fn parses_source_maps_with_inherited_fields() {
671        let entries = parse_srcmap("0:10:0:-:0;:5::i:;20::1").expect("source map");
672
673        assert_eq!(entries[0].offset, 0);
674        assert_eq!(entries[1].offset, 0);
675        assert_eq!(entries[1].length, 5);
676        assert_eq!(entries[1].file_index, 0);
677        assert_eq!(entries[1].jump_type, "i");
678        assert_eq!(entries[2].offset, 20);
679        assert_eq!(entries[2].length, 5);
680        assert_eq!(entries[2].file_index, 1);
681    }
682
683    #[test]
684    fn rejects_invalid_source_map_numbers() {
685        let error = parse_srcmap("abc:10:0").expect_err("invalid number");
686        assert!(error.to_string().contains("offset"));
687    }
688
689    #[test]
690    fn reports_source_info_for_valid_pcs() {
691        let bytecode = vec![0x60, 0xff, 0x00];
692        let srcmap_entries = parse_srcmap("0:4:0;10:2:1").expect("source map");
693        let info = SourceMapInfo {
694            contract_name: "Counter".to_owned(),
695            sources: vec!["A.sol".to_owned(), "B.sol".to_owned()],
696            bytecode: bytecode.clone(),
697            srcmap_entries,
698            pc_to_instruction_index: build_pc_to_instruction_map(&bytecode),
699            compiler_version: Some("0.8.16".to_owned()),
700        };
701
702        assert_eq!(info.source_info(0), Some(("A.sol", 0, 4)));
703        assert_eq!(info.source_info(2), Some(("B.sol", 10, 2)));
704        assert_eq!(info.source_info(1), None);
705    }
706
707    #[test]
708    fn detects_legacy_compiler_versions() {
709        assert!(is_legacy_compiler(""));
710        assert!(is_legacy_compiler("0.8.16"));
711        assert!(is_legacy_compiler("0.8.28+commit.deadbeef"));
712        assert!(!is_legacy_compiler("0.8.29"));
713        assert!(!is_legacy_compiler("0.9.0"));
714    }
715
716    #[test]
717    fn loads_runtime_combined_json_as_debug_info() {
718        let dir = temp_dir("runtime");
719        let source = "contract Counter { function add() external {} }";
720        fs::write(dir.join("Counter.sol"), source).expect("write source");
721        fs::write(
722            dir.join("combined.json"),
723            json!({
724                "version": "0.8.36+commit.test",
725                "sourceList": ["Counter.sol"],
726                "contracts": {
727                    "Counter.sol:Counter": {
728                        "bin-runtime": "600100",
729                        "srcmap-runtime": "0:8:0:-:0;9:3:0",
730                        "storage-layout": {
731                            "storage": [{
732                                "astId": 1,
733                                "contract": "Counter.sol:Counter",
734                                "label": "value",
735                                "offset": 0,
736                                "slot": "0",
737                                "type": "t_uint256"
738                            }],
739                            "types": {
740                                "t_uint256": {
741                                    "encoding": "inplace",
742                                    "label": "uint256",
743                                    "numberOfBytes": "32"
744                                }
745                            }
746                        },
747                        "transient-storage-layout": {
748                            "storage": [{
749                                "astId": 2,
750                                "contract": "Counter.sol:Counter",
751                                "label": "locked",
752                                "offset": 0,
753                                "slot": "0",
754                                "type": "t_bool"
755                            }],
756                            "types": {
757                                "t_bool": {
758                                    "encoding": "inplace",
759                                    "label": "bool",
760                                    "numberOfBytes": "1"
761                                }
762                            }
763                        }
764                    }
765                }
766            })
767            .to_string(),
768        )
769        .expect("write combined JSON");
770
771        let program = load_source_map_program(&dir, "Counter", SourceMapEnvironment::Runtime)
772            .expect("load source map")
773            .expect("runtime program");
774
775        // `solc --combined-json ...,storage-layout` carries the layout per contract, so a
776        // pre-ETHDebug compiler reads state variables by name just as a modern one does.
777        let layout = program.storage_layout.as_ref().expect("storage layout");
778        assert_eq!(layout.variables.len(), 1);
779        assert_eq!(layout.variable("value").expect("value").slot, [0_u8; 32]);
780        assert_eq!(
781            layout.resolve("value").expect("resolve").type_id,
782            "t_uint256"
783        );
784        // `transient-storage-layout` is the same shape, for the `transient` variables.
785        let transient = program
786            .transient_storage_layout
787            .as_ref()
788            .expect("transient layout");
789        assert!(transient.variable("locked").is_some());
790        assert!(transient.variable("value").is_none());
791
792        assert_eq!(program.info.contract_name, "Counter");
793        assert_eq!(program.info.environment, "call");
794        assert_eq!(program.info.source_info(0), Some(("Counter.sol", 0, 8)));
795        assert_eq!(program.info.source_info(2), Some(("Counter.sol", 9, 3)));
796        assert_eq!(
797            program
798                .info
799                .instruction_at_pc(0)
800                .and_then(Instruction::mnemonic),
801            Some("PUSH1")
802        );
803        assert_eq!(
804            program
805                .info
806                .instruction_at_pc(2)
807                .and_then(Instruction::mnemonic),
808            Some("STOP")
809        );
810        assert_eq!(
811            program.source_contents.get(&0).map(String::as_str),
812            Some(source)
813        );
814        assert_eq!(
815            program.resources["compilation"]["compiler"]["version"],
816            "0.8.36+commit.test"
817        );
818    }
819
820    #[test]
821    fn jump_types_become_call_and_return_markers() {
822        let dir = temp_dir("jump-types");
823        let source = "contract Counter { function add() external {} }";
824        fs::write(dir.join("Counter.sol"), source).expect("write source");
825        fs::write(
826            dir.join("combined.json"),
827            json!({
828                "version": "0.8.16+commit.test",
829                "sourceList": ["Counter.sol"],
830                "contracts": {
831                    "Counter.sol:Counter": {
832                        "bin-runtime": "6001600100",
833                        "srcmap-runtime": "0:8:0:i;9:3:0:o;0:0:-1:-"
834                    }
835                }
836            })
837            .to_string(),
838        )
839        .expect("write combined JSON");
840
841        let program = load_source_map_program(&dir, "Counter", SourceMapEnvironment::Runtime)
842            .expect("load source map")
843            .expect("runtime program");
844        let at = |pc: u64| program.info.instruction_at_pc(pc).expect("instruction");
845        assert_eq!(at(0).function_invocations().len(), 1);
846        assert_eq!(at(0).modifier_depth(), Some(0));
847        assert_eq!(at(0).function_exit(), None);
848        assert!(at(2).function_invocations().is_empty());
849        assert_eq!(at(2).function_exit(), Some(FunctionExit::Return));
850        // Generated code without a range still retains its modifier depth.
851        assert!(at(4).source_location().is_none());
852        assert!(at(4).function_invocations().is_empty());
853    }
854
855    #[test]
856    fn generated_code_past_the_source_list_has_no_source() {
857        // solc 0.8.x maps its generated Yul to a source index one past `sourceList`;
858        // those instructions are unmapped rather than a reason to reject the map.
859        let dir = temp_dir("utility-source");
860        let source = "contract Counter { function add() external {} }";
861        fs::write(dir.join("Counter.sol"), source).expect("write source");
862        fs::write(
863            dir.join("combined.json"),
864            json!({
865                "version": "0.8.16+commit.test",
866                "sourceList": ["Counter.sol"],
867                "contracts": {
868                    "Counter.sol:Counter": {
869                        "bin-runtime": "600100",
870                        "srcmap-runtime": "0:8:0:-:0;0:5:1"
871                    }
872                }
873            })
874            .to_string(),
875        )
876        .expect("write combined JSON");
877
878        let program = load_source_map_program(&dir, "Counter", SourceMapEnvironment::Runtime)
879            .expect("load source map")
880            .expect("runtime program");
881        assert_eq!(program.info.source_info(0), Some(("Counter.sol", 0, 8)));
882        assert_eq!(program.info.source_info(2), None);
883        assert!(program.info.instruction_at_pc(2).is_some());
884    }
885
886    #[test]
887    fn loads_creation_map_and_infers_single_contract() {
888        let dir = temp_dir("creation");
889        fs::write(
890            dir.join("combined.json"),
891            json!({
892                "sourceList": ["Missing.sol"],
893                "contracts": {
894                    "Missing.sol:Created": {
895                        "bin": "00",
896                        "srcmap": "0:1:0"
897                    }
898                }
899            })
900            .to_string(),
901        )
902        .expect("write combined JSON");
903
904        let program = load_source_map_program(&dir, "", SourceMapEnvironment::Creation)
905            .expect("load source map")
906            .expect("creation program");
907
908        assert_eq!(program.info.contract_name, "Created");
909        assert_eq!(program.info.environment, "create");
910        assert!(program.source_contents.is_empty());
911    }
912
913    #[test]
914    fn requires_bytecode_for_program_counter_mapping() {
915        let dir = temp_dir("missing-bytecode");
916        fs::write(
917            dir.join("combined.json"),
918            json!({
919                "sourceList": ["Counter.sol"],
920                "contracts": {
921                    "Counter.sol:Counter": {"srcmap-runtime": "0:1:0"}
922                }
923            })
924            .to_string(),
925        )
926        .expect("write combined JSON");
927
928        let error = load_source_map_program(&dir, "Counter", SourceMapEnvironment::Runtime)
929            .expect_err("missing bytecode");
930        assert!(error.to_string().contains("`bin-runtime`"));
931        assert!(error.to_string().contains("bytecode output"));
932    }
933}