1use 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#[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#[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
149pub 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}