1use 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
21fn 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#[derive(Debug, Serialize, Deserialize)]
37#[serde(bound = "")]
38pub struct ChiselSession<FEN: FoundryEvmNetwork> {
39 pub source: SessionSource<FEN>,
41 pub id: Option<String>,
43}
44
45impl<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 session.source.config.foundry_config.force = false;
52 session.source.config.executor_builder = executor_builder;
53 Ok(session)
54 }
55
56 pub fn new(config: SessionSourceConfig<FEN>) -> Result<Self> {
66 Ok(Self { source: SessionSource::new(config)?, id: None })
68 }
69
70 pub fn contract_source(&self) -> String {
81 self.source.to_repl_source()
82 }
83
84 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 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 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 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 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 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 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 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 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 pub fn create_cache_dir() -> Result<()> {
239 Self::secure_cache_dir(&Self::cache_dir()?)
240 }
241
242 pub fn get_sessions() -> Result<Vec<(String, String)>> {
244 let cache_dir = Self::cache_dir()?;
246 let entries = Self::cached_session_files(&cache_dir)?;
247
248 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 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 session.id = Some(id.to_string());
290 Ok(session)
291 }
292
293 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 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 session.id = Self::session_id_from_cache_file_name(&last_session);
332 Ok(session)
333 }
334
335 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 fn secure_cache_dir(cache_dir: &str) -> Result<()> {
342 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 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 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 #[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 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 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 #[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 #[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 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 #[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 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}