1use crate::prelude::{SessionSource, SessionSourceConfig};
7use eyre::Result;
8use foundry_evm::{core::evm::FoundryEvmNetwork, executors::ExecutorBuilder};
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11use time::{OffsetDateTime, format_description};
12
13fn validate_session_id(id: &str) -> Result<()> {
18 if id.is_empty() || id == "." || id == ".." || id.contains(['/', '\\', ':']) {
19 eyre::bail!(
20 "invalid Chisel session id `{id}`: must not be empty, `.`, `..`, or contain a path \
21 separator or `:`"
22 );
23 }
24 Ok(())
25}
26
27#[derive(Debug, Serialize, Deserialize)]
29#[serde(bound = "")]
30pub struct ChiselSession<FEN: FoundryEvmNetwork> {
31 pub source: SessionSource<FEN>,
33 pub id: Option<String>,
35}
36
37impl<FEN: FoundryEvmNetwork> ChiselSession<FEN> {
39 fn deserialize_cached(contents: &str, executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
40 let mut session: Self = serde_json::from_str(contents)?;
41 session.source.config.foundry_config.force = false;
43 session.source.config.executor_builder = executor_builder;
44 Ok(session)
45 }
46
47 pub fn new(config: SessionSourceConfig<FEN>) -> Result<Self> {
57 Ok(Self { source: SessionSource::new(config)?, id: None })
59 }
60
61 pub fn contract_source(&self) -> String {
72 self.source.to_repl_source()
73 }
74
75 pub fn clear_cache() -> Result<()> {
82 let cache_dir = Self::cache_dir()?;
83 for entry in std::fs::read_dir(cache_dir)? {
84 let entry = entry?;
85 let path = entry.path();
86 if path.is_dir() {
87 std::fs::remove_dir_all(path)?;
88 } else {
89 std::fs::remove_file(path)?;
90 }
91 }
92 Ok(())
93 }
94
95 pub fn remove_cached_session(id: &str) -> Result<()> {
97 validate_session_id(id)?;
98 let cache_file = format!("{}chisel-{id}.json", Self::cache_dir()?);
99 match std::fs::remove_file(cache_file) {
100 Ok(()) => Ok(()),
101 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
102 Err(error) => Err(error.into()),
103 }
104 }
105
106 pub fn write(&mut self) -> Result<String> {
112 let cache_dir = Self::cache_dir()?;
114 std::fs::create_dir_all(&cache_dir)?;
115
116 let cache_file_name = match self.id.as_ref() {
117 Some(id) => {
118 validate_session_id(id)?;
120 format!("{cache_dir}chisel-{id}.json")
121 }
122 None => {
123 let (id, file_name) = Self::next_cached_session()?;
125 self.id = Some(id);
127 file_name
129 }
130 };
131
132 let serialized_contents = serde_json::to_string_pretty(self)?;
134 std::fs::write(&cache_file_name, serialized_contents)?;
135
136 Ok(cache_file_name)
139 }
140
141 pub fn next_cached_session() -> Result<(String, String)> {
149 Self::next_cached_session_in(&Self::cache_dir()?)
150 }
151
152 fn next_cached_session_in(cache_dir: &str) -> Result<(String, String)> {
153 let next_id = std::fs::read_dir(cache_dir)?
154 .filter_map(|entry| entry.ok())
155 .filter_map(|entry| {
156 entry
157 .file_name()
158 .to_str()?
159 .strip_prefix("chisel-")?
160 .strip_suffix(".json")?
161 .parse::<usize>()
162 .ok()
163 })
164 .max()
165 .map_or(Some(0), |max| max.checked_add(1))
166 .ok_or_else(|| eyre::eyre!("no unused chisel session id available"))?;
167
168 Ok((format!("{next_id}"), format!("{cache_dir}chisel-{next_id}.json")))
169 }
170
171 pub fn cache_dir() -> Result<String> {
177 let home_dir =
178 dirs::home_dir().ok_or_else(|| eyre::eyre!("Failed to grab home directory"))?;
179 let home_dir_str = home_dir
180 .to_str()
181 .ok_or_else(|| eyre::eyre!("Failed to convert home directory to string"))?;
182 Ok(format!("{home_dir_str}/.foundry/cache/chisel/"))
183 }
184
185 pub fn create_cache_dir() -> Result<()> {
191 let cache_dir = Self::cache_dir()?;
192 if !Path::new(&cache_dir).exists() {
193 std::fs::create_dir_all(&cache_dir)?;
194 }
195 Ok(())
196 }
197
198 pub fn get_sessions() -> Result<Vec<(String, String)>> {
200 let cache_dir = Self::cache_dir()?;
202 let entries = std::fs::read_dir(cache_dir)?;
203
204 let mut sessions = Vec::new();
206 for entry in entries {
207 let entry = entry?;
208 let modified_time = entry.metadata()?.modified()?;
209 let file_name = entry.file_name();
210 let file_name = file_name
211 .into_string()
212 .map_err(|e| eyre::eyre!(format!("{}", e.to_string_lossy())))?;
213 sessions.push((
214 OffsetDateTime::from(modified_time).format(&format_description::parse(
215 "[year]-[month]-[day] [hour]:[minute]:[second]",
216 )?)?,
217 file_name,
218 ));
219 }
220 Ok(sessions)
221 }
222
223 pub fn load(id: &str, executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
233 Self::load_from(id, &Self::cache_dir()?, executor_builder)
234 }
235
236 fn load_from(
237 id: &str,
238 cache_dir: &str,
239 executor_builder: ExecutorBuilder<FEN>,
240 ) -> Result<Self> {
241 validate_session_id(id)?;
242 let contents = std::fs::read_to_string(Path::new(&format!("{cache_dir}chisel-{id}.json")))?;
243 let mut session = Self::deserialize_cached(&contents, executor_builder)?;
244 session.id = Some(id.to_string());
246 Ok(session)
247 }
248
249 pub fn latest_cached_session() -> Result<String> {
255 Self::latest_cached_session_in(&Self::cache_dir()?)
256 }
257
258 fn latest_cached_session_in(cache_dir: &str) -> Result<String> {
259 let mut entries = std::fs::read_dir(cache_dir)?;
260 let mut latest = entries.next().ok_or_else(|| eyre::eyre!("No entries found!"))??;
261 for entry in entries {
262 let entry = entry?;
263 if entry.metadata()?.modified()? > latest.metadata()?.modified()? {
264 latest = entry;
265 }
266 }
267 Ok(latest
268 .path()
269 .to_str()
270 .ok_or_else(|| eyre::eyre!("Failed to get session path!"))?
271 .to_string())
272 }
273
274 pub fn latest(executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
280 Self::latest_from(&Self::cache_dir()?, executor_builder)
281 }
282
283 fn latest_from(cache_dir: &str, executor_builder: ExecutorBuilder<FEN>) -> Result<Self> {
284 let last_session = Self::latest_cached_session_in(cache_dir)?;
285 let last_session_contents = std::fs::read_to_string(Path::new(&last_session))?;
286 let mut session = Self::deserialize_cached(&last_session_contents, executor_builder)?;
287 session.id = Self::session_id_from_cache_file_name(&last_session);
289 Ok(session)
290 }
291
292 fn session_id_from_cache_file_name(path: &str) -> Option<String> {
294 Path::new(path).file_stem()?.to_str()?.strip_prefix("chisel-").map(str::to_string)
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use foundry_config::{Config, SolcReq};
302 use foundry_evm::core::evm::EthEvmNetwork;
303 use semver::Version;
304
305 #[cfg(feature = "monad")]
306 use foundry_evm::core::{constants::MONAD_CHEATCODE_ADDRESS, evm::MonadEvmNetwork};
307
308 #[test]
310 fn next_cached_session_skips_gaps_left_by_deleted_sessions() {
311 let dir = tempfile::tempdir().unwrap();
312 let cache_dir = format!("{}/", dir.path().to_str().unwrap());
313
314 std::fs::write(format!("{cache_dir}chisel-0.json"), "{\"id\":\"0\"}").unwrap();
316 std::fs::write(format!("{cache_dir}chisel-2.json"), "{\"id\":\"2\"}").unwrap();
317
318 let (next_id, next_file) =
319 ChiselSession::<EthEvmNetwork>::next_cached_session_in(&cache_dir).unwrap();
320
321 assert_eq!(next_id, "3", "must skip past the gap instead of reusing the occupied id 2");
323 assert_eq!(next_file, format!("{cache_dir}chisel-3.json"));
324
325 assert_eq!(
326 std::fs::read_to_string(format!("{cache_dir}chisel-0.json")).unwrap(),
327 "{\"id\":\"0\"}"
328 );
329 assert_eq!(
330 std::fs::read_to_string(format!("{cache_dir}chisel-2.json")).unwrap(),
331 "{\"id\":\"2\"}"
332 );
333 }
334
335 #[test]
336 fn next_cached_session_does_not_overflow_on_a_usize_max_named_session() {
337 let dir = tempfile::tempdir().unwrap();
338 let cache_dir = format!("{}/", dir.path().to_str().unwrap());
339 std::fs::write(format!("{cache_dir}chisel-{}.json", usize::MAX), "{}").unwrap();
340
341 let result = ChiselSession::<EthEvmNetwork>::next_cached_session_in(&cache_dir);
342 assert!(result.is_err(), "must error instead of panicking or wrapping to a reused id");
343 }
344
345 #[test]
346 fn deserialized_sessions_do_not_restore_force() {
347 let session = ChiselSession::<EthEvmNetwork>::new(SessionSourceConfig {
348 foundry_config: Config {
349 force: true,
350 solc: Some(SolcReq::Version(Version::new(0, 8, 29))),
351 ..Default::default()
352 },
353 no_vm: true,
354 ..Default::default()
355 })
356 .unwrap();
357 assert!(session.source.config.foundry_config.force);
358
359 let serialized = serde_json::to_string(&session).unwrap();
360 let session = ChiselSession::<EthEvmNetwork>::deserialize_cached(
361 &serialized,
362 ExecutorBuilder::<EthEvmNetwork>::new(),
363 )
364 .unwrap();
365
366 assert!(!session.source.config.foundry_config.force);
367 }
368
369 #[cfg(feature = "monad")]
370 #[test]
371 fn deserialized_sessions_use_active_monad_tooling() {
372 let session = ChiselSession::<MonadEvmNetwork>::new(SessionSourceConfig {
373 executor_builder: ExecutorBuilder::<MonadEvmNetwork>::new(),
374 ..Default::default()
375 })
376 .unwrap();
377 let serialized = serde_json::to_string(&session).unwrap();
378
379 let session = ChiselSession::<MonadEvmNetwork>::deserialize_cached(
380 &serialized,
381 ExecutorBuilder::<MonadEvmNetwork>::new(),
382 )
383 .unwrap();
384
385 assert_eq!(
386 session.source.config.executor_builder.extra_cheatcode_addresses(),
387 &[MONAD_CHEATCODE_ADDRESS]
388 );
389 }
390
391 #[test]
396 fn path_traversal_ids_are_rejected() {
397 for id in [
398 "../evil",
399 "x/../../../../../../tmp/pwned",
400 "..",
401 ".",
402 "",
403 "sub/dir",
404 "back\\slash",
405 ":colon",
406 "foo:bar",
407 "session:1",
408 ] {
409 let err = validate_session_id(id).unwrap_err();
410 assert!(err.to_string().contains("invalid Chisel session id"), "{id:?}: {err}");
411 }
412
413 for id in ["0", "42", "my-session", "my_session"] {
415 validate_session_id(id).unwrap();
416 }
417 }
418
419 #[test]
420 fn load_rejects_path_traversal_id() {
421 let err = ChiselSession::<EthEvmNetwork>::load(
422 "../../evil",
423 ExecutorBuilder::<EthEvmNetwork>::new(),
424 )
425 .unwrap_err();
426 assert!(err.to_string().contains("invalid Chisel session id"), "{err}");
427 }
428
429 #[test]
430 fn remove_cached_session_rejects_path_traversal_id() {
431 let err = ChiselSession::<EthEvmNetwork>::remove_cached_session("../../evil").unwrap_err();
432 assert!(err.to_string().contains("invalid Chisel session id"), "{err}");
433 }
434
435 fn session_for_normalization_tests() -> ChiselSession<EthEvmNetwork> {
436 ChiselSession::<EthEvmNetwork>::new(SessionSourceConfig {
437 foundry_config: Config {
438 solc: Some(SolcReq::Version(Version::new(0, 8, 29))),
439 ..Default::default()
440 },
441 no_vm: true,
442 ..Default::default()
443 })
444 .unwrap()
445 }
446
447 #[test]
449 fn load_normalizes_id_ignoring_a_stale_or_missing_embedded_id() {
450 let dir = tempfile::tempdir().unwrap();
451 let cache_dir = format!("{}/", dir.path().to_str().unwrap());
452
453 let mut session = session_for_normalization_tests();
454 session.id = Some("stale-name".to_string());
455 let serialized = serde_json::to_string(&session).unwrap();
456 std::fs::write(format!("{cache_dir}chisel-5.json"), &serialized).unwrap();
457
458 let loaded = ChiselSession::<EthEvmNetwork>::load_from(
459 "5",
460 &cache_dir,
461 ExecutorBuilder::<EthEvmNetwork>::new(),
462 )
463 .unwrap();
464 assert_eq!(loaded.id.as_deref(), Some("5"), "must use the requested id, not the stale one");
465
466 let without_id = serialized.replacen("\"stale-name\"", "null", 1);
467 std::fs::write(format!("{cache_dir}chisel-7.json"), without_id).unwrap();
468 let loaded = ChiselSession::<EthEvmNetwork>::load_from(
469 "7",
470 &cache_dir,
471 ExecutorBuilder::<EthEvmNetwork>::new(),
472 )
473 .unwrap();
474 assert_eq!(loaded.id.as_deref(), Some("7"), "a null embedded id must not survive the load");
475 }
476
477 #[test]
478 fn latest_normalizes_id_from_the_resolved_file_name() {
479 let dir = tempfile::tempdir().unwrap();
480 let cache_dir = format!("{}/", dir.path().to_str().unwrap());
481
482 let session = session_for_normalization_tests();
483 let serialized = serde_json::to_string(&session).unwrap();
485 std::fs::write(format!("{cache_dir}chisel-9.json"), serialized).unwrap();
486
487 let loaded = ChiselSession::<EthEvmNetwork>::latest_from(
488 &cache_dir,
489 ExecutorBuilder::<EthEvmNetwork>::new(),
490 )
491 .unwrap();
492 assert_eq!(loaded.id.as_deref(), Some("9"));
493 }
494
495 #[test]
496 fn session_id_from_cache_file_name_strips_prefix_and_extension() {
497 assert_eq!(
498 ChiselSession::<EthEvmNetwork>::session_id_from_cache_file_name(
499 "/home/user/.foundry/cache/chisel/chisel-42.json"
500 ),
501 Some("42".to_string())
502 );
503 assert_eq!(
504 ChiselSession::<EthEvmNetwork>::session_id_from_cache_file_name(
505 "/home/user/.foundry/cache/chisel/not-a-session-file.json"
506 ),
507 None
508 );
509 }
510
511 #[test]
512 fn write_rejects_path_traversal_id() {
513 let mut session = ChiselSession::<EthEvmNetwork>::new(SessionSourceConfig {
514 foundry_config: Config {
515 solc: Some(SolcReq::Version(Version::new(0, 8, 29))),
516 ..Default::default()
517 },
518 no_vm: true,
519 ..Default::default()
520 })
521 .unwrap();
522 session.id = Some("../../evil".to_string());
523
524 let err = session.write().unwrap_err();
525 assert!(err.to_string().contains("invalid Chisel session id"), "{err}");
526 }
527}