Skip to main content

soldb_ethdebug/
artifacts.rs

1//! Finding and loading one contract's debug artifacts from a directory.
2//!
3//! An output directory holds the global resource file (`ethdebug.json` from older
4//! compilers, `ethdebug_resources.json` from modern ones), one program artifact per
5//! contract and environment (`<Contract>_ethdebug.json` for creation code,
6//! `<Contract>_ethdebug-runtime.json` for runtime code), and, for compilers that predate
7//! ETHDebug, a `combined.json` with legacy source maps. [`load_debug_program`] is the one
8//! path through all of that: ETHDebug first, the legacy map as a fallback, and the
9//! source text read from the compilation record or from disk. The CLI and the DAP server
10//! both load through it, so a directory that works in one works in the other.
11
12use 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/// One contract's debug information as loaded from its artifacts.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct DebugProgram {
27    pub info: EthdebugInfo,
28    /// The ETHDebug resources record: the compilation, types, and pointers.
29    pub resources: Value,
30    /// Source text by source id, for every source that could be read.
31    pub source_contents: BTreeMap<u64, String>,
32    /// True when the program came from a legacy `srcmap` rather than ETHDebug.
33    pub legacy: bool,
34    /// The contract's storage layout, when it was compiled with `--storage-layout`.
35    pub storage_layout: Option<StorageLayout>,
36    /// Sources the artifact names that could not be read, so a frontend can say why its
37    /// lines are missing instead of degrading to an opcode view without a word.
38    pub missing_sources: Vec<String>,
39}
40
41/// The global ETHDebug resource file in `root`, under either of its names.
42#[must_use]
43pub fn find_ethdebug_metadata(root: &Path) -> Option<PathBuf> {
44    // Modern solc (>= ~0.8.32) renamed the global ETHDebug metadata file from
45    // `ethdebug.json` to `ethdebug_resources.json`; accept either.
46    [
47        root.join("ethdebug.json"),
48        root.join("ethdebug_resources.json"),
49    ]
50    .into_iter()
51    .find(|path| path.exists())
52}
53
54/// The ETHDebug program artifact for one contract and environment.
55///
56/// Prefers `<Contract><suffix>`; otherwise accepts a unique artifact whose name ends in
57/// `_<Contract><suffix>`, or the only artifact with that suffix in the directory, which
58/// is how a directory compiled for one contract loads with no name given.
59#[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/// The contract name a program artifact's file name carries.
107#[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/// Reads a source file named by the compiler, trying it relative to the artifact
120/// directory, to that directory's parent, and as given.
121#[must_use]
122pub fn read_debug_source(root: &Path, source_path: &str) -> Option<String> {
123    read_debug_source_from(root, &[], source_path)
124}
125
126/// The same, looking in `extra_roots` first: directories the user named because the
127/// sources are not where the artifact's paths reach.
128pub 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
140/// Where a source path recorded in an artifact might be on disk.
141///
142/// A compiler writes the path it was given, which is relative to the directory the
143/// compilation ran in — usually a project root, with the artifacts a few directories
144/// below it. So the artifact directory is tried first, then its ancestors, then the path
145/// as given, which resolves against the directory soldb runs in.
146pub 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    // The project root is rarely more than a few directories above `out/`.
154    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
165/// Parses a JSON file, naming the file in the error.
166pub 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
175/// The resources record of an ETHDebug metadata file: its `resources` entry when it has
176/// one, otherwise one assembled from the compilation, types, and pointers it carries.
177pub 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
196/// Loads a contract's debug program from `root`: the ETHDebug artifacts when present,
197/// otherwise the legacy source map in `combined.json`.
198///
199/// `Ok(None)` means the directory holds neither for this contract. A present but
200/// unreadable artifact is an error, so a caller never silently loses source attribution.
201/// An empty `contract_name` loads the only program in the directory and takes its name
202/// from the file.
203pub 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
211/// The same, also looking for sources in `source_roots`.
212///
213/// A compiler records the source paths it was given, which are relative to the directory
214/// the compilation ran in. When the artifacts are somewhere else entirely, that directory
215/// is the one thing the debugger cannot work out for itself, so a frontend passes it.
216pub 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
278/// The `<Contract>_storage.json` that `solc --storage-layout -o <root>` writes, when it
279/// is there. A missing file is not an error: the layout is optional debug information.
280/// An unreadable one is, because the user compiled with it and would otherwise get a
281/// silent "no storage layout" that looks identical to never having asked for it.
282pub 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
293/// Fills in the source text for every source the program names, from the compilation
294/// record first and from disk next to the artifacts otherwise.
295fn 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        // No `Counter_storage.json` yet: the layout is optional debug information.
378        assert!(program.storage_layout.is_none());
379
380        // No name: the only program in the directory, named after its file.
381        let inferred = load_debug_program(&dir, "", SourceMapEnvironment::Runtime)
382            .expect("load")
383            .expect("present");
384        assert_eq!(inferred.info.contract_name, "Counter");
385        // Creation code is a different artifact, absent here.
386        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        // `solc --storage-layout -o <dir>` writes one file per contract; the loader picks
415        // it up, and a malformed one is an error rather than a silent miss.
416        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}