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_cli::utils::parse_json;
9use foundry_evm::{core::evm::FoundryEvmNetwork, executors::ExecutorBuilder};
10use serde::{Deserialize, Serialize};
11use std::{
12    fs::{self, File},
13    io::{Read, Write},
14    path::{Path, PathBuf},
15};
16use time::{OffsetDateTime, format_description};
17
18#[cfg(unix)]
19use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
20
21/// Rejects a session id that would let `chisel-<id>.json` escape the cache directory when
22/// concatenated into a path (e.g. `../../etc/cron.d/evil`, which yields the literal path
23/// component `chisel-..`, followed by a real `..` component once the id itself contains a `/`).
24/// Also rejects `:` to prevent targeting Windows Alternate Data Streams (ADS).
25fn validate_session_id(id: &str) -> Result<()> {
26    if id.is_empty() || id == "." || id == ".." || id.contains(['/', '\\', ':']) {
27        eyre::bail!(
28            "invalid Chisel session id `{id}`: must not be empty, `.`, `..`, or contain a path \
29             separator or `:`"
30        );
31    }
32    Ok(())
33}
34
35/// A Chisel REPL Session
36#[derive(Debug, Serialize, Deserialize)]
37#[serde(bound = "")]
38pub struct ChiselSession<FEN: FoundryEvmNetwork> {
39    /// The `SessionSource` object that houses the REPL session.
40    pub source: SessionSource<FEN>,
41    /// The current session's identifier
42    pub id: Option<String>,
43}
44
45// ChiselSession Common Associated Functions
46impl<FEN: FoundryEvmNetwork> ChiselSession<FEN> {
47    fn deserialize_cached(contents: &str, executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
48        let mut session: Self = parse_json(contents)?;
49        session.source.config.clear_credentials();
50        // A session load must not run project cleanup requested by cached configuration.
51        session.source.config.foundry_config.force = false;
52        session.source.config.executor_builder = executor_builder;
53        Ok(session)
54    }
55
56    /// Create a new `ChiselSession` with a specified `solc` version and configuration.
57    ///
58    /// ### Takes
59    ///
60    /// An instance of [SessionSourceConfig]
61    ///
62    /// ### Returns
63    ///
64    /// A new instance of [ChiselSession]
65    pub fn new(config: SessionSourceConfig<FEN>) -> Result<Self> {
66        // Return initialized ChiselSession with set solc version
67        Ok(Self { source: SessionSource::new(config)?, id: None })
68    }
69
70    /// Render the full source code for the current session.
71    ///
72    /// ### Returns
73    ///
74    /// Returns the full, flattened source code for the current session.
75    ///
76    /// ### Notes
77    ///
78    /// This function will not panic, but will return a blank string if the
79    /// session's [SessionSource] is None.
80    pub fn contract_source(&self) -> String {
81        self.source.to_repl_source()
82    }
83
84    /// Clears the cache directory
85    ///
86    /// ### WARNING
87    ///
88    /// This will delete all sessions from the cache.
89    /// There is no method of recovering these deleted sessions.
90    pub fn clear_cache() -> Result<()> {
91        let cache_dir = Self::cache_dir()?;
92        for entry in std::fs::read_dir(cache_dir)? {
93            let entry = entry?;
94            let path = entry.path();
95            if path.is_dir() {
96                std::fs::remove_dir_all(path)?;
97            } else {
98                std::fs::remove_file(path)?;
99            }
100        }
101        Ok(())
102    }
103
104    /// Removes a cached session if it exists.
105    pub fn remove_cached_session(id: &str) -> Result<()> {
106        validate_session_id(id)?;
107        let cache_file = format!("{}chisel-{id}.json", Self::cache_dir()?);
108        match std::fs::remove_file(cache_file) {
109            Ok(()) => Ok(()),
110            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
111            Err(error) => Err(error.into()),
112        }
113    }
114
115    /// Writes the ChiselSession to a file by serializing it to a JSON string
116    ///
117    /// ### Returns
118    ///
119    /// Returns the path of the new cache file
120    pub fn write(&mut self) -> Result<String> {
121        self.write_to(&Self::cache_dir()?)
122    }
123
124    fn write_to(&mut self, cache_dir: &str) -> Result<String> {
125        if let Some(id) = &self.id {
126            validate_session_id(id)?;
127        }
128        Self::secure_cache_dir(cache_dir)?;
129
130        // ID is already set- replace the existing cache file.
131        if let Some(id) = &self.id {
132            let cache_file_name = format!("{cache_dir}chisel-{id}.json");
133            self.write_temp_file(cache_dir)?.persist(&cache_file_name).map_err(|err| err.error)?;
134            return Ok(cache_file_name);
135        }
136
137        let id = Self::next_cached_session_in(cache_dir)?;
138        self.write_new_from(cache_dir, id)
139    }
140
141    /// Saves the session under the first unused ID from `id` onward, without replacing a session
142    /// that another process saved after the ID was chosen.
143    ///
144    /// On failure the ID is cleared, so a later save cannot replace a file this session never
145    /// claimed.
146    fn write_new_from(&mut self, cache_dir: &str, mut id: usize) -> Result<String> {
147        let result = loop {
148            self.id = Some(id.to_string());
149            let cache_file_name = format!("{cache_dir}chisel-{id}.json");
150            let file = match self.write_temp_file(cache_dir) {
151                Ok(file) => file,
152                Err(err) => break Err(err),
153            };
154            match file.persist_noclobber(&cache_file_name) {
155                Ok(_) => break Ok(cache_file_name),
156                Err(err) if err.error.kind() == std::io::ErrorKind::AlreadyExists => {
157                    let Some(next) = id.checked_add(1) else {
158                        break Err(eyre::eyre!("no unused chisel session id available"));
159                    };
160                    id = next;
161                }
162                Err(err) => {
163                    break Err(eyre::Error::new(err.error)
164                        .wrap_err(format!("failed to create new session file {cache_file_name}")));
165                }
166            }
167        };
168        if result.is_err() {
169            self.id = None;
170        }
171        result
172    }
173
174    /// Serializes the session into a private temporary file in the cache directory.
175    ///
176    /// The temporary file is private from creation, and persisting it does not follow a
177    /// destination symlink or retain the permissions of an older session.
178    fn write_temp_file(&self, cache_dir: &str) -> Result<tempfile::NamedTempFile> {
179        let mut file = tempfile::NamedTempFile::new_in(cache_dir)?;
180        #[cfg(unix)]
181        file.as_file().set_permissions(fs::Permissions::from_mode(0o600))?;
182        serde_json::to_writer_pretty(&mut file, self)?;
183        file.flush()?;
184        file.as_file().sync_all()?;
185        Ok(file)
186    }
187
188    /// Get the next default session cache file name
189    ///
190    /// ### Returns
191    ///
192    /// Optionally, returns a tuple containing the next cached session's id and file name.
193    ///
194    /// Uses one past the highest numeric ID to avoid collisions after deletion.
195    pub fn next_cached_session() -> Result<(String, String)> {
196        let cache_dir = Self::cache_dir()?;
197        let id = Self::next_cached_session_in(&cache_dir)?;
198        Ok((id.to_string(), format!("{cache_dir}chisel-{id}.json")))
199    }
200
201    /// Returns one past the highest numeric session ID in the cache directory.
202    fn next_cached_session_in(cache_dir: &str) -> Result<usize> {
203        std::fs::read_dir(cache_dir)?
204            .filter_map(|entry| entry.ok())
205            .filter_map(|entry| {
206                entry
207                    .file_name()
208                    .to_str()?
209                    .strip_prefix("chisel-")?
210                    .strip_suffix(".json")?
211                    .parse::<usize>()
212                    .ok()
213            })
214            .max()
215            .map_or(Some(0), |max| max.checked_add(1))
216            .ok_or_else(|| eyre::eyre!("no unused chisel session id available"))
217    }
218
219    /// The Chisel Cache Directory
220    ///
221    /// ### Returns
222    ///
223    /// Optionally, the directory of the chisel cache.
224    pub fn cache_dir() -> Result<String> {
225        let home_dir =
226            dirs::home_dir().ok_or_else(|| eyre::eyre!("Failed to grab home directory"))?;
227        let home_dir_str = home_dir
228            .to_str()
229            .ok_or_else(|| eyre::eyre!("Failed to convert home directory to string"))?;
230        Ok(format!("{home_dir_str}/.foundry/cache/chisel/"))
231    }
232
233    /// Create the cache directory if it does not exist
234    ///
235    /// ### Returns
236    ///
237    /// The unit type if the operation was successful.
238    pub fn create_cache_dir() -> Result<()> {
239        Self::secure_cache_dir(&Self::cache_dir()?)
240    }
241
242    /// Returns a list of all available cached sessions.
243    pub fn get_sessions() -> Result<Vec<(String, String)>> {
244        // Read the cache directory entries
245        let cache_dir = Self::cache_dir()?;
246        let entries = Self::cached_session_files(&cache_dir)?;
247
248        // For each entry, get the file name and modified time
249        let mut sessions = Vec::new();
250        for entry in entries {
251            let modified_time = entry.metadata()?.modified()?;
252            let file_name = entry.file_name();
253            let file_name = file_name
254                .into_string()
255                .map_err(|e| eyre::eyre!(format!("{}", e.to_string_lossy())))?;
256            sessions.push((
257                OffsetDateTime::from(modified_time).format(&format_description::parse(
258                    "[year]-[month]-[day] [hour]:[minute]:[second]",
259                )?)?,
260                file_name,
261            ));
262        }
263        Ok(sessions)
264    }
265
266    /// Loads a specific ChiselSession from the specified cache file
267    ///
268    /// ### Takes
269    ///
270    /// The ID of the chisel session that you wish to load.
271    ///
272    /// ### Returns
273    ///
274    /// Optionally, an owned instance of the loaded chisel session.
275    pub fn load(id: &str, executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
276        Self::load_from(id, &Self::cache_dir()?, executor_builder)
277    }
278
279    fn load_from(
280        id: &str,
281        cache_dir: &str,
282        executor_builder: ExecutorBuilder<FEN>,
283    ) -> Result<Self> {
284        validate_session_id(id)?;
285        Self::secure_cache_dir(cache_dir)?;
286        let contents = Self::read_cached_file(Path::new(&format!("{cache_dir}chisel-{id}.json")))?;
287        let mut session = Self::deserialize_cached(&contents, executor_builder)?;
288        // Use the requested ID even if the cached ID is missing or stale.
289        session.id = Some(id.to_string());
290        Ok(session)
291    }
292
293    /// Gets the most recent chisel session from the cache dir
294    ///
295    /// ### Returns
296    ///
297    /// Optionally, the file name of the most recently modified cached session.
298    pub fn latest_cached_session() -> Result<String> {
299        Self::latest_cached_session_in(&Self::cache_dir()?)
300    }
301
302    fn latest_cached_session_in(cache_dir: &str) -> Result<String> {
303        let mut entries = Self::cached_session_files(cache_dir)?.into_iter();
304        let mut latest = entries.next().ok_or_else(|| eyre::eyre!("No entries found!"))?;
305        for entry in entries {
306            if entry.metadata()?.modified()? > latest.metadata()?.modified()? {
307                latest = entry;
308            }
309        }
310        Ok(latest
311            .path()
312            .to_str()
313            .ok_or_else(|| eyre::eyre!("Failed to get session path!"))?
314            .to_string())
315    }
316
317    /// Loads the latest ChiselSession from the cache file
318    ///
319    /// ### Returns
320    ///
321    /// Optionally, an owned instance of the most recently modified cached session.
322    pub fn latest(executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
323        Self::latest_from(&Self::cache_dir()?, executor_builder)
324    }
325
326    fn latest_from(cache_dir: &str, executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
327        let last_session = Self::latest_cached_session_in(cache_dir)?;
328        let last_session_contents = Self::read_cached_file(Path::new(&last_session))?;
329        let mut session = Self::deserialize_cached(&last_session_contents, executor_builder)?;
330        // Bind the session to the file that was loaded.
331        session.id = Self::session_id_from_cache_file_name(&last_session);
332        Ok(session)
333    }
334
335    /// Extracts the session id from a `.../chisel-<id>.json` cache file path.
336    fn session_id_from_cache_file_name(path: &str) -> Option<String> {
337        Path::new(path).file_stem()?.to_str()?.strip_prefix("chisel-").map(str::to_string)
338    }
339
340    /// Protects private source and credentials in both new and legacy cache directories.
341    fn secure_cache_dir(cache_dir: &str) -> Result<()> {
342        // Remove trailing separators so symlink_metadata inspects the directory entry itself.
343        let path = Path::new(cache_dir).components().collect::<PathBuf>();
344        let mut builder = fs::DirBuilder::new();
345        builder.recursive(true);
346        #[cfg(unix)]
347        builder.mode(0o700);
348        builder.create(&path)?;
349        eyre::ensure!(
350            fs::symlink_metadata(&path)?.is_dir(),
351            "Chisel cache must be a directory, not a symlink"
352        );
353        #[cfg(unix)]
354        fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?;
355        Ok(())
356    }
357
358    /// Reads a regular session file after restricting legacy file permissions.
359    fn read_cached_file(path: &Path) -> Result<String> {
360        eyre::ensure!(
361            fs::symlink_metadata(path)?.is_file(),
362            "Chisel session must be a regular file"
363        );
364        let mut file = File::open(path)?;
365        eyre::ensure!(file.metadata()?.is_file(), "Chisel session must be a regular file");
366        #[cfg(unix)]
367        file.set_permissions(fs::Permissions::from_mode(0o600))?;
368        let mut contents = String::new();
369        file.read_to_string(&mut contents)?;
370        Ok(contents)
371    }
372
373    /// Excludes temporary saves, unrelated files, and symlinks from session discovery.
374    fn cached_session_files(cache_dir: &str) -> Result<Vec<fs::DirEntry>> {
375        Self::secure_cache_dir(cache_dir)?;
376        let mut sessions = Vec::new();
377        for entry in fs::read_dir(cache_dir)? {
378            let entry = entry?;
379            if entry.file_type()?.is_file()
380                && let Some(name) = entry.file_name().to_str()
381                && let Some(id) =
382                    name.strip_prefix("chisel-").and_then(|name| name.strip_suffix(".json"))
383                && validate_session_id(id).is_ok()
384            {
385                sessions.push(entry);
386            }
387        }
388        Ok(sessions)
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use foundry_config::{Config, SolcReq};
396    use foundry_evm::core::evm::EthEvmNetwork;
397    use semver::Version;
398
399    #[cfg(unix)]
400    use std::os::unix::fs::symlink;
401
402    #[cfg(feature = "monad")]
403    use foundry_evm::core::{constants::MONAD_CHEATCODE_ADDRESS, evm::MonadEvmNetwork};
404
405    /// Deleted sessions must not cause the next ID to collide with an existing file.
406    #[test]
407    fn next_cached_session_skips_gaps_left_by_deleted_sessions() {
408        let dir = tempfile::tempdir().unwrap();
409        let cache_dir = format!("{}/", dir.path().to_str().unwrap());
410
411        // Sessions 0 and 2 exist; session 1 was deleted or renamed away, leaving a gap.
412        std::fs::write(format!("{cache_dir}chisel-0.json"), "{\"id\":\"0\"}").unwrap();
413        std::fs::write(format!("{cache_dir}chisel-2.json"), "{\"id\":\"2\"}").unwrap();
414
415        let next_id = ChiselSession::<EthEvmNetwork>::next_cached_session_in(&cache_dir).unwrap();
416
417        // Counting entries would select the occupied ID 2.
418        assert_eq!(next_id, 3, "must skip past the gap instead of reusing the occupied id 2");
419
420        assert_eq!(
421            std::fs::read_to_string(format!("{cache_dir}chisel-0.json")).unwrap(),
422            "{\"id\":\"0\"}"
423        );
424        assert_eq!(
425            std::fs::read_to_string(format!("{cache_dir}chisel-2.json")).unwrap(),
426            "{\"id\":\"2\"}"
427        );
428    }
429
430    #[test]
431    fn next_cached_session_does_not_overflow_on_a_usize_max_named_session() {
432        let dir = tempfile::tempdir().unwrap();
433        let cache_dir = format!("{}/", dir.path().to_str().unwrap());
434        std::fs::write(format!("{cache_dir}chisel-{}.json", usize::MAX), "{}").unwrap();
435
436        let result = ChiselSession::<EthEvmNetwork>::next_cached_session_in(&cache_dir);
437        assert!(result.is_err(), "must error instead of panicking or wrapping to a reused id");
438    }
439
440    /// An automatic save must skip an ID that another process claimed after the scan instead of
441    /// replacing its session, and must not keep an ID it failed to claim.
442    #[test]
443    fn automatic_save_skips_ids_claimed_after_the_scan() {
444        let dir = tempfile::tempdir().unwrap();
445        let cache_dir = format!("{}/", dir.path().to_str().unwrap());
446        let mut session = session_for_normalization_tests();
447
448        let other = format!("{cache_dir}chisel-0.json");
449        std::fs::write(&other, "other").unwrap();
450        let file = session.write_new_from(&cache_dir, 0).unwrap();
451        assert_eq!(file, format!("{cache_dir}chisel-1.json"));
452        assert_eq!(session.id.as_deref(), Some("1"));
453        let saved: ChiselSession<EthEvmNetwork> =
454            serde_json::from_str(&std::fs::read_to_string(file).unwrap()).unwrap();
455        assert_eq!(saved.id.as_deref(), Some("1"));
456        assert_eq!(std::fs::read_to_string(&other).unwrap(), "other");
457
458        let last = format!("{cache_dir}chisel-{}.json", usize::MAX);
459        std::fs::write(&last, "other").unwrap();
460        session.id = None;
461        assert!(session.write_new_from(&cache_dir, usize::MAX).is_err());
462        assert_eq!(session.id, None, "a failed save must not keep an unclaimed ID");
463        assert_eq!(std::fs::read_to_string(&last).unwrap(), "other");
464    }
465
466    #[test]
467    fn deserialized_sessions_do_not_restore_force() {
468        let session = ChiselSession::<EthEvmNetwork>::new(SessionSourceConfig {
469            foundry_config: Config {
470                force: true,
471                solc: Some(SolcReq::Version(Version::new(0, 8, 29))),
472                ..Default::default()
473            },
474            no_vm: true,
475            ..Default::default()
476        })
477        .unwrap();
478        assert!(session.source.config.foundry_config.force);
479
480        let serialized = serde_json::to_string(&session).unwrap();
481        let session = ChiselSession::<EthEvmNetwork>::deserialize_cached(
482            &serialized,
483            ExecutorBuilder::<EthEvmNetwork>::new(),
484        )
485        .unwrap();
486
487        assert!(!session.source.config.foundry_config.force);
488    }
489
490    #[cfg(feature = "monad")]
491    #[test]
492    fn deserialized_sessions_use_active_monad_tooling() {
493        let session = ChiselSession::<MonadEvmNetwork>::new(SessionSourceConfig {
494            executor_builder: ExecutorBuilder::<MonadEvmNetwork>::new(),
495            ..Default::default()
496        })
497        .unwrap();
498        let serialized = serde_json::to_string(&session).unwrap();
499
500        let session = ChiselSession::<MonadEvmNetwork>::deserialize_cached(
501            &serialized,
502            ExecutorBuilder::<MonadEvmNetwork>::new(),
503        )
504        .unwrap();
505
506        assert_eq!(
507            session.source.config.executor_builder.extra_cheatcode_addresses(),
508            &[MONAD_CHEATCODE_ADDRESS]
509        );
510    }
511
512    /// A session id containing a path separator lets `chisel-<id>.json` escape the cache
513    /// directory once resolved: `chisel-x/../../../foo.json` has real `..` path components
514    /// after the `x` segment, walking back out past the cache directory entirely.
515    /// Also verifies that `:` is rejected to prevent targeting NTFS Alternate Data Streams (ADS).
516    #[test]
517    fn path_traversal_ids_are_rejected() {
518        for id in [
519            "../evil",
520            "x/../../../../../../tmp/pwned",
521            "..",
522            ".",
523            "",
524            "sub/dir",
525            "back\\slash",
526            ":colon",
527            "foo:bar",
528            "session:1",
529        ] {
530            let err = validate_session_id(id).unwrap_err();
531            assert!(err.to_string().contains("invalid Chisel session id"), "{id:?}: {err}");
532        }
533
534        // ordinary numeric and name-like ids remain accepted
535        for id in ["0", "42", "my-session", "my_session"] {
536            validate_session_id(id).unwrap();
537        }
538    }
539
540    #[test]
541    fn load_rejects_path_traversal_id() {
542        let err = ChiselSession::<EthEvmNetwork>::load(
543            "../../evil",
544            ExecutorBuilder::<EthEvmNetwork>::new(),
545        )
546        .unwrap_err();
547        assert!(err.to_string().contains("invalid Chisel session id"), "{err}");
548    }
549
550    #[test]
551    fn remove_cached_session_rejects_path_traversal_id() {
552        let err = ChiselSession::<EthEvmNetwork>::remove_cached_session("../../evil").unwrap_err();
553        assert!(err.to_string().contains("invalid Chisel session id"), "{err}");
554    }
555
556    fn session_for_normalization_tests() -> ChiselSession<EthEvmNetwork> {
557        ChiselSession::<EthEvmNetwork>::new(SessionSourceConfig {
558            foundry_config: Config {
559                solc: Some(SolcReq::Version(Version::new(0, 8, 29))),
560                ..Default::default()
561            },
562            no_vm: true,
563            ..Default::default()
564        })
565        .unwrap()
566    }
567
568    /// Loading uses the filename rather than a stale or missing cached ID.
569    #[test]
570    fn load_normalizes_id_ignoring_a_stale_or_missing_embedded_id() {
571        let dir = tempfile::tempdir().unwrap();
572        let cache_dir = format!("{}/", dir.path().to_str().unwrap());
573
574        let mut session = session_for_normalization_tests();
575        session.id = Some("stale-name".to_string());
576        let serialized = serde_json::to_string(&session).unwrap();
577        std::fs::write(format!("{cache_dir}chisel-5.json"), &serialized).unwrap();
578
579        let loaded = ChiselSession::<EthEvmNetwork>::load_from(
580            "5",
581            &cache_dir,
582            ExecutorBuilder::<EthEvmNetwork>::new(),
583        )
584        .unwrap();
585        assert_eq!(loaded.id.as_deref(), Some("5"), "must use the requested id, not the stale one");
586
587        let without_id = serialized.replacen("\"stale-name\"", "null", 1);
588        std::fs::write(format!("{cache_dir}chisel-7.json"), without_id).unwrap();
589        let loaded = ChiselSession::<EthEvmNetwork>::load_from(
590            "7",
591            &cache_dir,
592            ExecutorBuilder::<EthEvmNetwork>::new(),
593        )
594        .unwrap();
595        assert_eq!(loaded.id.as_deref(), Some("7"), "a null embedded id must not survive the load");
596    }
597
598    #[test]
599    fn latest_normalizes_id_from_the_resolved_file_name() {
600        let dir = tempfile::tempdir().unwrap();
601        let cache_dir = format!("{}/", dir.path().to_str().unwrap());
602
603        let session = session_for_normalization_tests();
604        // New sessions serialize with a null ID.
605        let serialized = serde_json::to_string(&session).unwrap();
606        std::fs::write(format!("{cache_dir}chisel-9.json"), serialized).unwrap();
607
608        let loaded = ChiselSession::<EthEvmNetwork>::latest_from(
609            &cache_dir,
610            ExecutorBuilder::<EthEvmNetwork>::new(),
611        )
612        .unwrap();
613        assert_eq!(loaded.id.as_deref(), Some("9"));
614    }
615
616    #[test]
617    fn session_id_from_cache_file_name_strips_prefix_and_extension() {
618        assert_eq!(
619            ChiselSession::<EthEvmNetwork>::session_id_from_cache_file_name(
620                "/home/user/.foundry/cache/chisel/chisel-42.json"
621            ),
622            Some("42".to_string())
623        );
624        assert_eq!(
625            ChiselSession::<EthEvmNetwork>::session_id_from_cache_file_name(
626                "/home/user/.foundry/cache/chisel/not-a-session-file.json"
627            ),
628            None
629        );
630    }
631
632    #[test]
633    fn write_rejects_path_traversal_id() {
634        let mut session = ChiselSession::<EthEvmNetwork>::new(SessionSourceConfig {
635            foundry_config: Config {
636                solc: Some(SolcReq::Version(Version::new(0, 8, 29))),
637                ..Default::default()
638            },
639            no_vm: true,
640            ..Default::default()
641        })
642        .unwrap();
643        session.id = Some("../../evil".to_string());
644
645        let err = session.write().unwrap_err();
646        assert!(err.to_string().contains("invalid Chisel session id"), "{err}");
647    }
648
649    #[cfg(unix)]
650    #[test]
651    fn saved_sessions_are_private() {
652        let dir = tempfile::tempdir().unwrap();
653        let cache = dir.path().join("chisel");
654        std::fs::create_dir(&cache).unwrap();
655        std::fs::set_permissions(&cache, std::fs::Permissions::from_mode(0o755)).unwrap();
656        let file = cache.join("chisel-private.json");
657        std::fs::write(&file, "old session").unwrap();
658        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
659        let mut session = session_for_normalization_tests();
660        session.id = Some("private".to_string());
661        session.source.run_code = "uint256 privateValue = 42;".to_string();
662
663        session.write_to(&format!("{}/", cache.display())).unwrap();
664
665        assert_eq!(std::fs::metadata(&cache).unwrap().permissions().mode() & 0o777, 0o700);
666        assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600);
667        let saved: ChiselSession<EthEvmNetwork> =
668            serde_json::from_str(&std::fs::read_to_string(file).unwrap()).unwrap();
669        assert_eq!(saved.source.run_code, session.source.run_code);
670    }
671
672    #[cfg(unix)]
673    #[test]
674    fn new_cache_directories_are_private_without_changing_existing_parents() {
675        let dir = tempfile::tempdir().unwrap();
676        fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
677        let cache = dir.path().join(".foundry/cache/chisel");
678        let mut session = session_for_normalization_tests();
679
680        let file = session.write_to(&format!("{}/", cache.display())).unwrap();
681
682        assert_eq!(fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777, 0o755);
683        for path in [dir.path().join(".foundry"), dir.path().join(".foundry/cache"), cache] {
684            assert_eq!(fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o700);
685        }
686        assert_eq!(fs::metadata(file).unwrap().permissions().mode() & 0o777, 0o600);
687    }
688
689    #[cfg(unix)]
690    #[test]
691    fn saving_replaces_symlinks_without_changing_their_targets() {
692        let dir = tempfile::tempdir().unwrap();
693        let target = dir.path().join("unrelated.json");
694        fs::write(&target, "untouched").unwrap();
695        fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap();
696        let cache = dir.path().join("chisel");
697        fs::create_dir(&cache).unwrap();
698        let destination = cache.join("chisel-linked.json");
699        symlink(&target, &destination).unwrap();
700        let mut session = session_for_normalization_tests();
701        session.id = Some("linked".to_string());
702
703        session.write_to(&format!("{}/", cache.display())).unwrap();
704
705        assert!(fs::symlink_metadata(&destination).unwrap().is_file());
706        assert_eq!(fs::metadata(destination).unwrap().permissions().mode() & 0o777, 0o600);
707        assert_eq!(fs::read_to_string(&target).unwrap(), "untouched");
708        assert_eq!(fs::metadata(target).unwrap().permissions().mode() & 0o777, 0o644);
709    }
710
711    #[cfg(unix)]
712    #[test]
713    fn cache_directory_symlinks_are_rejected_without_changing_targets() {
714        let dir = tempfile::tempdir().unwrap();
715        let target = dir.path().join("unrelated");
716        fs::create_dir(&target).unwrap();
717        fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
718        let cache = dir.path().join("chisel");
719        symlink(&target, &cache).unwrap();
720        let mut session = session_for_normalization_tests();
721
722        let result = session.write_to(&format!("{}/", cache.display()));
723
724        assert_eq!(
725            result.unwrap_err().to_string(),
726            "Chisel cache must be a directory, not a symlink"
727        );
728        assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o755);
729        assert_eq!(fs::read_dir(target).unwrap().count(), 0);
730    }
731
732    #[cfg(unix)]
733    #[test]
734    fn loading_legacy_sessions_restricts_file_and_directory_permissions() {
735        let dir = tempfile::tempdir().unwrap();
736        let cache_dir = format!("{}/", dir.path().display());
737        let path = dir.path().join("chisel-legacy.json");
738        let session = session_for_normalization_tests();
739        fs::write(&path, serde_json::to_vec(&session).unwrap()).unwrap();
740        fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
741        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
742
743        let loaded = ChiselSession::<EthEvmNetwork>::load_from(
744            "legacy",
745            &cache_dir,
746            ExecutorBuilder::<EthEvmNetwork>::new(),
747        )
748        .unwrap();
749
750        assert_eq!(loaded.id.as_deref(), Some("legacy"));
751        assert_eq!(fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777, 0o700);
752        assert_eq!(fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o600);
753    }
754
755    #[cfg(unix)]
756    #[test]
757    fn loading_rejects_symlinks_without_changing_their_targets() {
758        let dir = tempfile::tempdir().unwrap();
759        let target = dir.path().join("unrelated.json");
760        fs::write(&target, "untouched").unwrap();
761        fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap();
762        symlink(&target, dir.path().join("chisel-linked.json")).unwrap();
763
764        let result = ChiselSession::<EthEvmNetwork>::load_from(
765            "linked",
766            &format!("{}/", dir.path().display()),
767            ExecutorBuilder::<EthEvmNetwork>::new(),
768        );
769
770        assert_eq!(result.unwrap_err().to_string(), "Chisel session must be a regular file");
771        assert_eq!(fs::read_to_string(&target).unwrap(), "untouched");
772        assert_eq!(fs::metadata(target).unwrap().permissions().mode() & 0o777, 0o644);
773    }
774
775    #[test]
776    fn session_discovery_ignores_incomplete_and_unrelated_files() {
777        let dir = tempfile::tempdir().unwrap();
778        let cache_dir = format!("{}/", dir.path().display());
779        let mut session = session_for_normalization_tests();
780        session.id = Some("saved".to_string());
781        let saved = session.write_to(&cache_dir).unwrap();
782        fs::write(dir.path().join(".tmp-incomplete"), "{").unwrap();
783        fs::write(dir.path().join("unrelated.json"), "unrelated").unwrap();
784        fs::write(dir.path().join("chisel-.json"), "invalid").unwrap();
785        fs::create_dir(dir.path().join("chisel-directory.json")).unwrap();
786        #[cfg(unix)]
787        symlink(&saved, dir.path().join("chisel-linked.json")).unwrap();
788
789        let sessions = ChiselSession::<EthEvmNetwork>::cached_session_files(&cache_dir).unwrap();
790
791        assert_eq!(sessions.len(), 1);
792        assert_eq!(sessions[0].path(), Path::new(&saved));
793        assert_eq!(
794            ChiselSession::<EthEvmNetwork>::latest_cached_session_in(&cache_dir).unwrap(),
795            saved
796        );
797        let latest = ChiselSession::<EthEvmNetwork>::latest_from(
798            &cache_dir,
799            ExecutorBuilder::<EthEvmNetwork>::new(),
800        )
801        .unwrap();
802        assert_eq!(latest.id.as_deref(), Some("saved"));
803    }
804
805    #[test]
806    fn failed_save_cleans_up_temporary_files() {
807        let dir = tempfile::tempdir().unwrap();
808        let cache_dir = format!("{}/", dir.path().display());
809        let destination = dir.path().join("chisel-blocked.json");
810        fs::create_dir(&destination).unwrap();
811        fs::write(destination.join("sentinel"), "untouched").unwrap();
812        let mut session = session_for_normalization_tests();
813        session.id = Some("blocked".to_string());
814
815        assert!(session.write_to(&cache_dir).is_err());
816
817        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
818        assert_eq!(fs::read_to_string(destination.join("sentinel")).unwrap(), "untouched");
819    }
820
821    #[test]
822    fn loading_legacy_fork_sessions_discards_cached_credentials() {
823        let dir = tempfile::tempdir().unwrap();
824        let cache_dir = format!("{}/", dir.path().display());
825        let mut session = session_for_normalization_tests();
826        session.source.run_code = "uint256 privateValue = 42;".into();
827        session.source.config.calldata = Some(vec![0xde, 0xad, 0xbe, 0xef]);
828        let mut legacy = serde_json::to_value(&session).unwrap();
829        let config = &mut legacy["source"]["config"];
830        config.as_object_mut().unwrap().remove("fork_url_required");
831        config["foundry_config"]["eth_rpc_url"] = "https://rpc.invalid/legacy-token".into();
832        config["foundry_config"]["eth_rpc_jwt"] = "legacy-jwt".into();
833        config["foundry_config"]["eth_rpc_headers"] =
834            serde_json::json!(["Authorization: legacy-header"]);
835        config["foundry_config"]["etherscan_api_key"] = "legacy-api-key".into();
836        config["foundry_config"]["etherscan"] = serde_json::json!({
837            "mainnet": { "key": "legacy-explorer-key", "chain": 1 }
838        });
839        config["foundry_config"]["rpc_endpoints"] = serde_json::json!({
840            "mainnet": "https://rpc.invalid/legacy-endpoint"
841        });
842        config["evm_opts"]["eth_rpc_url"] = "https://rpc.invalid/legacy-token".into();
843        config["evm_opts"]["eth_rpc_jwt"] = "legacy-jwt".into();
844        config["evm_opts"]["eth_rpc_headers"] = serde_json::json!(["Authorization: legacy-header"]);
845        config["evm_opts"]["fork_headers"] =
846            serde_json::json!(["Authorization: legacy-fork-header"]);
847        let path = dir.path().join("chisel-legacy.json");
848        fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
849
850        for loaded in [
851            ChiselSession::<EthEvmNetwork>::load_from(
852                "legacy",
853                &cache_dir,
854                ExecutorBuilder::<EthEvmNetwork>::new(),
855            )
856            .unwrap(),
857            ChiselSession::<EthEvmNetwork>::latest_from(
858                &cache_dir,
859                ExecutorBuilder::<EthEvmNetwork>::new(),
860            )
861            .unwrap(),
862        ] {
863            let config = &loaded.source.config;
864            assert!(config.fork_url_required);
865            assert_eq!(config.foundry_config.eth_rpc_url, None);
866            assert_eq!(config.foundry_config.eth_rpc_jwt, None);
867            assert_eq!(config.foundry_config.eth_rpc_headers, None);
868            assert_eq!(config.foundry_config.etherscan_api_key, None);
869            assert!(config.foundry_config.etherscan.is_empty());
870            assert!(config.foundry_config.rpc_endpoints.is_empty());
871            assert_eq!(config.evm_opts.fork_url, None);
872            assert_eq!(config.evm_opts.rpc_jwt, None);
873            assert_eq!(config.evm_opts.rpc_headers, None);
874            assert_eq!(config.evm_opts.fork_headers, None);
875            assert_eq!(config.calldata, session.source.config.calldata);
876            assert_eq!(loaded.source.run_code, session.source.run_code);
877        }
878    }
879}