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}
381
382pub fn load_contract_mapping(path: &Path) -> SoldbResult<Vec<ContractMapping>> {
384 let value = read_json_file(path)?;
385 parse_contract_mapping(path, &value)
386}
387
388pub fn parse_contract_mapping(path: &Path, value: &Value) -> SoldbResult<Vec<ContractMapping>> {
390 let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
391 let contracts = value
392 .get("contracts")
393 .and_then(Value::as_array)
394 .ok_or_else(|| {
395 SoldbError::Message(format!(
396 "contract mapping `{}` must contain a `contracts` array",
397 path.display()
398 ))
399 })?;
400
401 Ok(contracts
402 .iter()
403 .filter_map(|contract| {
404 let address = contract.get("address")?.as_str()?.to_owned();
405 let name = contract
406 .get("name")
407 .and_then(Value::as_str)
408 .unwrap_or("Unknown")
409 .to_owned();
410 let debug_dir = contract
411 .get("debug_dir")
412 .and_then(Value::as_str)
413 .map(PathBuf::from)?;
414 let debug_dir = if debug_dir.is_absolute() {
415 debug_dir
416 } else {
417 base_dir.join(debug_dir)
418 };
419 Some(ContractMapping {
420 address,
421 name,
422 debug_dir,
423 })
424 })
425 .collect())
426}
427
428#[cfg(test)]
429mod tests {
430 use std::fs;
431 use std::path::PathBuf;
432 use std::time::{SystemTime, UNIX_EPOCH};
433
434 use serde_json::json;
435
436 use super::{
437 contract_name_from_program_path, ethdebug_resources_from_metadata, find_ethdebug_metadata,
438 find_program_ethdebug, load_debug_program, load_storage_layout,
439 load_transient_storage_layout, read_debug_source, read_json_file,
440 };
441 use crate::source_map::SourceMapEnvironment;
442
443 fn temp_dir(label: &str) -> PathBuf {
444 let nanos = SystemTime::now()
445 .duration_since(UNIX_EPOCH)
446 .expect("clock")
447 .as_nanos();
448 let dir = std::env::temp_dir().join(format!("soldb-artifacts-{label}-{nanos}"));
449 fs::create_dir_all(&dir).expect("create temp dir");
450 dir
451 }
452
453 #[test]
454 fn loads_ethdebug_programs_with_their_sources() {
455 let dir = temp_dir("ethdebug");
456 let source =
457 "contract Counter {\n function set(uint256 x) public {\n value = x;\n }\n}\n";
458 fs::write(dir.join("Counter.sol"), source).expect("source");
459 fs::write(
460 dir.join("ethdebug_resources.json"),
461 json!({"compilation": {"sources": [{"id": 0, "path": "Counter.sol"}]}}).to_string(),
462 )
463 .expect("metadata");
464 fs::write(
465 dir.join("Counter_ethdebug-runtime.json"),
466 json!({"instructions": [{
467 "offset": 7,
468 "operation": {"mnemonic": "STOP"},
469 "context": {"code": {"source": {"id": 0}, "range": {"offset": 59, "length": 9}}}
470 }]})
471 .to_string(),
472 )
473 .expect("program");
474
475 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
476 .expect("load")
477 .expect("present");
478 assert!(!program.legacy);
479 assert_eq!(program.info.contract_name, "Counter");
480 assert_eq!(program.info.environment, "call");
481 assert!(program.info.instruction_at_pc(7).is_some());
482 assert_eq!(
483 program.source_contents.get(&0).map(String::as_str),
484 Some(source)
485 );
486 assert_eq!(program.source_files.get(&0), Some(&dir.join("Counter.sol")));
487 assert!(program.resources.get("compilation").is_some());
488 assert!(program.storage_layout.is_none());
490
491 let inferred = load_debug_program(&dir, "", SourceMapEnvironment::Runtime)
493 .expect("load")
494 .expect("present");
495 assert_eq!(inferred.info.contract_name, "Counter");
496 assert!(
498 load_debug_program(&dir, "Counter", SourceMapEnvironment::Creation)
499 .expect("load")
500 .is_none()
501 );
502 assert_eq!(
503 contract_name_from_program_path(
504 &dir.join("Counter_ethdebug-runtime.json"),
505 SourceMapEnvironment::Runtime
506 )
507 .as_deref(),
508 Some("Counter")
509 );
510 assert_eq!(
511 find_ethdebug_metadata(&dir),
512 Some(dir.join("ethdebug_resources.json"))
513 );
514 assert_eq!(
515 find_program_ethdebug(&dir, "Other", SourceMapEnvironment::Runtime),
516 Some(dir.join("Counter_ethdebug-runtime.json")),
517 "a single artifact serves any name"
518 );
519 assert_eq!(
520 read_debug_source(&dir, "Counter.sol").as_deref(),
521 Some(source)
522 );
523 assert!(read_debug_source(&dir, "Missing.sol").is_none());
524
525 fs::write(
528 dir.join("Counter_storage.json"),
529 json!({
530 "storage": [{
531 "astId": 1,
532 "contract": "Counter.sol:Counter",
533 "label": "value",
534 "offset": 0,
535 "slot": "1",
536 "type": "t_uint256"
537 }],
538 "types": {"t_uint256": {
539 "encoding": "inplace",
540 "label": "uint256",
541 "numberOfBytes": "32"
542 }}
543 })
544 .to_string(),
545 )
546 .expect("layout");
547 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
548 .expect("load")
549 .expect("present");
550 let layout = program.storage_layout.as_ref().expect("storage layout");
551 assert_eq!(layout.variable("value").expect("value").slot[31], 1);
552 assert!(load_storage_layout(&dir, "Missing")
553 .expect("absent")
554 .is_none());
555 assert!(program.transient_storage_layout.is_none());
557
558 fs::write(
561 dir.join("Counter_transient_storage.json"),
562 json!({
563 "storage": [{
564 "astId": 2,
565 "contract": "Counter.sol:Counter",
566 "label": "locked",
567 "offset": 0,
568 "slot": "0",
569 "type": "t_bool"
570 }],
571 "types": {"t_bool": {
572 "encoding": "inplace",
573 "label": "bool",
574 "numberOfBytes": "1"
575 }}
576 })
577 .to_string(),
578 )
579 .expect("transient layout");
580 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
581 .expect("load")
582 .expect("present");
583 let transient = program
584 .transient_storage_layout
585 .as_ref()
586 .expect("transient layout");
587 assert_eq!(transient.variable("locked").expect("locked").slot, [0; 32]);
588 assert!(transient.variable("value").is_none());
589 assert!(load_transient_storage_layout(&dir, "Missing")
590 .expect("absent")
591 .is_none());
592 fs::write(dir.join("Counter_storage.json"), "{}").expect("layout");
593 let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
594 .expect_err("a storage layout that cannot be read is reported");
595 assert!(
596 error.to_string().contains("Counter_storage.json"),
597 "{error}"
598 );
599 }
600
601 #[test]
602 fn a_program_without_metadata_is_an_error_not_a_silent_miss() {
603 let dir = temp_dir("no-metadata");
604 fs::write(dir.join("Counter_ethdebug-runtime.json"), "{}").expect("program");
605 let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
606 .expect_err("metadata is required");
607 assert!(
608 error.to_string().contains("No ETHDebug metadata file"),
609 "{error}"
610 );
611
612 fs::write(dir.join("ethdebug.json"), "{not json").expect("metadata");
613 let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
614 .expect_err("malformed metadata");
615 assert!(error.to_string().contains("invalid JSON"), "{error}");
616 assert!(read_json_file(&dir.join("absent.json")).is_err());
617 }
618
619 #[test]
620 fn falls_back_to_legacy_source_maps() {
621 let dir = temp_dir("legacy");
622 let source =
623 "contract Counter {\n function set(uint256 x) public {\n value = x;\n }\n}\n";
624 fs::write(dir.join("Counter.sol"), source).expect("source");
625 let offset = source.find("value = x").expect("statement");
626 fs::write(
627 dir.join("combined.json"),
628 json!({
629 "sourceList": ["Counter.sol"],
630 "contracts": {"Counter.sol:Counter": {
631 "bin-runtime": "60010000",
632 "srcmap-runtime": format!("{offset}:9:0:-:0;{offset}:9:0;{offset}:9:0")
633 }}
634 })
635 .to_string(),
636 )
637 .expect("combined");
638
639 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
640 .expect("load")
641 .expect("present");
642 assert!(program.legacy);
643 assert_eq!(
644 program.source_contents.get(&0).map(String::as_str),
645 Some(source)
646 );
647 assert_eq!(program.source_files.get(&0), Some(&dir.join("Counter.sol")));
648 assert!(program.info.instruction_at_pc(2).is_some());
649
650 let empty = temp_dir("empty");
651 assert!(
652 load_debug_program(&empty, "Counter", SourceMapEnvironment::Runtime)
653 .expect("load")
654 .is_none()
655 );
656 }
657
658 #[test]
659 fn resources_come_from_the_record_or_are_assembled() {
660 let path = PathBuf::from("ethdebug.json");
661 let explicit = json!({"resources": {"compilation": {}}});
662 assert_eq!(
663 ethdebug_resources_from_metadata(&path, &explicit).expect("resources"),
664 json!({"compilation": {}})
665 );
666 let assembled = json!({"compilation": {"sources": []}, "types": {"a": 1}});
667 assert_eq!(
668 ethdebug_resources_from_metadata(&path, &assembled).expect("resources"),
669 json!({"compilation": {"sources": []}, "types": {"a": 1}, "pointers": {}})
670 );
671 let error = ethdebug_resources_from_metadata(&path, &json!({})).expect_err("neither");
672 assert!(
673 error.to_string().contains("does not contain resources"),
674 "{error}"
675 );
676 }
677}