Skip to main content

chisel/
session.rs

1//! ChiselSession
2//!
3//! This module contains the `ChiselSession` struct, which is the top-level
4//! wrapper for a serializable REPL session.
5
6use crate::prelude::{SessionSource, SessionSourceConfig};
7use eyre::Result;
8use foundry_evm::{core::evm::FoundryEvmNetwork, executors::ExecutorBuilder};
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11use time::{OffsetDateTime, format_description};
12
13/// Rejects a session id that would let `chisel-<id>.json` escape the cache directory when
14/// concatenated into a path (e.g. `../../etc/cron.d/evil`, which yields the literal path
15/// component `chisel-..`, followed by a real `..` component once the id itself contains a `/`).
16/// Also rejects `:` to prevent targeting Windows Alternate Data Streams (ADS).
17fn validate_session_id(id: &str) -> Result<()> {
18    if id.is_empty() || id == "." || id == ".." || id.contains(['/', '\\', ':']) {
19        eyre::bail!(
20            "invalid Chisel session id `{id}`: must not be empty, `.`, `..`, or contain a path \
21             separator or `:`"
22        );
23    }
24    Ok(())
25}
26
27/// A Chisel REPL Session
28#[derive(Debug, Serialize, Deserialize)]
29#[serde(bound = "")]
30pub struct ChiselSession<FEN: FoundryEvmNetwork> {
31    /// The `SessionSource` object that houses the REPL session.
32    pub source: SessionSource<FEN>,
33    /// The current session's identifier
34    pub id: Option<String>,
35}
36
37// ChiselSession Common Associated Functions
38impl<FEN: FoundryEvmNetwork> ChiselSession<FEN> {
39    fn deserialize_cached(contents: &str, executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
40        let mut session: Self = serde_json::from_str(contents)?;
41        // A session load must not run project cleanup requested by cached configuration.
42        session.source.config.foundry_config.force = false;
43        session.source.config.executor_builder = executor_builder;
44        Ok(session)
45    }
46
47    /// Create a new `ChiselSession` with a specified `solc` version and configuration.
48    ///
49    /// ### Takes
50    ///
51    /// An instance of [SessionSourceConfig]
52    ///
53    /// ### Returns
54    ///
55    /// A new instance of [ChiselSession]
56    pub fn new(config: SessionSourceConfig<FEN>) -> Result<Self> {
57        // Return initialized ChiselSession with set solc version
58        Ok(Self { source: SessionSource::new(config)?, id: None })
59    }
60
61    /// Render the full source code for the current session.
62    ///
63    /// ### Returns
64    ///
65    /// Returns the full, flattened source code for the current session.
66    ///
67    /// ### Notes
68    ///
69    /// This function will not panic, but will return a blank string if the
70    /// session's [SessionSource] is None.
71    pub fn contract_source(&self) -> String {
72        self.source.to_repl_source()
73    }
74
75    /// Clears the cache directory
76    ///
77    /// ### WARNING
78    ///
79    /// This will delete all sessions from the cache.
80    /// There is no method of recovering these deleted sessions.
81    pub fn clear_cache() -> Result<()> {
82        let cache_dir = Self::cache_dir()?;
83        for entry in std::fs::read_dir(cache_dir)? {
84            let entry = entry?;
85            let path = entry.path();
86            if path.is_dir() {
87                std::fs::remove_dir_all(path)?;
88            } else {
89                std::fs::remove_file(path)?;
90            }
91        }
92        Ok(())
93    }
94
95    /// Removes a cached session if it exists.
96    pub fn remove_cached_session(id: &str) -> Result<()> {
97        validate_session_id(id)?;
98        let cache_file = format!("{}chisel-{id}.json", Self::cache_dir()?);
99        match std::fs::remove_file(cache_file) {
100            Ok(()) => Ok(()),
101            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
102            Err(error) => Err(error.into()),
103        }
104    }
105
106    /// Writes the ChiselSession to a file by serializing it to a JSON string
107    ///
108    /// ### Returns
109    ///
110    /// Returns the path of the new cache file
111    pub fn write(&mut self) -> Result<String> {
112        // Try to create the cache directory
113        let cache_dir = Self::cache_dir()?;
114        std::fs::create_dir_all(&cache_dir)?;
115
116        let cache_file_name = match self.id.as_ref() {
117            Some(id) => {
118                // ID is already set- use the existing cache file.
119                validate_session_id(id)?;
120                format!("{cache_dir}chisel-{id}.json")
121            }
122            None => {
123                // Get the next session cache ID / file
124                let (id, file_name) = Self::next_cached_session()?;
125                // Set the session's ID
126                self.id = Some(id);
127                // Return the new session's cache file name
128                file_name
129            }
130        };
131
132        // Write the current ChiselSession to that file
133        let serialized_contents = serde_json::to_string_pretty(self)?;
134        std::fs::write(&cache_file_name, serialized_contents)?;
135
136        // Return the full cache file path
137        // Ex: /home/user/.foundry/cache/chisel/chisel-0.json
138        Ok(cache_file_name)
139    }
140
141    /// Get the next default session cache file name
142    ///
143    /// ### Returns
144    ///
145    /// Optionally, returns a tuple containing the next cached session's id and file name.
146    ///
147    /// Uses one past the highest numeric ID to avoid collisions after deletion.
148    pub fn next_cached_session() -> Result<(String, String)> {
149        Self::next_cached_session_in(&Self::cache_dir()?)
150    }
151
152    fn next_cached_session_in(cache_dir: &str) -> Result<(String, String)> {
153        let next_id = std::fs::read_dir(cache_dir)?
154            .filter_map(|entry| entry.ok())
155            .filter_map(|entry| {
156                entry
157                    .file_name()
158                    .to_str()?
159                    .strip_prefix("chisel-")?
160                    .strip_suffix(".json")?
161                    .parse::<usize>()
162                    .ok()
163            })
164            .max()
165            .map_or(Some(0), |max| max.checked_add(1))
166            .ok_or_else(|| eyre::eyre!("no unused chisel session id available"))?;
167
168        Ok((format!("{next_id}"), format!("{cache_dir}chisel-{next_id}.json")))
169    }
170
171    /// The Chisel Cache Directory
172    ///
173    /// ### Returns
174    ///
175    /// Optionally, the directory of the chisel cache.
176    pub fn cache_dir() -> Result<String> {
177        let home_dir =
178            dirs::home_dir().ok_or_else(|| eyre::eyre!("Failed to grab home directory"))?;
179        let home_dir_str = home_dir
180            .to_str()
181            .ok_or_else(|| eyre::eyre!("Failed to convert home directory to string"))?;
182        Ok(format!("{home_dir_str}/.foundry/cache/chisel/"))
183    }
184
185    /// Create the cache directory if it does not exist
186    ///
187    /// ### Returns
188    ///
189    /// The unit type if the operation was successful.
190    pub fn create_cache_dir() -> Result<()> {
191        let cache_dir = Self::cache_dir()?;
192        if !Path::new(&cache_dir).exists() {
193            std::fs::create_dir_all(&cache_dir)?;
194        }
195        Ok(())
196    }
197
198    /// Returns a list of all available cached sessions.
199    pub fn get_sessions() -> Result<Vec<(String, String)>> {
200        // Read the cache directory entries
201        let cache_dir = Self::cache_dir()?;
202        let entries = std::fs::read_dir(cache_dir)?;
203
204        // For each entry, get the file name and modified time
205        let mut sessions = Vec::new();
206        for entry in entries {
207            let entry = entry?;
208            let modified_time = entry.metadata()?.modified()?;
209            let file_name = entry.file_name();
210            let file_name = file_name
211                .into_string()
212                .map_err(|e| eyre::eyre!(format!("{}", e.to_string_lossy())))?;
213            sessions.push((
214                OffsetDateTime::from(modified_time).format(&format_description::parse(
215                    "[year]-[month]-[day] [hour]:[minute]:[second]",
216                )?)?,
217                file_name,
218            ));
219        }
220        Ok(sessions)
221    }
222
223    /// Loads a specific ChiselSession from the specified cache file
224    ///
225    /// ### Takes
226    ///
227    /// The ID of the chisel session that you wish to load.
228    ///
229    /// ### Returns
230    ///
231    /// Optionally, an owned instance of the loaded chisel session.
232    pub fn load(id: &str, executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
233        Self::load_from(id, &Self::cache_dir()?, executor_builder)
234    }
235
236    fn load_from(
237        id: &str,
238        cache_dir: &str,
239        executor_builder: ExecutorBuilder<FEN>,
240    ) -> Result<Self> {
241        validate_session_id(id)?;
242        let contents = std::fs::read_to_string(Path::new(&format!("{cache_dir}chisel-{id}.json")))?;
243        let mut session = Self::deserialize_cached(&contents, executor_builder)?;
244        // Use the requested ID even if the cached ID is missing or stale.
245        session.id = Some(id.to_string());
246        Ok(session)
247    }
248
249    /// Gets the most recent chisel session from the cache dir
250    ///
251    /// ### Returns
252    ///
253    /// Optionally, the file name of the most recently modified cached session.
254    pub fn latest_cached_session() -> Result<String> {
255        Self::latest_cached_session_in(&Self::cache_dir()?)
256    }
257
258    fn latest_cached_session_in(cache_dir: &str) -> Result<String> {
259        let mut entries = std::fs::read_dir(cache_dir)?;
260        let mut latest = entries.next().ok_or_else(|| eyre::eyre!("No entries found!"))??;
261        for entry in entries {
262            let entry = entry?;
263            if entry.metadata()?.modified()? > latest.metadata()?.modified()? {
264                latest = entry;
265            }
266        }
267        Ok(latest
268            .path()
269            .to_str()
270            .ok_or_else(|| eyre::eyre!("Failed to get session path!"))?
271            .to_string())
272    }
273
274    /// Loads the latest ChiselSession from the cache file
275    ///
276    /// ### Returns
277    ///
278    /// Optionally, an owned instance of the most recently modified cached session.
279    pub fn latest(executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
280        Self::latest_from(&Self::cache_dir()?, executor_builder)
281    }
282
283    fn latest_from(cache_dir: &str, executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
284        let last_session = Self::latest_cached_session_in(cache_dir)?;
285        let last_session_contents = std::fs::read_to_string(Path::new(&last_session))?;
286        let mut session = Self::deserialize_cached(&last_session_contents, executor_builder)?;
287        // Bind the session to the file that was loaded.
288        session.id = Self::session_id_from_cache_file_name(&last_session);
289        Ok(session)
290    }
291
292    /// Extracts the session id from a `.../chisel-<id>.json` cache file path.
293    fn session_id_from_cache_file_name(path: &str) -> Option<String> {
294        Path::new(path).file_stem()?.to_str()?.strip_prefix("chisel-").map(str::to_string)
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use foundry_config::{Config, SolcReq};
302    use foundry_evm::core::evm::EthEvmNetwork;
303    use semver::Version;
304
305    #[cfg(feature = "monad")]
306    use foundry_evm::core::{constants::MONAD_CHEATCODE_ADDRESS, evm::MonadEvmNetwork};
307
308    /// Deleted sessions must not cause the next ID to collide with an existing file.
309    #[test]
310    fn next_cached_session_skips_gaps_left_by_deleted_sessions() {
311        let dir = tempfile::tempdir().unwrap();
312        let cache_dir = format!("{}/", dir.path().to_str().unwrap());
313
314        // Sessions 0 and 2 exist; session 1 was deleted or renamed away, leaving a gap.
315        std::fs::write(format!("{cache_dir}chisel-0.json"), "{\"id\":\"0\"}").unwrap();
316        std::fs::write(format!("{cache_dir}chisel-2.json"), "{\"id\":\"2\"}").unwrap();
317
318        let (next_id, next_file) =
319            ChiselSession::<EthEvmNetwork>::next_cached_session_in(&cache_dir).unwrap();
320
321        // Counting entries would select the occupied ID 2.
322        assert_eq!(next_id, "3", "must skip past the gap instead of reusing the occupied id 2");
323        assert_eq!(next_file, format!("{cache_dir}chisel-3.json"));
324
325        assert_eq!(
326            std::fs::read_to_string(format!("{cache_dir}chisel-0.json")).unwrap(),
327            "{\"id\":\"0\"}"
328        );
329        assert_eq!(
330            std::fs::read_to_string(format!("{cache_dir}chisel-2.json")).unwrap(),
331            "{\"id\":\"2\"}"
332        );
333    }
334
335    #[test]
336    fn next_cached_session_does_not_overflow_on_a_usize_max_named_session() {
337        let dir = tempfile::tempdir().unwrap();
338        let cache_dir = format!("{}/", dir.path().to_str().unwrap());
339        std::fs::write(format!("{cache_dir}chisel-{}.json", usize::MAX), "{}").unwrap();
340
341        let result = ChiselSession::<EthEvmNetwork>::next_cached_session_in(&cache_dir);
342        assert!(result.is_err(), "must error instead of panicking or wrapping to a reused id");
343    }
344
345    #[test]
346    fn deserialized_sessions_do_not_restore_force() {
347        let session = ChiselSession::<EthEvmNetwork>::new(SessionSourceConfig {
348            foundry_config: Config {
349                force: true,
350                solc: Some(SolcReq::Version(Version::new(0, 8, 29))),
351                ..Default::default()
352            },
353            no_vm: true,
354            ..Default::default()
355        })
356        .unwrap();
357        assert!(session.source.config.foundry_config.force);
358
359        let serialized = serde_json::to_string(&session).unwrap();
360        let session = ChiselSession::<EthEvmNetwork>::deserialize_cached(
361            &serialized,
362            ExecutorBuilder::<EthEvmNetwork>::new(),
363        )
364        .unwrap();
365
366        assert!(!session.source.config.foundry_config.force);
367    }
368
369    #[cfg(feature = "monad")]
370    #[test]
371    fn deserialized_sessions_use_active_monad_tooling() {
372        let session = ChiselSession::<MonadEvmNetwork>::new(SessionSourceConfig {
373            executor_builder: ExecutorBuilder::<MonadEvmNetwork>::new(),
374            ..Default::default()
375        })
376        .unwrap();
377        let serialized = serde_json::to_string(&session).unwrap();
378
379        let session = ChiselSession::<MonadEvmNetwork>::deserialize_cached(
380            &serialized,
381            ExecutorBuilder::<MonadEvmNetwork>::new(),
382        )
383        .unwrap();
384
385        assert_eq!(
386            session.source.config.executor_builder.extra_cheatcode_addresses(),
387            &[MONAD_CHEATCODE_ADDRESS]
388        );
389    }
390
391    /// A session id containing a path separator lets `chisel-<id>.json` escape the cache
392    /// directory once resolved: `chisel-x/../../../foo.json` has real `..` path components
393    /// after the `x` segment, walking back out past the cache directory entirely.
394    /// Also verifies that `:` is rejected to prevent targeting NTFS Alternate Data Streams (ADS).
395    #[test]
396    fn path_traversal_ids_are_rejected() {
397        for id in [
398            "../evil",
399            "x/../../../../../../tmp/pwned",
400            "..",
401            ".",
402            "",
403            "sub/dir",
404            "back\\slash",
405            ":colon",
406            "foo:bar",
407            "session:1",
408        ] {
409            let err = validate_session_id(id).unwrap_err();
410            assert!(err.to_string().contains("invalid Chisel session id"), "{id:?}: {err}");
411        }
412
413        // ordinary numeric and name-like ids remain accepted
414        for id in ["0", "42", "my-session", "my_session"] {
415            validate_session_id(id).unwrap();
416        }
417    }
418
419    #[test]
420    fn load_rejects_path_traversal_id() {
421        let err = ChiselSession::<EthEvmNetwork>::load(
422            "../../evil",
423            ExecutorBuilder::<EthEvmNetwork>::new(),
424        )
425        .unwrap_err();
426        assert!(err.to_string().contains("invalid Chisel session id"), "{err}");
427    }
428
429    #[test]
430    fn remove_cached_session_rejects_path_traversal_id() {
431        let err = ChiselSession::<EthEvmNetwork>::remove_cached_session("../../evil").unwrap_err();
432        assert!(err.to_string().contains("invalid Chisel session id"), "{err}");
433    }
434
435    fn session_for_normalization_tests() -> ChiselSession<EthEvmNetwork> {
436        ChiselSession::<EthEvmNetwork>::new(SessionSourceConfig {
437            foundry_config: Config {
438                solc: Some(SolcReq::Version(Version::new(0, 8, 29))),
439                ..Default::default()
440            },
441            no_vm: true,
442            ..Default::default()
443        })
444        .unwrap()
445    }
446
447    /// Loading uses the filename rather than a stale or missing cached ID.
448    #[test]
449    fn load_normalizes_id_ignoring_a_stale_or_missing_embedded_id() {
450        let dir = tempfile::tempdir().unwrap();
451        let cache_dir = format!("{}/", dir.path().to_str().unwrap());
452
453        let mut session = session_for_normalization_tests();
454        session.id = Some("stale-name".to_string());
455        let serialized = serde_json::to_string(&session).unwrap();
456        std::fs::write(format!("{cache_dir}chisel-5.json"), &serialized).unwrap();
457
458        let loaded = ChiselSession::<EthEvmNetwork>::load_from(
459            "5",
460            &cache_dir,
461            ExecutorBuilder::<EthEvmNetwork>::new(),
462        )
463        .unwrap();
464        assert_eq!(loaded.id.as_deref(), Some("5"), "must use the requested id, not the stale one");
465
466        let without_id = serialized.replacen("\"stale-name\"", "null", 1);
467        std::fs::write(format!("{cache_dir}chisel-7.json"), without_id).unwrap();
468        let loaded = ChiselSession::<EthEvmNetwork>::load_from(
469            "7",
470            &cache_dir,
471            ExecutorBuilder::<EthEvmNetwork>::new(),
472        )
473        .unwrap();
474        assert_eq!(loaded.id.as_deref(), Some("7"), "a null embedded id must not survive the load");
475    }
476
477    #[test]
478    fn latest_normalizes_id_from_the_resolved_file_name() {
479        let dir = tempfile::tempdir().unwrap();
480        let cache_dir = format!("{}/", dir.path().to_str().unwrap());
481
482        let session = session_for_normalization_tests();
483        // New sessions serialize with a null ID.
484        let serialized = serde_json::to_string(&session).unwrap();
485        std::fs::write(format!("{cache_dir}chisel-9.json"), serialized).unwrap();
486
487        let loaded = ChiselSession::<EthEvmNetwork>::latest_from(
488            &cache_dir,
489            ExecutorBuilder::<EthEvmNetwork>::new(),
490        )
491        .unwrap();
492        assert_eq!(loaded.id.as_deref(), Some("9"));
493    }
494
495    #[test]
496    fn session_id_from_cache_file_name_strips_prefix_and_extension() {
497        assert_eq!(
498            ChiselSession::<EthEvmNetwork>::session_id_from_cache_file_name(
499                "/home/user/.foundry/cache/chisel/chisel-42.json"
500            ),
501            Some("42".to_string())
502        );
503        assert_eq!(
504            ChiselSession::<EthEvmNetwork>::session_id_from_cache_file_name(
505                "/home/user/.foundry/cache/chisel/not-a-session-file.json"
506            ),
507            None
508        );
509    }
510
511    #[test]
512    fn write_rejects_path_traversal_id() {
513        let mut session = ChiselSession::<EthEvmNetwork>::new(SessionSourceConfig {
514            foundry_config: Config {
515                solc: Some(SolcReq::Version(Version::new(0, 8, 29))),
516                ..Default::default()
517            },
518            no_vm: true,
519            ..Default::default()
520        })
521        .unwrap();
522        session.id = Some("../../evil".to_string());
523
524        let err = session.write().unwrap_err();
525        assert!(err.to_string().contains("invalid Chisel session id"), "{err}");
526    }
527}