1use std::collections::BTreeMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use serde_json::{json, Value};
17
18use soldb_core::{SoldbError, SoldbResult};
19
20use crate::metadata::{read_compilation_source, EthdebugInfo};
21use crate::source_map::{load_source_map_program_with_sources, SourceMapEnvironment};
22use crate::storage_layout::StorageLayout;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct DebugProgram {
27 pub info: EthdebugInfo,
28 pub resources: Value,
30 pub source_contents: BTreeMap<u64, String>,
32 pub legacy: bool,
34 pub storage_layout: Option<StorageLayout>,
36 pub missing_sources: Vec<String>,
39}
40
41#[must_use]
43pub fn find_ethdebug_metadata(root: &Path) -> Option<PathBuf> {
44 [
47 root.join("ethdebug.json"),
48 root.join("ethdebug_resources.json"),
49 ]
50 .into_iter()
51 .find(|path| path.exists())
52}
53
54#[must_use]
60pub fn find_program_ethdebug(
61 root: &Path,
62 contract_name: &str,
63 environment: SourceMapEnvironment,
64) -> Option<PathBuf> {
65 let suffix = program_suffix(environment);
66 let named = root.join(format!("{contract_name}{suffix}"));
67 if !contract_name.is_empty() && named.exists() {
68 return Some(named);
69 }
70
71 let mut candidates = fs::read_dir(root)
72 .ok()?
73 .filter_map(Result::ok)
74 .map(|entry| entry.path())
75 .filter(|path| {
76 path.file_name()
77 .and_then(|name| name.to_str())
78 .is_some_and(|name| name.ends_with(suffix))
79 })
80 .collect::<Vec<_>>();
81 candidates.sort();
82
83 if !contract_name.is_empty() {
84 let contract_suffix = format!("_{contract_name}{suffix}");
85 let mut matching = candidates.iter().filter(|path| {
86 path.file_name()
87 .and_then(|name| name.to_str())
88 .is_some_and(|name| name.ends_with(&contract_suffix))
89 });
90 let matched = matching.next().cloned();
91 if matched.is_some() && matching.next().is_none() {
92 return matched;
93 }
94 }
95
96 (candidates.len() == 1).then(|| candidates.remove(0))
97}
98
99fn program_suffix(environment: SourceMapEnvironment) -> &'static str {
100 match environment {
101 SourceMapEnvironment::Creation => "_ethdebug.json",
102 SourceMapEnvironment::Runtime => "_ethdebug-runtime.json",
103 }
104}
105
106#[must_use]
108pub fn contract_name_from_program_path(
109 path: &Path,
110 environment: SourceMapEnvironment,
111) -> Option<String> {
112 path.file_name()
113 .and_then(|name| name.to_str())
114 .and_then(|name| name.strip_suffix(program_suffix(environment)))
115 .filter(|name| !name.is_empty())
116 .map(str::to_owned)
117}
118
119#[must_use]
122pub fn read_debug_source(root: &Path, source_path: &str) -> Option<String> {
123 read_debug_source_from(root, &[], source_path)
124}
125
126pub fn read_debug_source_from(
129 root: &Path,
130 extra_roots: &[PathBuf],
131 source_path: &str,
132) -> Option<String> {
133 extra_roots
134 .iter()
135 .flat_map(|extra| source_candidates(extra, source_path))
136 .chain(source_candidates(root, source_path))
137 .find_map(|candidate| fs::read_to_string(candidate).ok())
138}
139
140pub fn source_candidates(root: &Path, source_path: &str) -> Vec<PathBuf> {
147 let source = Path::new(source_path);
148 if source.is_absolute() {
149 return vec![source.to_path_buf()];
150 }
151 let mut candidates = Vec::new();
152 let mut directory = Some(root);
153 for _ in 0..4 {
155 let Some(current) = directory else {
156 break;
157 };
158 candidates.push(current.join(source));
159 directory = current.parent();
160 }
161 candidates.push(source.to_path_buf());
162 candidates
163}
164
165pub fn read_json_file(path: &Path) -> SoldbResult<Value> {
167 let content = fs::read_to_string(path).map_err(|error| {
168 SoldbError::Message(format!("failed to read `{}`: {error}", path.display()))
169 })?;
170 serde_json::from_str(&content).map_err(|error| {
171 SoldbError::Message(format!("invalid JSON in `{}`: {error}", path.display()))
172 })
173}
174
175pub fn ethdebug_resources_from_metadata(path: &Path, metadata: &Value) -> SoldbResult<Value> {
178 if let Some(resources) = metadata.get("resources") {
179 return Ok(resources.clone());
180 }
181
182 let Some(compilation) = metadata.get("compilation") else {
183 return Err(SoldbError::Message(format!(
184 "ETHDebug metadata {} does not contain resources or compilation",
185 path.display()
186 )));
187 };
188
189 Ok(json!({
190 "compilation": compilation,
191 "types": metadata.get("types").cloned().unwrap_or_else(|| json!({})),
192 "pointers": metadata.get("pointers").cloned().unwrap_or_else(|| json!({})),
193 }))
194}
195
196pub fn load_debug_program(
204 root: &Path,
205 contract_name: &str,
206 environment: SourceMapEnvironment,
207) -> SoldbResult<Option<DebugProgram>> {
208 load_debug_program_with_sources(root, contract_name, environment, &[])
209}
210
211pub fn load_debug_program_with_sources(
217 root: &Path,
218 contract_name: &str,
219 environment: SourceMapEnvironment,
220 source_roots: &[PathBuf],
221) -> SoldbResult<Option<DebugProgram>> {
222 let environment_name = match environment {
223 SourceMapEnvironment::Creation => "create",
224 SourceMapEnvironment::Runtime => "call",
225 };
226 if let Some(program_path) = find_program_ethdebug(root, contract_name, environment) {
227 let metadata_path = find_ethdebug_metadata(root).ok_or_else(|| {
228 SoldbError::Message(format!(
229 "No ETHDebug metadata file (ethdebug.json or ethdebug_resources.json) found in {}",
230 root.display()
231 ))
232 })?;
233 let metadata = read_json_file(&metadata_path)?;
234 let program = read_json_file(&program_path)?;
235 let resources = ethdebug_resources_from_metadata(&metadata_path, &metadata)?;
236 let name = if contract_name.is_empty() {
237 contract_name_from_program_path(&program_path, environment)
238 .unwrap_or_else(|| "Contract".to_owned())
239 } else {
240 contract_name.to_owned()
241 };
242 let info = EthdebugInfo::from_artifacts(&name, environment_name, &metadata, &program)
243 .map_err(|error| SoldbError::Message(format!("{}: {error}", program_path.display())))?;
244 let (source_contents, missing_sources) =
245 read_sources(root, source_roots, &info, BTreeMap::new());
246 let storage_layout = load_storage_layout(root, &info.contract_name)?;
247 return Ok(Some(DebugProgram {
248 info,
249 resources,
250 source_contents,
251 legacy: false,
252 storage_layout,
253 missing_sources,
254 }));
255 }
256
257 let Some(program) =
258 load_source_map_program_with_sources(root, source_roots, contract_name, environment)?
259 else {
260 return Ok(None);
261 };
262 let (source_contents, missing_sources) =
263 read_sources(root, source_roots, &program.info, program.source_contents);
264 let storage_layout = match program.storage_layout {
265 Some(layout) => Some(layout),
266 None => load_storage_layout(root, &program.info.contract_name)?,
267 };
268 Ok(Some(DebugProgram {
269 info: program.info,
270 resources: program.resources,
271 source_contents,
272 legacy: true,
273 storage_layout,
274 missing_sources,
275 }))
276}
277
278pub fn load_storage_layout(root: &Path, contract_name: &str) -> SoldbResult<Option<StorageLayout>> {
283 let path = root.join(format!("{contract_name}_storage.json"));
284 if !path.exists() {
285 return Ok(None);
286 }
287 let value = read_json_file(&path)?;
288 StorageLayout::parse(&value)
289 .map(Some)
290 .map_err(|error| SoldbError::Message(format!("{}: {error}", path.display())))
291}
292
293fn read_sources(
296 root: &Path,
297 source_roots: &[PathBuf],
298 info: &EthdebugInfo,
299 mut source_contents: BTreeMap<u64, String>,
300) -> (BTreeMap<u64, String>, Vec<String>) {
301 let mut missing = Vec::new();
302 for (source_id, source_path) in &info.sources {
303 if source_contents.contains_key(source_id) {
304 continue;
305 }
306 match read_compilation_source(&info.compilation, *source_id)
307 .or_else(|| read_debug_source_from(root, source_roots, source_path))
308 {
309 Some(source) => {
310 source_contents.insert(*source_id, source);
311 }
312 None => missing.push(source_path.clone()),
313 }
314 }
315 (source_contents, missing)
316}
317
318#[cfg(test)]
319mod tests {
320 use std::fs;
321 use std::path::PathBuf;
322 use std::time::{SystemTime, UNIX_EPOCH};
323
324 use serde_json::json;
325
326 use super::{
327 contract_name_from_program_path, ethdebug_resources_from_metadata, find_ethdebug_metadata,
328 find_program_ethdebug, load_debug_program, load_storage_layout, read_debug_source,
329 read_json_file,
330 };
331 use crate::source_map::SourceMapEnvironment;
332
333 fn temp_dir(label: &str) -> PathBuf {
334 let nanos = SystemTime::now()
335 .duration_since(UNIX_EPOCH)
336 .expect("clock")
337 .as_nanos();
338 let dir = std::env::temp_dir().join(format!("soldb-artifacts-{label}-{nanos}"));
339 fs::create_dir_all(&dir).expect("create temp dir");
340 dir
341 }
342
343 #[test]
344 fn loads_ethdebug_programs_with_their_sources() {
345 let dir = temp_dir("ethdebug");
346 let source =
347 "contract Counter {\n function set(uint256 x) public {\n value = x;\n }\n}\n";
348 fs::write(dir.join("Counter.sol"), source).expect("source");
349 fs::write(
350 dir.join("ethdebug_resources.json"),
351 json!({"compilation": {"sources": [{"id": 0, "path": "Counter.sol"}]}}).to_string(),
352 )
353 .expect("metadata");
354 fs::write(
355 dir.join("Counter_ethdebug-runtime.json"),
356 json!({"instructions": [{
357 "offset": 7,
358 "operation": {"mnemonic": "STOP"},
359 "context": {"code": {"source": {"id": 0}, "range": {"offset": 59, "length": 9}}}
360 }]})
361 .to_string(),
362 )
363 .expect("program");
364
365 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
366 .expect("load")
367 .expect("present");
368 assert!(!program.legacy);
369 assert_eq!(program.info.contract_name, "Counter");
370 assert_eq!(program.info.environment, "call");
371 assert!(program.info.instruction_at_pc(7).is_some());
372 assert_eq!(
373 program.source_contents.get(&0).map(String::as_str),
374 Some(source)
375 );
376 assert!(program.resources.get("compilation").is_some());
377 assert!(program.storage_layout.is_none());
379
380 let inferred = load_debug_program(&dir, "", SourceMapEnvironment::Runtime)
382 .expect("load")
383 .expect("present");
384 assert_eq!(inferred.info.contract_name, "Counter");
385 assert!(
387 load_debug_program(&dir, "Counter", SourceMapEnvironment::Creation)
388 .expect("load")
389 .is_none()
390 );
391 assert_eq!(
392 contract_name_from_program_path(
393 &dir.join("Counter_ethdebug-runtime.json"),
394 SourceMapEnvironment::Runtime
395 )
396 .as_deref(),
397 Some("Counter")
398 );
399 assert_eq!(
400 find_ethdebug_metadata(&dir),
401 Some(dir.join("ethdebug_resources.json"))
402 );
403 assert_eq!(
404 find_program_ethdebug(&dir, "Other", SourceMapEnvironment::Runtime),
405 Some(dir.join("Counter_ethdebug-runtime.json")),
406 "a single artifact serves any name"
407 );
408 assert_eq!(
409 read_debug_source(&dir, "Counter.sol").as_deref(),
410 Some(source)
411 );
412 assert!(read_debug_source(&dir, "Missing.sol").is_none());
413
414 fs::write(
417 dir.join("Counter_storage.json"),
418 json!({
419 "storage": [{
420 "astId": 1,
421 "contract": "Counter.sol:Counter",
422 "label": "value",
423 "offset": 0,
424 "slot": "1",
425 "type": "t_uint256"
426 }],
427 "types": {"t_uint256": {
428 "encoding": "inplace",
429 "label": "uint256",
430 "numberOfBytes": "32"
431 }}
432 })
433 .to_string(),
434 )
435 .expect("layout");
436 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
437 .expect("load")
438 .expect("present");
439 let layout = program.storage_layout.as_ref().expect("storage layout");
440 assert_eq!(layout.variable("value").expect("value").slot[31], 1);
441 assert!(load_storage_layout(&dir, "Missing")
442 .expect("absent")
443 .is_none());
444 fs::write(dir.join("Counter_storage.json"), "{}").expect("layout");
445 let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
446 .expect_err("a storage layout that cannot be read is reported");
447 assert!(
448 error.to_string().contains("Counter_storage.json"),
449 "{error}"
450 );
451 }
452
453 #[test]
454 fn a_program_without_metadata_is_an_error_not_a_silent_miss() {
455 let dir = temp_dir("no-metadata");
456 fs::write(dir.join("Counter_ethdebug-runtime.json"), "{}").expect("program");
457 let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
458 .expect_err("metadata is required");
459 assert!(
460 error.to_string().contains("No ETHDebug metadata file"),
461 "{error}"
462 );
463
464 fs::write(dir.join("ethdebug.json"), "{not json").expect("metadata");
465 let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
466 .expect_err("malformed metadata");
467 assert!(error.to_string().contains("invalid JSON"), "{error}");
468 assert!(read_json_file(&dir.join("absent.json")).is_err());
469 }
470
471 #[test]
472 fn falls_back_to_legacy_source_maps() {
473 let dir = temp_dir("legacy");
474 let source =
475 "contract Counter {\n function set(uint256 x) public {\n value = x;\n }\n}\n";
476 fs::write(dir.join("Counter.sol"), source).expect("source");
477 let offset = source.find("value = x").expect("statement");
478 fs::write(
479 dir.join("combined.json"),
480 json!({
481 "sourceList": ["Counter.sol"],
482 "contracts": {"Counter.sol:Counter": {
483 "bin-runtime": "60010000",
484 "srcmap-runtime": format!("{offset}:9:0:-:0;{offset}:9:0;{offset}:9:0")
485 }}
486 })
487 .to_string(),
488 )
489 .expect("combined");
490
491 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
492 .expect("load")
493 .expect("present");
494 assert!(program.legacy);
495 assert_eq!(
496 program.source_contents.get(&0).map(String::as_str),
497 Some(source)
498 );
499 assert!(program.info.instruction_at_pc(2).is_some());
500
501 let empty = temp_dir("empty");
502 assert!(
503 load_debug_program(&empty, "Counter", SourceMapEnvironment::Runtime)
504 .expect("load")
505 .is_none()
506 );
507 }
508
509 #[test]
510 fn resources_come_from_the_record_or_are_assembled() {
511 let path = PathBuf::from("ethdebug.json");
512 let explicit = json!({"resources": {"compilation": {}}});
513 assert_eq!(
514 ethdebug_resources_from_metadata(&path, &explicit).expect("resources"),
515 json!({"compilation": {}})
516 );
517 let assembled = json!({"compilation": {"sources": []}, "types": {"a": 1}});
518 assert_eq!(
519 ethdebug_resources_from_metadata(&path, &assembled).expect("resources"),
520 json!({"compilation": {"sources": []}, "types": {"a": 1}, "pointers": {}})
521 );
522 let error = ethdebug_resources_from_metadata(&path, &json!({})).expect_err("neither");
523 assert!(
524 error.to_string().contains("does not contain resources"),
525 "{error}"
526 );
527 }
528}