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::{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/// Which of solc's code generators produced a program.
26///
27/// The two keep variables in different places: the legacy generator reserves a stack slot
28/// per declaration and frees it at the end of its block, which a debugger can follow
29/// without variable information from the compiler; the via-IR pipeline lays the stack out
30/// as its optimizer sees fit, and only compiler-emitted locations can describe it.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum CodeGenerator {
34    Legacy,
35    ViaIr,
36}
37
38/// One contract's debug information as loaded from its artifacts.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct DebugProgram {
41    pub info: EthdebugInfo,
42    /// The ETHDebug resources record: the compilation, types, and pointers.
43    pub resources: Value,
44    /// Source text by source id, for every source that could be read.
45    pub source_contents: BTreeMap<u64, String>,
46    /// True when the program came from a legacy `srcmap` rather than ETHDebug.
47    pub legacy: bool,
48    /// The contract's storage layout, when it was compiled with `--storage-layout`.
49    pub storage_layout: Option<StorageLayout>,
50    /// Sources the artifact names that could not be read, so a frontend can say why its
51    /// lines are missing instead of degrading to an opcode view without a word.
52    pub missing_sources: Vec<String>,
53}
54
55impl DebugProgram {
56    /// The code generator the program came from. A legacy `srcmap` is taken to come from
57    /// the legacy pipeline, which is the only one that emits nothing else; ETHDebug comes
58    /// from solc's via-IR pipeline or from a compiler built on IR.
59    #[must_use]
60    pub const fn code_generator(&self) -> CodeGenerator {
61        if self.legacy {
62            CodeGenerator::Legacy
63        } else {
64            CodeGenerator::ViaIr
65        }
66    }
67}
68
69/// The global ETHDebug resource file in `root`, under either of its names.
70#[must_use]
71pub fn find_ethdebug_metadata(root: &Path) -> Option<PathBuf> {
72    // Modern solc (>= ~0.8.32) renamed the global ETHDebug metadata file from
73    // `ethdebug.json` to `ethdebug_resources.json`; accept either.
74    [
75        root.join("ethdebug.json"),
76        root.join("ethdebug_resources.json"),
77    ]
78    .into_iter()
79    .find(|path| path.exists())
80}
81
82/// The ETHDebug program artifact for one contract and environment.
83///
84/// Prefers `<Contract><suffix>`; otherwise accepts a unique artifact whose name ends in
85/// `_<Contract><suffix>`, or the only artifact with that suffix in the directory, which
86/// is how a directory compiled for one contract loads with no name given.
87#[must_use]
88pub fn find_program_ethdebug(
89    root: &Path,
90    contract_name: &str,
91    environment: SourceMapEnvironment,
92) -> Option<PathBuf> {
93    let suffix = program_suffix(environment);
94    let named = root.join(format!("{contract_name}{suffix}"));
95    if !contract_name.is_empty() && named.exists() {
96        return Some(named);
97    }
98
99    let mut candidates = fs::read_dir(root)
100        .ok()?
101        .filter_map(Result::ok)
102        .map(|entry| entry.path())
103        .filter(|path| {
104            path.file_name()
105                .and_then(|name| name.to_str())
106                .is_some_and(|name| name.ends_with(suffix))
107        })
108        .collect::<Vec<_>>();
109    candidates.sort();
110
111    if !contract_name.is_empty() {
112        let contract_suffix = format!("_{contract_name}{suffix}");
113        let mut matching = candidates.iter().filter(|path| {
114            path.file_name()
115                .and_then(|name| name.to_str())
116                .is_some_and(|name| name.ends_with(&contract_suffix))
117        });
118        let matched = matching.next().cloned();
119        if matched.is_some() && matching.next().is_none() {
120            return matched;
121        }
122    }
123
124    (candidates.len() == 1).then(|| candidates.remove(0))
125}
126
127fn program_suffix(environment: SourceMapEnvironment) -> &'static str {
128    match environment {
129        SourceMapEnvironment::Creation => "_ethdebug.json",
130        SourceMapEnvironment::Runtime => "_ethdebug-runtime.json",
131    }
132}
133
134/// The contract name a program artifact's file name carries.
135#[must_use]
136pub fn contract_name_from_program_path(
137    path: &Path,
138    environment: SourceMapEnvironment,
139) -> Option<String> {
140    path.file_name()
141        .and_then(|name| name.to_str())
142        .and_then(|name| name.strip_suffix(program_suffix(environment)))
143        .filter(|name| !name.is_empty())
144        .map(str::to_owned)
145}
146
147/// Reads a source file named by the compiler, trying it relative to the artifact
148/// directory, to that directory's parent, and as given.
149#[must_use]
150pub fn read_debug_source(root: &Path, source_path: &str) -> Option<String> {
151    read_debug_source_from(root, &[], source_path)
152}
153
154/// The same, looking in `extra_roots` first: directories the user named because the
155/// sources are not where the artifact's paths reach.
156pub fn read_debug_source_from(
157    root: &Path,
158    extra_roots: &[PathBuf],
159    source_path: &str,
160) -> Option<String> {
161    extra_roots
162        .iter()
163        .flat_map(|extra| source_candidates(extra, source_path))
164        .chain(source_candidates(root, source_path))
165        .find_map(|candidate| fs::read_to_string(candidate).ok())
166}
167
168/// Where a source path recorded in an artifact might be on disk.
169///
170/// A compiler writes the path it was given, which is relative to the directory the
171/// compilation ran in — usually a project root, with the artifacts a few directories
172/// below it. So the artifact directory is tried first, then its ancestors, then the path
173/// as given, which resolves against the directory soldb runs in.
174pub fn source_candidates(root: &Path, source_path: &str) -> Vec<PathBuf> {
175    let source = Path::new(source_path);
176    if source.is_absolute() {
177        return vec![source.to_path_buf()];
178    }
179    let mut candidates = Vec::new();
180    let mut directory = Some(root);
181    // The project root is rarely more than a few directories above `out/`.
182    for _ in 0..4 {
183        let Some(current) = directory else {
184            break;
185        };
186        candidates.push(current.join(source));
187        directory = current.parent();
188    }
189    candidates.push(source.to_path_buf());
190    candidates
191}
192
193/// Parses a JSON file, naming the file in the error.
194pub fn read_json_file(path: &Path) -> SoldbResult<Value> {
195    let content = fs::read_to_string(path).map_err(|error| {
196        SoldbError::Message(format!("failed to read `{}`: {error}", path.display()))
197    })?;
198    serde_json::from_str(&content).map_err(|error| {
199        SoldbError::Message(format!("invalid JSON in `{}`: {error}", path.display()))
200    })
201}
202
203/// The resources record of an ETHDebug metadata file: its `resources` entry when it has
204/// one, otherwise one assembled from the compilation, types, and pointers it carries.
205pub fn ethdebug_resources_from_metadata(path: &Path, metadata: &Value) -> SoldbResult<Value> {
206    if let Some(resources) = metadata.get("resources") {
207        return Ok(resources.clone());
208    }
209
210    let Some(compilation) = metadata.get("compilation") else {
211        return Err(SoldbError::Message(format!(
212            "ETHDebug metadata {} does not contain resources or compilation",
213            path.display()
214        )));
215    };
216
217    Ok(json!({
218        "compilation": compilation,
219        "types": metadata.get("types").cloned().unwrap_or_else(|| json!({})),
220        "pointers": metadata.get("pointers").cloned().unwrap_or_else(|| json!({})),
221    }))
222}
223
224/// Loads a contract's debug program from `root`: the ETHDebug artifacts when present,
225/// otherwise the legacy source map in `combined.json`.
226///
227/// `Ok(None)` means the directory holds neither for this contract. A present but
228/// unreadable artifact is an error, so a caller never silently loses source attribution.
229/// An empty `contract_name` loads the only program in the directory and takes its name
230/// from the file.
231pub fn load_debug_program(
232    root: &Path,
233    contract_name: &str,
234    environment: SourceMapEnvironment,
235) -> SoldbResult<Option<DebugProgram>> {
236    load_debug_program_with_sources(root, contract_name, environment, &[])
237}
238
239/// The same, also looking for sources in `source_roots`.
240///
241/// A compiler records the source paths it was given, which are relative to the directory
242/// the compilation ran in. When the artifacts are somewhere else entirely, that directory
243/// is the one thing the debugger cannot work out for itself, so a frontend passes it.
244pub fn load_debug_program_with_sources(
245    root: &Path,
246    contract_name: &str,
247    environment: SourceMapEnvironment,
248    source_roots: &[PathBuf],
249) -> SoldbResult<Option<DebugProgram>> {
250    let environment_name = match environment {
251        SourceMapEnvironment::Creation => "create",
252        SourceMapEnvironment::Runtime => "call",
253    };
254    if let Some(program_path) = find_program_ethdebug(root, contract_name, environment) {
255        let metadata_path = find_ethdebug_metadata(root).ok_or_else(|| {
256            SoldbError::Message(format!(
257                "No ETHDebug metadata file (ethdebug.json or ethdebug_resources.json) found in {}",
258                root.display()
259            ))
260        })?;
261        let metadata = read_json_file(&metadata_path)?;
262        let program = read_json_file(&program_path)?;
263        let resources = ethdebug_resources_from_metadata(&metadata_path, &metadata)?;
264        let name = if contract_name.is_empty() {
265            contract_name_from_program_path(&program_path, environment)
266                .unwrap_or_else(|| "Contract".to_owned())
267        } else {
268            contract_name.to_owned()
269        };
270        let info = EthdebugInfo::from_artifacts(&name, environment_name, &metadata, &program)
271            .map_err(|error| SoldbError::Message(format!("{}: {error}", program_path.display())))?;
272        let (source_contents, missing_sources) =
273            read_sources(root, source_roots, &info, BTreeMap::new());
274        let storage_layout = load_storage_layout(root, &info.contract_name)?;
275        return Ok(Some(DebugProgram {
276            info,
277            resources,
278            source_contents,
279            legacy: false,
280            storage_layout,
281            missing_sources,
282        }));
283    }
284
285    let Some(program) =
286        load_source_map_program_with_sources(root, source_roots, contract_name, environment)?
287    else {
288        return Ok(None);
289    };
290    let (source_contents, missing_sources) =
291        read_sources(root, source_roots, &program.info, program.source_contents);
292    let storage_layout = match program.storage_layout {
293        Some(layout) => Some(layout),
294        None => load_storage_layout(root, &program.info.contract_name)?,
295    };
296    Ok(Some(DebugProgram {
297        info: program.info,
298        resources: program.resources,
299        source_contents,
300        legacy: true,
301        storage_layout,
302        missing_sources,
303    }))
304}
305
306/// The `<Contract>_storage.json` that `solc --storage-layout -o <root>` writes, when it
307/// is there. A missing file is not an error: the layout is optional debug information.
308/// An unreadable one is, because the user compiled with it and would otherwise get a
309/// silent "no storage layout" that looks identical to never having asked for it.
310pub fn load_storage_layout(root: &Path, contract_name: &str) -> SoldbResult<Option<StorageLayout>> {
311    let path = root.join(format!("{contract_name}_storage.json"));
312    if !path.exists() {
313        return Ok(None);
314    }
315    let value = read_json_file(&path)?;
316    StorageLayout::parse(&value)
317        .map(Some)
318        .map_err(|error| SoldbError::Message(format!("{}: {error}", path.display())))
319}
320
321/// Fills in the source text for every source the program names, from the compilation
322/// record first and from disk next to the artifacts otherwise.
323fn read_sources(
324    root: &Path,
325    source_roots: &[PathBuf],
326    info: &EthdebugInfo,
327    mut source_contents: BTreeMap<u64, String>,
328) -> (BTreeMap<u64, String>, Vec<String>) {
329    let mut missing = Vec::new();
330    for (source_id, source_path) in &info.sources {
331        if source_contents.contains_key(source_id) {
332            continue;
333        }
334        match read_compilation_source(&info.compilation, *source_id)
335            .or_else(|| read_debug_source_from(root, source_roots, source_path))
336        {
337            Some(source) => {
338                source_contents.insert(*source_id, source);
339            }
340            None => missing.push(source_path.clone()),
341        }
342    }
343    (source_contents, missing)
344}
345
346#[cfg(test)]
347mod tests {
348    use std::fs;
349    use std::path::PathBuf;
350    use std::time::{SystemTime, UNIX_EPOCH};
351
352    use serde_json::json;
353
354    use super::{
355        contract_name_from_program_path, ethdebug_resources_from_metadata, find_ethdebug_metadata,
356        find_program_ethdebug, load_debug_program, load_storage_layout, read_debug_source,
357        read_json_file,
358    };
359    use crate::source_map::SourceMapEnvironment;
360
361    fn temp_dir(label: &str) -> PathBuf {
362        let nanos = SystemTime::now()
363            .duration_since(UNIX_EPOCH)
364            .expect("clock")
365            .as_nanos();
366        let dir = std::env::temp_dir().join(format!("soldb-artifacts-{label}-{nanos}"));
367        fs::create_dir_all(&dir).expect("create temp dir");
368        dir
369    }
370
371    #[test]
372    fn loads_ethdebug_programs_with_their_sources() {
373        let dir = temp_dir("ethdebug");
374        let source =
375            "contract Counter {\n  function set(uint256 x) public {\n    value = x;\n  }\n}\n";
376        fs::write(dir.join("Counter.sol"), source).expect("source");
377        fs::write(
378            dir.join("ethdebug_resources.json"),
379            json!({"compilation": {"sources": [{"id": 0, "path": "Counter.sol"}]}}).to_string(),
380        )
381        .expect("metadata");
382        fs::write(
383            dir.join("Counter_ethdebug-runtime.json"),
384            json!({"instructions": [{
385                "offset": 7,
386                "operation": {"mnemonic": "STOP"},
387                "context": {"code": {"source": {"id": 0}, "range": {"offset": 59, "length": 9}}}
388            }]})
389            .to_string(),
390        )
391        .expect("program");
392
393        let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
394            .expect("load")
395            .expect("present");
396        assert!(!program.legacy);
397        assert_eq!(program.info.contract_name, "Counter");
398        assert_eq!(program.info.environment, "call");
399        assert!(program.info.instruction_at_pc(7).is_some());
400        assert_eq!(
401            program.source_contents.get(&0).map(String::as_str),
402            Some(source)
403        );
404        assert!(program.resources.get("compilation").is_some());
405        // No `Counter_storage.json` yet: the layout is optional debug information.
406        assert!(program.storage_layout.is_none());
407
408        // No name: the only program in the directory, named after its file.
409        let inferred = load_debug_program(&dir, "", SourceMapEnvironment::Runtime)
410            .expect("load")
411            .expect("present");
412        assert_eq!(inferred.info.contract_name, "Counter");
413        // Creation code is a different artifact, absent here.
414        assert!(
415            load_debug_program(&dir, "Counter", SourceMapEnvironment::Creation)
416                .expect("load")
417                .is_none()
418        );
419        assert_eq!(
420            contract_name_from_program_path(
421                &dir.join("Counter_ethdebug-runtime.json"),
422                SourceMapEnvironment::Runtime
423            )
424            .as_deref(),
425            Some("Counter")
426        );
427        assert_eq!(
428            find_ethdebug_metadata(&dir),
429            Some(dir.join("ethdebug_resources.json"))
430        );
431        assert_eq!(
432            find_program_ethdebug(&dir, "Other", SourceMapEnvironment::Runtime),
433            Some(dir.join("Counter_ethdebug-runtime.json")),
434            "a single artifact serves any name"
435        );
436        assert_eq!(
437            read_debug_source(&dir, "Counter.sol").as_deref(),
438            Some(source)
439        );
440        assert!(read_debug_source(&dir, "Missing.sol").is_none());
441
442        // `solc --storage-layout -o <dir>` writes one file per contract; the loader picks
443        // it up, and a malformed one is an error rather than a silent miss.
444        fs::write(
445            dir.join("Counter_storage.json"),
446            json!({
447                "storage": [{
448                    "astId": 1,
449                    "contract": "Counter.sol:Counter",
450                    "label": "value",
451                    "offset": 0,
452                    "slot": "1",
453                    "type": "t_uint256"
454                }],
455                "types": {"t_uint256": {
456                    "encoding": "inplace",
457                    "label": "uint256",
458                    "numberOfBytes": "32"
459                }}
460            })
461            .to_string(),
462        )
463        .expect("layout");
464        let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
465            .expect("load")
466            .expect("present");
467        let layout = program.storage_layout.as_ref().expect("storage layout");
468        assert_eq!(layout.variable("value").expect("value").slot[31], 1);
469        assert!(load_storage_layout(&dir, "Missing")
470            .expect("absent")
471            .is_none());
472        fs::write(dir.join("Counter_storage.json"), "{}").expect("layout");
473        let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
474            .expect_err("a storage layout that cannot be read is reported");
475        assert!(
476            error.to_string().contains("Counter_storage.json"),
477            "{error}"
478        );
479    }
480
481    #[test]
482    fn a_program_without_metadata_is_an_error_not_a_silent_miss() {
483        let dir = temp_dir("no-metadata");
484        fs::write(dir.join("Counter_ethdebug-runtime.json"), "{}").expect("program");
485        let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
486            .expect_err("metadata is required");
487        assert!(
488            error.to_string().contains("No ETHDebug metadata file"),
489            "{error}"
490        );
491
492        fs::write(dir.join("ethdebug.json"), "{not json").expect("metadata");
493        let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
494            .expect_err("malformed metadata");
495        assert!(error.to_string().contains("invalid JSON"), "{error}");
496        assert!(read_json_file(&dir.join("absent.json")).is_err());
497    }
498
499    #[test]
500    fn falls_back_to_legacy_source_maps() {
501        let dir = temp_dir("legacy");
502        let source =
503            "contract Counter {\n  function set(uint256 x) public {\n    value = x;\n  }\n}\n";
504        fs::write(dir.join("Counter.sol"), source).expect("source");
505        let offset = source.find("value = x").expect("statement");
506        fs::write(
507            dir.join("combined.json"),
508            json!({
509                "sourceList": ["Counter.sol"],
510                "contracts": {"Counter.sol:Counter": {
511                    "bin-runtime": "60010000",
512                    "srcmap-runtime": format!("{offset}:9:0:-:0;{offset}:9:0;{offset}:9:0")
513                }}
514            })
515            .to_string(),
516        )
517        .expect("combined");
518
519        let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
520            .expect("load")
521            .expect("present");
522        assert!(program.legacy);
523        assert_eq!(
524            program.source_contents.get(&0).map(String::as_str),
525            Some(source)
526        );
527        assert!(program.info.instruction_at_pc(2).is_some());
528
529        let empty = temp_dir("empty");
530        assert!(
531            load_debug_program(&empty, "Counter", SourceMapEnvironment::Runtime)
532                .expect("load")
533                .is_none()
534        );
535    }
536
537    #[test]
538    fn resources_come_from_the_record_or_are_assembled() {
539        let path = PathBuf::from("ethdebug.json");
540        let explicit = json!({"resources": {"compilation": {}}});
541        assert_eq!(
542            ethdebug_resources_from_metadata(&path, &explicit).expect("resources"),
543            json!({"compilation": {}})
544        );
545        let assembled = json!({"compilation": {"sources": []}, "types": {"a": 1}});
546        assert_eq!(
547            ethdebug_resources_from_metadata(&path, &assembled).expect("resources"),
548            json!({"compilation": {"sources": []}, "types": {"a": 1}, "pointers": {}})
549        );
550        let error = ethdebug_resources_from_metadata(&path, &json!({})).expect_err("neither");
551        assert!(
552            error.to_string().contains("does not contain resources"),
553            "{error}"
554        );
555    }
556}