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        let cache_file_name = match self.id.as_ref() {
131            Some(id) => {
132                // ID is already set- use the existing cache file.
133                format!("{cache_dir}chisel-{id}.json")
134            }
135            None => {
136                // Get the next session cache ID / file
137                let (id, file_name) = Self::next_cached_session_in(cache_dir)?;
138                // Set the session's ID
139                self.id = Some(id);
140                // Return the new session's cache file name
141                file_name
142            }
143        };
144
145        // The temporary file is private from creation, and replacement does not follow a
146        // destination symlink or retain the permissions of an older session.
147        let mut file = tempfile::NamedTempFile::new_in(cache_dir)?;
148        #[cfg(unix)]
149        file.as_file().set_permissions(fs::Permissions::from_mode(0o600))?;
150        serde_json::to_writer_pretty(&mut file, self)?;
151        file.flush()?;
152        file.as_file().sync_all()?;
153        file.persist(&cache_file_name).map_err(|err| err.error)?;
154
155        // Return the full cache file path
156        // Ex: /home/user/.foundry/cache/chisel/chisel-0.json
157        Ok(cache_file_name)
158    }
159
160    /// Get the next default session cache file name
161    ///
162    /// ### Returns
163    ///
164    /// Optionally, returns a tuple containing the next cached session's id and file name.
165    ///
166    /// Uses one past the highest numeric ID to avoid collisions after deletion.
167    pub fn next_cached_session() -> Result<(String, String)> {
168        Self::next_cached_session_in(&Self::cache_dir()?)
169    }
170
171    fn next_cached_session_in(cache_dir: &str) -> Result<(String, String)> {
172        let next_id = std::fs::read_dir(cache_dir)?
173            .filter_map(|entry| entry.ok())
174            .filter_map(|entry| {
175                entry
176                    .file_name()
177                    .to_str()?
178                    .strip_prefix("chisel-")?
179                    .strip_suffix(".json")?
180                    .parse::<usize>()
181                    .ok()
182            })
183            .max()
184            .map_or(Some(0), |max| max.checked_add(1))
185            .ok_or_else(|| eyre::eyre!("no unused chisel session id available"))?;
186
187        Ok((format!("{next_id}"), format!("{cache_dir}chisel-{next_id}.json")))
188    }
189
190    /// The Chisel Cache Directory
191    ///
192    /// ### Returns
193    ///
194    /// Optionally, the directory of the chisel cache.
195    pub fn cache_dir() -> Result<String> {
196        let home_dir =
197            dirs::home_dir().ok_or_else(|| eyre::eyre!("Failed to grab home directory"))?;
198        let home_dir_str = home_dir
199            .to_str()
200            .ok_or_else(|| eyre::eyre!("Failed to convert home directory to string"))?;
201        Ok(format!("{home_dir_str}/.foundry/cache/chisel/"))
202    }
203
204    /// Create the cache directory if it does not exist
205    ///
206    /// ### Returns
207    ///
208    /// The unit type if the operation was successful.
209    pub fn create_cache_dir() -> Result<()> {
210        Self::secure_cache_dir(&Self::cache_dir()?)
211    }
212
213    /// Returns a list of all available cached sessions.
214    pub fn get_sessions() -> Result<Vec<(String, String)>> {
215        // Read the cache directory entries
216        let cache_dir = Self::cache_dir()?;
217        let entries = Self::cached_session_files(&cache_dir)?;
218
219        // For each entry, get the file name and modified time
220        let mut sessions = Vec::new();
221        for entry in entries {
222            let modified_time = entry.metadata()?.modified()?;
223            let file_name = entry.file_name();
224            let file_name = file_name
225                .into_string()
226                .map_err(|e| eyre::eyre!(format!("{}", e.to_string_lossy())))?;
227            sessions.push((
228                OffsetDateTime::from(modified_time).format(&format_description::parse(
229                    "[year]-[month]-[day] [hour]:[minute]:[second]",
230                )?)?,
231                file_name,
232            ));
233        }
234        Ok(sessions)
235    }
236
237    /// Loads a specific ChiselSession from the specified cache file
238    ///
239    /// ### Takes
240    ///
241    /// The ID of the chisel session that you wish to load.
242    ///
243    /// ### Returns
244    ///
245    /// Optionally, an owned instance of the loaded chisel session.
246    pub fn load(id: &str, executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
247        Self::load_from(id, &Self::cache_dir()?, executor_builder)
248    }
249
250    fn load_from(
251        id: &str,
252        cache_dir: &str,
253        executor_builder: ExecutorBuilder<FEN>,
254    ) -> Result<Self> {
255        validate_session_id(id)?;
256        Self::secure_cache_dir(cache_dir)?;
257        let contents = Self::read_cached_file(Path::new(&format!("{cache_dir}chisel-{id}.json")))?;
258        let mut session = Self::deserialize_cached(&contents, executor_builder)?;
259        // Use the requested ID even if the cached ID is missing or stale.
260        session.id = Some(id.to_string());
261        Ok(session)
262    }
263
264    /// Gets the most recent chisel session from the cache dir
265    ///
266    /// ### Returns
267    ///
268    /// Optionally, the file name of the most recently modified cached session.
269    pub fn latest_cached_session() -> Result<String> {
270        Self::latest_cached_session_in(&Self::cache_dir()?)
271    }
272
273    fn latest_cached_session_in(cache_dir: &str) -> Result<String> {
274        let mut entries = Self::cached_session_files(cache_dir)?.into_iter();
275        let mut latest = entries.next().ok_or_else(|| eyre::eyre!("No entries found!"))?;
276        for entry in entries {
277            if entry.metadata()?.modified()? > latest.metadata()?.modified()? {
278                latest = entry;
279            }
280        }
281        Ok(latest
282            .path()
283            .to_str()
284            .ok_or_else(|| eyre::eyre!("Failed to get session path!"))?
285            .to_string())
286    }
287
288    /// Loads the latest ChiselSession from the cache file
289    ///
290    /// ### Returns
291    ///
292    /// Optionally, an owned instance of the most recently modified cached session.
293    pub fn latest(executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
294        Self::latest_from(&Self::cache_dir()?, executor_builder)
295    }
296
297    fn latest_from(cache_dir: &str, executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
298        let last_session = Self::latest_cached_session_in(cache_dir)?;
299        let last_session_contents = Self::read_cached_file(Path::new(&last_session))?;
300        let mut session = Self::deserialize_cached(&last_session_contents, executor_builder)?;
301        // Bind the session to the file that was loaded.
302        session.id = Self::session_id_from_cache_file_name(&last_session);
303        Ok(session)
304    }
305
306    /// Extracts the session id from a `.../chisel-<id>.json` cache file path.
307    fn session_id_from_cache_file_name(path: &str) -> Option<String> {
308        Path::new(path).file_stem()?.to_str()?.strip_prefix("chisel-").map(str::to_string)
309    }
310
311    /// Protects private source and credentials in both new and legacy cache directories.
312    fn secure_cache_dir(cache_dir: &str) -> Result<()> {
313        // Remove trailing separators so symlink_metadata inspects the directory entry itself.
314        let path = Path::new(cache_dir).components().collect::<PathBuf>();
315        let mut builder = fs::DirBuilder::new();
316        builder.recursive(true);
317        #[cfg(unix)]
318        builder.mode(0o700);
319        builder.create(&path)?;
320        eyre::ensure!(
321            fs::symlink_metadata(&path)?.is_dir(),
322            "Chisel cache must be a directory, not a symlink"
323        );
324        #[cfg(unix)]
325        fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?;
326        Ok(())
327    }
328
329    /// Reads a regular session file after restricting legacy file permissions.
330    fn read_cached_file(path: &Path) -> Result<String> {
331        eyre::ensure!(
332            fs::symlink_metadata(path)?.is_file(),
333            "Chisel session must be a regular file"
334        );
335        let mut file = File::open(path)?;
336        eyre::ensure!(file.metadata()?.is_file(), "Chisel session must be a regular file");
337        #[cfg(unix)]
338        file.set_permissions(fs::Permissions::from_mode(0o600))?;
339        let mut contents = String::new();
340        file.read_to_string(&mut contents)?;
341        Ok(contents)
342    }
343
344    /// Excludes temporary saves, unrelated files, and symlinks from session discovery.
345    fn cached_session_files(cache_dir: &str) -> Result<Vec<fs::DirEntry>> {
346        Self::secure_cache_dir(cache_dir)?;
347        let mut sessions = Vec::new();
348        for entry in fs::read_dir(cache_dir)? {
349            let entry = entry?;
350            if entry.file_type()?.is_file()
351                && let Some(name) = entry.file_name().to_str()
352                && let Some(id) =
353                    name.strip_prefix("chisel-").and_then(|name| name.strip_suffix(".json"))
354                && validate_session_id(id).is_ok()
355            {
356                sessions.push(entry);
357            }
358        }
359        Ok(sessions)
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use foundry_config::{Config, SolcReq};
367    use foundry_evm::core::evm::EthEvmNetwork;
368    use semver::Version;
369
370    #[cfg(unix)]
371    use std::os::unix::fs::symlink;
372
373    #[cfg(feature = "monad")]
374    use foundry_evm::core::{constants::MONAD_CHEATCODE_ADDRESS, evm::MonadEvmNetwork};
375
376    /// Deleted sessions must not cause the next ID to collide with an existing file.
377    #[test]
378    fn next_cached_session_skips_gaps_left_by_deleted_sessions() {
379        let dir = tempfile::tempdir().unwrap();
380        let cache_dir = format!("{}/", dir.path().to_str().unwrap());
381
382        // Sessions 0 and 2 exist; session 1 was deleted or renamed away, leaving a gap.
383        std::fs::write(format!("{cache_dir}chisel-0.json"), "{\"id\":\"0\"}").unwrap();
384        std::fs::write(format!("{cache_dir}chisel-2.json"), "{\"id\":\"2\"}").unwrap();
385
386        let (next_id, next_file) =
387            ChiselSession::<EthEvmNetwork>::next_cached_session_in(&cache_dir).unwrap();
388
389        // Counting entries would select the occupied ID 2.
390        assert_eq!(next_id, "3", "must skip past the gap instead of reusing the occupied id 2");
391        assert_eq!(next_file, format!("{cache_dir}chisel-3.json"));
392
393        assert_eq!(
394            std::fs::read_to_string(format!("{cache_dir}chisel-0.json")).unwrap(),
395            "{\"id\":\"0\"}"
396        );
397        assert_eq!(
398            std::fs::read_to_string(format!("{cache_dir}chisel-2.json")).unwrap(),
399            "{\"id\":\"2\"}"
400        );
401    }
402
403    #[test]
404    fn next_cached_session_does_not_overflow_on_a_usize_max_named_session() {
405        let dir = tempfile::tempdir().unwrap();
406        let cache_dir = format!("{}/", dir.path().to_str().unwrap());
407        std::fs::write(format!("{cache_dir}chisel-{}.json", usize::MAX), "{}").unwrap();
408
409        let result = ChiselSession::<EthEvmNetwork>::next_cached_session_in(&cache_dir);
410        assert!(result.is_err(), "must error instead of panicking or wrapping to a reused id");
411    }
412
413    #[test]
414    fn deserialized_sessions_do_not_restore_force() {
415        let session = ChiselSession::<EthEvmNetwork>::new(SessionSourceConfig {
416            foundry_config: Config {
417                force: true,
418                solc: Some(SolcReq::Version(Version::new(0, 8, 29))),
419                ..Default::default()
420            },
421            no_vm: true,
422            ..Default::default()
423        })
424        .unwrap();
425        assert!(session.source.config.foundry_config.force);
426
427        let serialized = serde_json::to_string(&session).unwrap();
428        let session = ChiselSession::<EthEvmNetwork>::deserialize_cached(
429            &serialized,
430            ExecutorBuilder::<EthEvmNetwork>::new(),
431        )
432        .unwrap();
433
434        assert!(!session.source.config.foundry_config.force);
435    }
436
437    #[cfg(feature = "monad")]
438    #[test]
439    fn deserialized_sessions_use_active_monad_tooling() {
440        let session = ChiselSession::<MonadEvmNetwork>::new(SessionSourceConfig {
441            executor_builder: ExecutorBuilder::<MonadEvmNetwork>::new(),
442            ..Default::default()
443        })
444        .unwrap();
445        let serialized = serde_json::to_string(&session).unwrap();
446
447        let session = ChiselSession::<MonadEvmNetwork>::deserialize_cached(
448            &serialized,
449            ExecutorBuilder::<MonadEvmNetwork>::new(),
450        )
451        .unwrap();
452
453        assert_eq!(
454            session.source.config.executor_builder.extra_cheatcode_addresses(),
455            &[MONAD_CHEATCODE_ADDRESS]
456        );
457    }
458
459    /// A session id containing a path separator lets `chisel-<id>.json` escape the cache
460    /// directory once resolved: `chisel-x/../../../foo.json` has real `..` path components
461    /// after the `x` segment, walking back out past the cache directory entirely.
462    /// Also verifies that `:` is rejected to prevent targeting NTFS Alternate Data Streams (ADS).
463    #[test]
464    fn path_traversal_ids_are_rejected() {
465        for id in [
466            "../evil",
467            "x/../../../../../../tmp/pwned",
468            "..",
469            ".",
470            "",
471            "sub/dir",
472            "back\\slash",
473            ":colon",
474            "foo:bar",
475            "session:1",
476        ] {
477            let err = validate_session_id(id).unwrap_err();
478            assert!(err.to_string().contains("invalid Chisel session id"), "{id:?}: {err}");
479        }
480
481        // ordinary numeric and name-like ids remain accepted
482        for id in ["0", "42", "my-session", "my_session"] {
483            validate_session_id(id).unwrap();
484        }
485    }
486
487    #[test]
488    fn load_rejects_path_traversal_id() {
489        let err = ChiselSession::<EthEvmNetwork>::load(
490            "../../evil",
491            ExecutorBuilder::<EthEvmNetwork>::new(),
492        )
493        .unwrap_err();
494        assert!(err.to_string().contains("invalid Chisel session id"), "{err}");
495    }
496
497    #[test]
498    fn remove_cached_session_rejects_path_traversal_id() {
499        let err = ChiselSession::<EthEvmNetwork>::remove_cached_session("../../evil").unwrap_err();
500        assert!(err.to_string().contains("invalid Chisel session id"), "{err}");
501    }
502
503    fn session_for_normalization_tests() -> ChiselSession<EthEvmNetwork> {
504        ChiselSession::<EthEvmNetwork>::new(SessionSourceConfig {
505            foundry_config: Config {
506                solc: Some(SolcReq::Version(Version::new(0, 8, 29))),
507                ..Default::default()
508            },
509            no_vm: true,
510            ..Default::default()
511        })
512        .unwrap()
513    }
514
515    /// Loading uses the filename rather than a stale or missing cached ID.
516    #[test]
517    fn load_normalizes_id_ignoring_a_stale_or_missing_embedded_id() {
518        let dir = tempfile::tempdir().unwrap();
519        let cache_dir = format!("{}/", dir.path().to_str().unwrap());
520
521        let mut session = session_for_normalization_tests();
522        session.id = Some("stale-name".to_string());
523        let serialized = serde_json::to_string(&session).unwrap();
524        std::fs::write(format!("{cache_dir}chisel-5.json"), &serialized).unwrap();
525
526        let loaded = ChiselSession::<EthEvmNetwork>::load_from(
527            "5",
528            &cache_dir,
529            ExecutorBuilder::<EthEvmNetwork>::new(),
530        )
531        .unwrap();
532        assert_eq!(loaded.id.as_deref(), Some("5"), "must use the requested id, not the stale one");
533
534        let without_id = serialized.replacen("\"stale-name\"", "null", 1);
535        std::fs::write(format!("{cache_dir}chisel-7.json"), without_id).unwrap();
536        let loaded = ChiselSession::<EthEvmNetwork>::load_from(
537            "7",
538            &cache_dir,
539            ExecutorBuilder::<EthEvmNetwork>::new(),
540        )
541        .unwrap();
542        assert_eq!(loaded.id.as_deref(), Some("7"), "a null embedded id must not survive the load");
543    }
544
545    #[test]
546    fn latest_normalizes_id_from_the_resolved_file_name() {
547        let dir = tempfile::tempdir().unwrap();
548        let cache_dir = format!("{}/", dir.path().to_str().unwrap());
549
550        let session = session_for_normalization_tests();
551        // New sessions serialize with a null ID.
552        let serialized = serde_json::to_string(&session).unwrap();
553        std::fs::write(format!("{cache_dir}chisel-9.json"), serialized).unwrap();
554
555        let loaded = ChiselSession::<EthEvmNetwork>::latest_from(
556            &cache_dir,
557            ExecutorBuilder::<EthEvmNetwork>::new(),
558        )
559        .unwrap();
560        assert_eq!(loaded.id.as_deref(), Some("9"));
561    }
562
563    #[test]
564    fn session_id_from_cache_file_name_strips_prefix_and_extension() {
565        assert_eq!(
566            ChiselSession::<EthEvmNetwork>::session_id_from_cache_file_name(
567                "/home/user/.foundry/cache/chisel/chisel-42.json"
568            ),
569            Some("42".to_string())
570        );
571        assert_eq!(
572            ChiselSession::<EthEvmNetwork>::session_id_from_cache_file_name(
573                "/home/user/.foundry/cache/chisel/not-a-session-file.json"
574            ),
575            None
576        );
577    }
578
579    #[test]
580    fn write_rejects_path_traversal_id() {
581        let mut session = ChiselSession::<EthEvmNetwork>::new(SessionSourceConfig {
582            foundry_config: Config {
583                solc: Some(SolcReq::Version(Version::new(0, 8, 29))),
584                ..Default::default()
585            },
586            no_vm: true,
587            ..Default::default()
588        })
589        .unwrap();
590        session.id = Some("../../evil".to_string());
591
592        let err = session.write().unwrap_err();
593        assert!(err.to_string().contains("invalid Chisel session id"), "{err}");
594    }
595
596    #[cfg(unix)]
597    #[test]
598    fn saved_sessions_are_private() {
599        let dir = tempfile::tempdir().unwrap();
600        let cache = dir.path().join("chisel");
601        std::fs::create_dir(&cache).unwrap();
602        std::fs::set_permissions(&cache, std::fs::Permissions::from_mode(0o755)).unwrap();
603        let file = cache.join("chisel-private.json");
604        std::fs::write(&file, "old session").unwrap();
605        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
606        let mut session = session_for_normalization_tests();
607        session.id = Some("private".to_string());
608        session.source.run_code = "uint256 privateValue = 42;".to_string();
609
610        session.write_to(&format!("{}/", cache.display())).unwrap();
611
612        assert_eq!(std::fs::metadata(&cache).unwrap().permissions().mode() & 0o777, 0o700);
613        assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600);
614        let saved: ChiselSession<EthEvmNetwork> =
615            serde_json::from_str(&std::fs::read_to_string(file).unwrap()).unwrap();
616        assert_eq!(saved.source.run_code, session.source.run_code);
617    }
618
619    #[cfg(unix)]
620    #[test]
621    fn new_cache_directories_are_private_without_changing_existing_parents() {
622        let dir = tempfile::tempdir().unwrap();
623        fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
624        let cache = dir.path().join(".foundry/cache/chisel");
625        let mut session = session_for_normalization_tests();
626
627        let file = session.write_to(&format!("{}/", cache.display())).unwrap();
628
629        assert_eq!(fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777, 0o755);
630        for path in [dir.path().join(".foundry"), dir.path().join(".foundry/cache"), cache] {
631            assert_eq!(fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o700);
632        }
633        assert_eq!(fs::metadata(file).unwrap().permissions().mode() & 0o777, 0o600);
634    }
635
636    #[cfg(unix)]
637    #[test]
638    fn saving_replaces_symlinks_without_changing_their_targets() {
639        let dir = tempfile::tempdir().unwrap();
640        let target = dir.path().join("unrelated.json");
641        fs::write(&target, "untouched").unwrap();
642        fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap();
643        let cache = dir.path().join("chisel");
644        fs::create_dir(&cache).unwrap();
645        let destination = cache.join("chisel-linked.json");
646        symlink(&target, &destination).unwrap();
647        let mut session = session_for_normalization_tests();
648        session.id = Some("linked".to_string());
649
650        session.write_to(&format!("{}/", cache.display())).unwrap();
651
652        assert!(fs::symlink_metadata(&destination).unwrap().is_file());
653        assert_eq!(fs::metadata(destination).unwrap().permissions().mode() & 0o777, 0o600);
654        assert_eq!(fs::read_to_string(&target).unwrap(), "untouched");
655        assert_eq!(fs::metadata(target).unwrap().permissions().mode() & 0o777, 0o644);
656    }
657
658    #[cfg(unix)]
659    #[test]
660    fn cache_directory_symlinks_are_rejected_without_changing_targets() {
661        let dir = tempfile::tempdir().unwrap();
662        let target = dir.path().join("unrelated");
663        fs::create_dir(&target).unwrap();
664        fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
665        let cache = dir.path().join("chisel");
666        symlink(&target, &cache).unwrap();
667        let mut session = session_for_normalization_tests();
668
669        let result = session.write_to(&format!("{}/", cache.display()));
670
671        assert_eq!(
672            result.unwrap_err().to_string(),
673            "Chisel cache must be a directory, not a symlink"
674        );
675        assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o755);
676        assert_eq!(fs::read_dir(target).unwrap().count(), 0);
677    }
678
679    #[cfg(unix)]
680    #[test]
681    fn loading_legacy_sessions_restricts_file_and_directory_permissions() {
682        let dir = tempfile::tempdir().unwrap();
683        let cache_dir = format!("{}/", dir.path().display());
684        let path = dir.path().join("chisel-legacy.json");
685        let session = session_for_normalization_tests();
686        fs::write(&path, serde_json::to_vec(&session).unwrap()).unwrap();
687        fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
688        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
689
690        let loaded = ChiselSession::<EthEvmNetwork>::load_from(
691            "legacy",
692            &cache_dir,
693            ExecutorBuilder::<EthEvmNetwork>::new(),
694        )
695        .unwrap();
696
697        assert_eq!(loaded.id.as_deref(), Some("legacy"));
698        assert_eq!(fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777, 0o700);
699        assert_eq!(fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o600);
700    }
701
702    #[cfg(unix)]
703    #[test]
704    fn loading_rejects_symlinks_without_changing_their_targets() {
705        let dir = tempfile::tempdir().unwrap();
706        let target = dir.path().join("unrelated.json");
707        fs::write(&target, "untouched").unwrap();
708        fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap();
709        symlink(&target, dir.path().join("chisel-linked.json")).unwrap();
710
711        let result = ChiselSession::<EthEvmNetwork>::load_from(
712            "linked",
713            &format!("{}/", dir.path().display()),
714            ExecutorBuilder::<EthEvmNetwork>::new(),
715        );
716
717        assert_eq!(result.unwrap_err().to_string(), "Chisel session must be a regular file");
718        assert_eq!(fs::read_to_string(&target).unwrap(), "untouched");
719        assert_eq!(fs::metadata(target).unwrap().permissions().mode() & 0o777, 0o644);
720    }
721
722    #[test]
723    fn session_discovery_ignores_incomplete_and_unrelated_files() {
724        let dir = tempfile::tempdir().unwrap();
725        let cache_dir = format!("{}/", dir.path().display());
726        let mut session = session_for_normalization_tests();
727        session.id = Some("saved".to_string());
728        let saved = session.write_to(&cache_dir).unwrap();
729        fs::write(dir.path().join(".tmp-incomplete"), "{").unwrap();
730        fs::write(dir.path().join("unrelated.json"), "unrelated").unwrap();
731        fs::write(dir.path().join("chisel-.json"), "invalid").unwrap();
732        fs::create_dir(dir.path().join("chisel-directory.json")).unwrap();
733        #[cfg(unix)]
734        symlink(&saved, dir.path().join("chisel-linked.json")).unwrap();
735
736        let sessions = ChiselSession::<EthEvmNetwork>::cached_session_files(&cache_dir).unwrap();
737
738        assert_eq!(sessions.len(), 1);
739        assert_eq!(sessions[0].path(), Path::new(&saved));
740        assert_eq!(
741            ChiselSession::<EthEvmNetwork>::latest_cached_session_in(&cache_dir).unwrap(),
742            saved
743        );
744        let latest = ChiselSession::<EthEvmNetwork>::latest_from(
745            &cache_dir,
746            ExecutorBuilder::<EthEvmNetwork>::new(),
747        )
748        .unwrap();
749        assert_eq!(latest.id.as_deref(), Some("saved"));
750    }
751
752    #[test]
753    fn failed_save_cleans_up_temporary_files() {
754        let dir = tempfile::tempdir().unwrap();
755        let cache_dir = format!("{}/", dir.path().display());
756        let destination = dir.path().join("chisel-blocked.json");
757        fs::create_dir(&destination).unwrap();
758        fs::write(destination.join("sentinel"), "untouched").unwrap();
759        let mut session = session_for_normalization_tests();
760        session.id = Some("blocked".to_string());
761
762        assert!(session.write_to(&cache_dir).is_err());
763
764        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
765        assert_eq!(fs::read_to_string(destination.join("sentinel")).unwrap(), "untouched");
766    }
767
768    #[test]
769    fn loading_legacy_fork_sessions_discards_cached_credentials() {
770        let dir = tempfile::tempdir().unwrap();
771        let cache_dir = format!("{}/", dir.path().display());
772        let mut session = session_for_normalization_tests();
773        session.source.run_code = "uint256 privateValue = 42;".into();
774        session.source.config.calldata = Some(vec![0xde, 0xad, 0xbe, 0xef]);
775        let mut legacy = serde_json::to_value(&session).unwrap();
776        let config = &mut legacy["source"]["config"];
777        config.as_object_mut().unwrap().remove("fork_url_required");
778        config["foundry_config"]["eth_rpc_url"] = "https://rpc.invalid/legacy-token".into();
779        config["foundry_config"]["eth_rpc_jwt"] = "legacy-jwt".into();
780        config["foundry_config"]["eth_rpc_headers"] =
781            serde_json::json!(["Authorization: legacy-header"]);
782        config["foundry_config"]["etherscan_api_key"] = "legacy-api-key".into();
783        config["foundry_config"]["etherscan"] = serde_json::json!({
784            "mainnet": { "key": "legacy-explorer-key", "chain": 1 }
785        });
786        config["foundry_config"]["rpc_endpoints"] = serde_json::json!({
787            "mainnet": "https://rpc.invalid/legacy-endpoint"
788        });
789        config["evm_opts"]["eth_rpc_url"] = "https://rpc.invalid/legacy-token".into();
790        config["evm_opts"]["eth_rpc_jwt"] = "legacy-jwt".into();
791        config["evm_opts"]["eth_rpc_headers"] = serde_json::json!(["Authorization: legacy-header"]);
792        config["evm_opts"]["fork_headers"] =
793            serde_json::json!(["Authorization: legacy-fork-header"]);
794        let path = dir.path().join("chisel-legacy.json");
795        fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
796
797        for loaded in [
798            ChiselSession::<EthEvmNetwork>::load_from(
799                "legacy",
800                &cache_dir,
801                ExecutorBuilder::<EthEvmNetwork>::new(),
802            )
803            .unwrap(),
804            ChiselSession::<EthEvmNetwork>::latest_from(
805                &cache_dir,
806                ExecutorBuilder::<EthEvmNetwork>::new(),
807            )
808            .unwrap(),
809        ] {
810            let config = &loaded.source.config;
811            assert!(config.fork_url_required);
812            assert_eq!(config.foundry_config.eth_rpc_url, None);
813            assert_eq!(config.foundry_config.eth_rpc_jwt, None);
814            assert_eq!(config.foundry_config.eth_rpc_headers, None);
815            assert_eq!(config.foundry_config.etherscan_api_key, None);
816            assert!(config.foundry_config.etherscan.is_empty());
817            assert!(config.foundry_config.rpc_endpoints.is_empty());
818            assert_eq!(config.evm_opts.fork_url, None);
819            assert_eq!(config.evm_opts.rpc_jwt, None);
820            assert_eq!(config.evm_opts.rpc_headers, None);
821            assert_eq!(config.evm_opts.fork_headers, None);
822            assert_eq!(config.calldata, session.source.config.calldata);
823            assert_eq!(loaded.source.run_code, session.source.run_code);
824        }
825    }
826}