1use std::collections::BTreeMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Value};
18
19use soldb_core::{SoldbError, SoldbResult};
20
21use crate::metadata::{read_compilation_source, EthdebugInfo};
22use crate::source_map::{load_source_map_program_with_sources, SourceMapEnvironment};
23use crate::storage_layout::StorageLayout;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum CodeGenerator {
34 Legacy,
35 ViaIr,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct DebugProgram {
41 pub info: EthdebugInfo,
42 pub resources: Value,
44 pub source_contents: BTreeMap<u64, String>,
46 pub source_files: BTreeMap<u64, PathBuf>,
48 pub legacy: bool,
50 pub storage_layout: Option<StorageLayout>,
52 pub transient_storage_layout: Option<StorageLayout>,
54 pub missing_sources: Vec<String>,
57}
58
59impl DebugProgram {
60 #[must_use]
64 pub const fn code_generator(&self) -> CodeGenerator {
65 if self.legacy {
66 CodeGenerator::Legacy
67 } else {
68 CodeGenerator::ViaIr
69 }
70 }
71
72 fn read_sources(&mut self, root: &Path, source_roots: &[PathBuf]) {
75 for (source_id, source_path) in &self.info.sources {
76 if self.source_contents.contains_key(source_id) {
77 continue;
78 }
79 if let Some(source) = read_compilation_source(&self.info.compilation, *source_id) {
80 self.source_contents.insert(*source_id, source);
81 continue;
82 }
83 match read_debug_source_file(root, source_roots, source_path) {
84 Some((file, source)) => {
85 self.source_contents.insert(*source_id, source);
86 self.source_files.insert(*source_id, file);
87 }
88 None => self.missing_sources.push(source_path.clone()),
89 }
90 }
91 }
92}
93
94#[must_use]
96pub fn find_ethdebug_metadata(root: &Path) -> Option<PathBuf> {
97 [
100 root.join("ethdebug.json"),
101 root.join("ethdebug_resources.json"),
102 ]
103 .into_iter()
104 .find(|path| path.exists())
105}
106
107#[must_use]
113pub fn find_program_ethdebug(
114 root: &Path,
115 contract_name: &str,
116 environment: SourceMapEnvironment,
117) -> Option<PathBuf> {
118 let suffix = program_suffix(environment);
119 let named = root.join(format!("{contract_name}{suffix}"));
120 if !contract_name.is_empty() && named.exists() {
121 return Some(named);
122 }
123
124 let mut candidates = fs::read_dir(root)
125 .ok()?
126 .filter_map(Result::ok)
127 .map(|entry| entry.path())
128 .filter(|path| {
129 path.file_name()
130 .and_then(|name| name.to_str())
131 .is_some_and(|name| name.ends_with(suffix))
132 })
133 .collect::<Vec<_>>();
134 candidates.sort();
135
136 if !contract_name.is_empty() {
137 let contract_suffix = format!("_{contract_name}{suffix}");
138 let mut matching = candidates.iter().filter(|path| {
139 path.file_name()
140 .and_then(|name| name.to_str())
141 .is_some_and(|name| name.ends_with(&contract_suffix))
142 });
143 let matched = matching.next().cloned();
144 if matched.is_some() && matching.next().is_none() {
145 return matched;
146 }
147 }
148
149 (candidates.len() == 1).then(|| candidates.remove(0))
150}
151
152fn program_suffix(environment: SourceMapEnvironment) -> &'static str {
153 match environment {
154 SourceMapEnvironment::Creation => "_ethdebug.json",
155 SourceMapEnvironment::Runtime => "_ethdebug-runtime.json",
156 }
157}
158
159#[must_use]
161pub fn contract_name_from_program_path(
162 path: &Path,
163 environment: SourceMapEnvironment,
164) -> Option<String> {
165 path.file_name()
166 .and_then(|name| name.to_str())
167 .and_then(|name| name.strip_suffix(program_suffix(environment)))
168 .filter(|name| !name.is_empty())
169 .map(str::to_owned)
170}
171
172#[must_use]
175pub fn read_debug_source(root: &Path, source_path: &str) -> Option<String> {
176 let (_, contents) = read_debug_source_file(root, &[], source_path)?;
177 Some(contents)
178}
179
180fn read_debug_source_file(
183 root: &Path,
184 extra_roots: &[PathBuf],
185 source_path: &str,
186) -> Option<(PathBuf, String)> {
187 let candidates = extra_roots
188 .iter()
189 .flat_map(|extra| source_candidates(extra, source_path))
190 .chain(source_candidates(root, source_path));
191 for candidate in candidates {
192 if let Ok(contents) = fs::read_to_string(&candidate) {
194 return Some((candidate, contents));
195 }
196 }
197 None
198}
199
200pub fn source_candidates(root: &Path, source_path: &str) -> Vec<PathBuf> {
207 let source = Path::new(source_path);
208 if source.is_absolute() {
209 return vec![source.to_path_buf()];
210 }
211 let mut candidates = Vec::new();
212 let mut directory = Some(root);
213 for _ in 0..4 {
215 let Some(current) = directory else {
216 break;
217 };
218 candidates.push(current.join(source));
219 directory = current.parent();
220 }
221 candidates.push(source.to_path_buf());
222 candidates
223}
224
225pub fn read_json_file(path: &Path) -> SoldbResult<Value> {
227 let content = fs::read_to_string(path).map_err(|error| {
228 SoldbError::Message(format!("failed to read `{}`: {error}", path.display()))
229 })?;
230 serde_json::from_str(&content).map_err(|error| {
231 SoldbError::Message(format!("invalid JSON in `{}`: {error}", path.display()))
232 })
233}
234
235pub fn ethdebug_resources_from_metadata(path: &Path, metadata: &Value) -> SoldbResult<Value> {
238 if let Some(resources) = metadata.get("resources") {
239 return Ok(resources.clone());
240 }
241
242 let Some(compilation) = metadata.get("compilation") else {
243 return Err(SoldbError::Message(format!(
244 "ETHDebug metadata {} does not contain resources or compilation",
245 path.display()
246 )));
247 };
248
249 Ok(json!({
250 "compilation": compilation,
251 "types": metadata.get("types").cloned().unwrap_or_else(|| json!({})),
252 "pointers": metadata.get("pointers").cloned().unwrap_or_else(|| json!({})),
253 }))
254}
255
256pub fn load_debug_program(
264 root: &Path,
265 contract_name: &str,
266 environment: SourceMapEnvironment,
267) -> SoldbResult<Option<DebugProgram>> {
268 load_debug_program_with_sources(root, contract_name, environment, &[])
269}
270
271pub fn load_debug_program_with_sources(
277 root: &Path,
278 contract_name: &str,
279 environment: SourceMapEnvironment,
280 source_roots: &[PathBuf],
281) -> SoldbResult<Option<DebugProgram>> {
282 let environment_name = match environment {
283 SourceMapEnvironment::Creation => "create",
284 SourceMapEnvironment::Runtime => "call",
285 };
286 if let Some(program_path) = find_program_ethdebug(root, contract_name, environment) {
287 let metadata_path = find_ethdebug_metadata(root).ok_or_else(|| {
288 SoldbError::Message(format!(
289 "No ETHDebug metadata file (ethdebug.json or ethdebug_resources.json) found in {}",
290 root.display()
291 ))
292 })?;
293 let metadata = read_json_file(&metadata_path)?;
294 let program = read_json_file(&program_path)?;
295 let resources = ethdebug_resources_from_metadata(&metadata_path, &metadata)?;
296 let name = if contract_name.is_empty() {
297 contract_name_from_program_path(&program_path, environment)
298 .unwrap_or_else(|| "Contract".to_owned())
299 } else {
300 contract_name.to_owned()
301 };
302 let info = EthdebugInfo::from_artifacts(&name, environment_name, &metadata, &program)
303 .map_err(|error| SoldbError::Message(format!("{}: {error}", program_path.display())))?;
304 let storage_layout = load_storage_layout(root, &info.contract_name)?;
305 let transient_storage_layout = load_transient_storage_layout(root, &info.contract_name)?;
306 let mut program = DebugProgram {
307 info,
308 resources,
309 source_contents: BTreeMap::new(),
310 source_files: BTreeMap::new(),
311 legacy: false,
312 storage_layout,
313 transient_storage_layout,
314 missing_sources: Vec::new(),
315 };
316 program.read_sources(root, source_roots);
317 return Ok(Some(program));
318 }
319
320 let Some(program) =
321 load_source_map_program_with_sources(root, source_roots, contract_name, environment)?
322 else {
323 return Ok(None);
324 };
325 let storage_layout = match program.storage_layout {
326 Some(layout) => Some(layout),
327 None => load_storage_layout(root, &program.info.contract_name)?,
328 };
329 let transient_storage_layout = match program.transient_storage_layout {
330 Some(layout) => Some(layout),
331 None => load_transient_storage_layout(root, &program.info.contract_name)?,
332 };
333 let mut program = DebugProgram {
334 info: program.info,
335 resources: program.resources,
336 source_contents: program.source_contents,
337 source_files: program.source_files,
338 legacy: true,
339 storage_layout,
340 transient_storage_layout,
341 missing_sources: Vec::new(),
342 };
343 program.read_sources(root, source_roots);
344 Ok(Some(program))
345}
346
347pub fn load_storage_layout(root: &Path, contract_name: &str) -> SoldbResult<Option<StorageLayout>> {
352 load_layout_file(&root.join(format!("{contract_name}_storage.json")))
353}
354
355pub fn load_transient_storage_layout(
357 root: &Path,
358 contract_name: &str,
359) -> SoldbResult<Option<StorageLayout>> {
360 load_layout_file(&root.join(format!("{contract_name}_transient_storage.json")))
361}
362
363fn load_layout_file(path: &Path) -> SoldbResult<Option<StorageLayout>> {
364 if !path.exists() {
365 return Ok(None);
366 }
367 let value = read_json_file(path)?;
368 StorageLayout::parse(&value)
369 .map(Some)
370 .map_err(|error| SoldbError::Message(format!("{}: {error}", path.display())))
371}
372
373#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct ContractMapping {
377 pub address: String,
378 pub name: String,
379 pub debug_dir: PathBuf,
380 pub code_hash: Option<String>,
383}
384
385pub fn load_contract_mapping(path: &Path) -> SoldbResult<Vec<ContractMapping>> {
388 let value = read_json_file(path)?;
389 parse_contract_mapping(path, &value)
390}
391
392pub fn parse_contract_mapping(path: &Path, value: &Value) -> SoldbResult<Vec<ContractMapping>> {
394 let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
395 let contracts = value
396 .get("contracts")
397 .and_then(Value::as_array)
398 .ok_or_else(|| {
399 SoldbError::Message(format!(
400 "contract mapping `{}` must contain a `contracts` array",
401 path.display()
402 ))
403 })?;
404
405 Ok(contracts
406 .iter()
407 .filter_map(|contract| {
408 let address = contract.get("address")?.as_str()?.to_owned();
409 let name = contract
410 .get("name")
411 .and_then(Value::as_str)
412 .unwrap_or("Unknown")
413 .to_owned();
414 let debug_dir = contract
415 .get("debug_dir")
416 .and_then(Value::as_str)
417 .map(PathBuf::from)?;
418 let debug_dir = if debug_dir.is_absolute() {
419 debug_dir
420 } else {
421 base_dir.join(debug_dir)
422 };
423 let code_hash = contract
424 .get("code_hash")
425 .and_then(Value::as_str)
426 .map(str::to_owned);
427 Some(ContractMapping {
428 address,
429 name,
430 debug_dir,
431 code_hash,
432 })
433 })
434 .collect())
435}
436
437#[cfg(test)]
438mod tests {
439 use std::fs;
440 use std::path::PathBuf;
441 use std::time::{SystemTime, UNIX_EPOCH};
442
443 use serde_json::json;
444
445 use super::{
446 contract_name_from_program_path, ethdebug_resources_from_metadata, find_ethdebug_metadata,
447 find_program_ethdebug, load_debug_program, load_storage_layout,
448 load_transient_storage_layout, read_debug_source, read_json_file,
449 };
450 use crate::source_map::SourceMapEnvironment;
451
452 fn temp_dir(label: &str) -> PathBuf {
453 let nanos = SystemTime::now()
454 .duration_since(UNIX_EPOCH)
455 .expect("clock")
456 .as_nanos();
457 let dir = std::env::temp_dir().join(format!("soldb-artifacts-{label}-{nanos}"));
458 fs::create_dir_all(&dir).expect("create temp dir");
459 dir
460 }
461
462 #[test]
463 fn loads_ethdebug_programs_with_their_sources() {
464 let dir = temp_dir("ethdebug");
465 let source =
466 "contract Counter {\n function set(uint256 x) public {\n value = x;\n }\n}\n";
467 fs::write(dir.join("Counter.sol"), source).expect("source");
468 fs::write(
469 dir.join("ethdebug_resources.json"),
470 json!({"compilation": {"sources": [{"id": 0, "path": "Counter.sol"}]}}).to_string(),
471 )
472 .expect("metadata");
473 fs::write(
474 dir.join("Counter_ethdebug-runtime.json"),
475 json!({"instructions": [{
476 "offset": 7,
477 "operation": {"mnemonic": "STOP"},
478 "context": {"code": {"source": {"id": 0}, "range": {"offset": 59, "length": 9}}}
479 }]})
480 .to_string(),
481 )
482 .expect("program");
483
484 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
485 .expect("load")
486 .expect("present");
487 assert!(!program.legacy);
488 assert_eq!(program.info.contract_name, "Counter");
489 assert_eq!(program.info.environment, "call");
490 assert!(program.info.instruction_at_pc(7).is_some());
491 assert_eq!(
492 program.source_contents.get(&0).map(String::as_str),
493 Some(source)
494 );
495 assert_eq!(program.source_files.get(&0), Some(&dir.join("Counter.sol")));
496 assert!(program.resources.get("compilation").is_some());
497 assert!(program.storage_layout.is_none());
499
500 let inferred = load_debug_program(&dir, "", SourceMapEnvironment::Runtime)
502 .expect("load")
503 .expect("present");
504 assert_eq!(inferred.info.contract_name, "Counter");
505 assert!(
507 load_debug_program(&dir, "Counter", SourceMapEnvironment::Creation)
508 .expect("load")
509 .is_none()
510 );
511 assert_eq!(
512 contract_name_from_program_path(
513 &dir.join("Counter_ethdebug-runtime.json"),
514 SourceMapEnvironment::Runtime
515 )
516 .as_deref(),
517 Some("Counter")
518 );
519 assert_eq!(
520 find_ethdebug_metadata(&dir),
521 Some(dir.join("ethdebug_resources.json"))
522 );
523 assert_eq!(
524 find_program_ethdebug(&dir, "Other", SourceMapEnvironment::Runtime),
525 Some(dir.join("Counter_ethdebug-runtime.json")),
526 "a single artifact serves any name"
527 );
528 assert_eq!(
529 read_debug_source(&dir, "Counter.sol").as_deref(),
530 Some(source)
531 );
532 assert!(read_debug_source(&dir, "Missing.sol").is_none());
533
534 fs::write(
537 dir.join("Counter_storage.json"),
538 json!({
539 "storage": [{
540 "astId": 1,
541 "contract": "Counter.sol:Counter",
542 "label": "value",
543 "offset": 0,
544 "slot": "1",
545 "type": "t_uint256"
546 }],
547 "types": {"t_uint256": {
548 "encoding": "inplace",
549 "label": "uint256",
550 "numberOfBytes": "32"
551 }}
552 })
553 .to_string(),
554 )
555 .expect("layout");
556 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
557 .expect("load")
558 .expect("present");
559 let layout = program.storage_layout.as_ref().expect("storage layout");
560 assert_eq!(layout.variable("value").expect("value").slot[31], 1);
561 assert!(load_storage_layout(&dir, "Missing")
562 .expect("absent")
563 .is_none());
564 assert!(program.transient_storage_layout.is_none());
566
567 fs::write(
570 dir.join("Counter_transient_storage.json"),
571 json!({
572 "storage": [{
573 "astId": 2,
574 "contract": "Counter.sol:Counter",
575 "label": "locked",
576 "offset": 0,
577 "slot": "0",
578 "type": "t_bool"
579 }],
580 "types": {"t_bool": {
581 "encoding": "inplace",
582 "label": "bool",
583 "numberOfBytes": "1"
584 }}
585 })
586 .to_string(),
587 )
588 .expect("transient layout");
589 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
590 .expect("load")
591 .expect("present");
592 let transient = program
593 .transient_storage_layout
594 .as_ref()
595 .expect("transient layout");
596 assert_eq!(transient.variable("locked").expect("locked").slot, [0; 32]);
597 assert!(transient.variable("value").is_none());
598 assert!(load_transient_storage_layout(&dir, "Missing")
599 .expect("absent")
600 .is_none());
601 fs::write(dir.join("Counter_storage.json"), "{}").expect("layout");
602 let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
603 .expect_err("a storage layout that cannot be read is reported");
604 assert!(
605 error.to_string().contains("Counter_storage.json"),
606 "{error}"
607 );
608 }
609
610 #[test]
611 fn a_program_without_metadata_is_an_error_not_a_silent_miss() {
612 let dir = temp_dir("no-metadata");
613 fs::write(dir.join("Counter_ethdebug-runtime.json"), "{}").expect("program");
614 let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
615 .expect_err("metadata is required");
616 assert!(
617 error.to_string().contains("No ETHDebug metadata file"),
618 "{error}"
619 );
620
621 fs::write(dir.join("ethdebug.json"), "{not json").expect("metadata");
622 let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
623 .expect_err("malformed metadata");
624 assert!(error.to_string().contains("invalid JSON"), "{error}");
625 assert!(read_json_file(&dir.join("absent.json")).is_err());
626 }
627
628 #[test]
629 fn falls_back_to_legacy_source_maps() {
630 let dir = temp_dir("legacy");
631 let source =
632 "contract Counter {\n function set(uint256 x) public {\n value = x;\n }\n}\n";
633 fs::write(dir.join("Counter.sol"), source).expect("source");
634 let offset = source.find("value = x").expect("statement");
635 fs::write(
636 dir.join("combined.json"),
637 json!({
638 "sourceList": ["Counter.sol"],
639 "contracts": {"Counter.sol:Counter": {
640 "bin-runtime": "60010000",
641 "srcmap-runtime": format!("{offset}:9:0:-:0;{offset}:9:0;{offset}:9:0")
642 }}
643 })
644 .to_string(),
645 )
646 .expect("combined");
647
648 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
649 .expect("load")
650 .expect("present");
651 assert!(program.legacy);
652 assert_eq!(
653 program.source_contents.get(&0).map(String::as_str),
654 Some(source)
655 );
656 assert_eq!(program.source_files.get(&0), Some(&dir.join("Counter.sol")));
657 assert!(program.info.instruction_at_pc(2).is_some());
658
659 let empty = temp_dir("empty");
660 assert!(
661 load_debug_program(&empty, "Counter", SourceMapEnvironment::Runtime)
662 .expect("load")
663 .is_none()
664 );
665 }
666
667 #[test]
668 fn resources_come_from_the_record_or_are_assembled() {
669 let path = PathBuf::from("ethdebug.json");
670 let explicit = json!({"resources": {"compilation": {}}});
671 assert_eq!(
672 ethdebug_resources_from_metadata(&path, &explicit).expect("resources"),
673 json!({"compilation": {}})
674 );
675 let assembled = json!({"compilation": {"sources": []}, "types": {"a": 1}});
676 assert_eq!(
677 ethdebug_resources_from_metadata(&path, &assembled).expect("resources"),
678 json!({"compilation": {"sources": []}, "types": {"a": 1}, "pointers": {}})
679 );
680 let error = ethdebug_resources_from_metadata(&path, &json!({})).expect_err("neither");
681 assert!(
682 error.to_string().contains("does not contain resources"),
683 "{error}"
684 );
685 }
686}