use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use soroban_env_host::xdr::{LedgerEntry, LedgerKey};
use soroban_ledger_snapshot::LedgerSnapshot;
use crate::error::{ForkError, Result};
pub(crate) const DEFAULT_BASE_RESERVE: u32 = 100;
pub(crate) const DEFAULT_MIN_PERSISTENT_ENTRY_TTL: u32 = 4_096;
pub(crate) const DEFAULT_MIN_TEMP_ENTRY_TTL: u32 = 16;
pub(crate) const DEFAULT_MAX_ENTRY_TTL: u32 = 6_312_000;
pub fn load_snapshot(path: &Path) -> Result<Vec<(LedgerKey, LedgerEntry, Option<u32>)>> {
let snapshot = LedgerSnapshot::read_file(path).map_err(|e| ForkError::Cache {
path: path.to_path_buf(),
message: e.to_string(),
})?;
let entries = snapshot
.ledger_entries
.into_iter()
.map(|(key, (entry, live_until))| (*key, *entry, live_until))
.collect();
Ok(entries)
}
pub fn save_snapshot(
path: &Path,
entries: &[(LedgerKey, LedgerEntry, Option<u32>)],
sequence: u32,
timestamp: u64,
network_id: [u8; 32],
protocol_version: u32,
) -> Result<()> {
let ledger_entries = entries
.iter()
.map(|(key, entry, live_until)| {
(
Box::new(key.clone()),
(Box::new(entry.clone()), *live_until),
)
})
.collect();
let snapshot = LedgerSnapshot {
protocol_version,
sequence_number: sequence,
timestamp,
network_id,
base_reserve: DEFAULT_BASE_RESERVE,
min_persistent_entry_ttl: DEFAULT_MIN_PERSISTENT_ENTRY_TTL,
min_temp_entry_ttl: DEFAULT_MIN_TEMP_ENTRY_TTL,
max_entry_ttl: DEFAULT_MAX_ENTRY_TTL,
ledger_entries,
};
let tmp = tmp_sibling(path);
snapshot.write_file(&tmp).map_err(|e| ForkError::Cache {
path: tmp.clone(),
message: e.to_string(),
})?;
std::fs::rename(&tmp, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
ForkError::Cache {
path: path.to_path_buf(),
message: format!("rename {} -> {}: {e}", tmp.display(), path.display()),
}
})
}
fn tmp_sibling(path: &Path) -> std::path::PathBuf {
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("snapshot");
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp_name = format!(".{filename}.tmp.{pid}.{nanos}");
match parent {
Some(p) => p.join(tmp_name),
None => std::path::PathBuf::from(tmp_name),
}
}
#[cfg(test)]
mod tests {
use super::*;
use soroban_env_host::xdr::{
ConfigSettingId, LedgerEntry, LedgerEntryData, LedgerEntryExt, LedgerKey,
LedgerKeyConfigSetting,
};
use std::io::Write;
fn dummy_entry() -> (LedgerKey, LedgerEntry, Option<u32>) {
let key = LedgerKey::ConfigSetting(LedgerKeyConfigSetting {
config_setting_id: ConfigSettingId::ContractMaxSizeBytes,
});
let entry = LedgerEntry {
last_modified_ledger_seq: 42,
data: LedgerEntryData::ConfigSetting(
soroban_env_host::xdr::ConfigSettingEntry::ContractMaxSizeBytes(65_536),
),
ext: LedgerEntryExt::V0,
};
(key, entry, None)
}
#[test]
fn save_then_load_preserves_entries() {
let tmp = tempfile_path("roundtrip.json");
let original = vec![dummy_entry()];
save_snapshot(&tmp, &original, 100, 1_234_567, [0xAB; 32], 25).expect("save_snapshot");
let loaded = load_snapshot(&tmp).expect("load_snapshot");
assert_eq!(loaded.len(), original.len());
assert_eq!(loaded[0].0, original[0].0);
assert_eq!(loaded[0].2, original[0].2);
std::fs::remove_file(&tmp).ok();
}
#[test]
fn load_missing_file_returns_cache_error() {
let path = std::path::PathBuf::from("/nonexistent/path/soroban-fork-test.json");
let err = load_snapshot(&path).unwrap_err();
assert!(matches!(err, ForkError::Cache { .. }));
}
#[test]
fn load_malformed_file_returns_cache_error() {
let tmp = tempfile_path("malformed.json");
let mut f = std::fs::File::create(&tmp).unwrap();
f.write_all(b"{not json").unwrap();
drop(f);
let err = load_snapshot(&tmp).unwrap_err();
assert!(matches!(err, ForkError::Cache { .. }));
std::fs::remove_file(&tmp).ok();
}
fn tempfile_path(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("soroban-fork-test-{}-{name}", std::process::id()));
p
}
#[test]
fn tmp_sibling_lives_next_to_target() {
let target = std::path::PathBuf::from("/tmp/cache.json");
let tmp = tmp_sibling(&target);
assert_eq!(tmp.parent(), Some(std::path::Path::new("/tmp")));
let name = tmp.file_name().and_then(|n| n.to_str()).unwrap();
assert!(name.starts_with(".cache.json.tmp."));
}
#[test]
fn tmp_sibling_handles_bare_filename() {
let target = std::path::PathBuf::from("cache.json");
let tmp = tmp_sibling(&target);
let name = tmp.file_name().and_then(|n| n.to_str()).unwrap();
assert!(name.starts_with(".cache.json.tmp."));
}
#[test]
fn save_is_atomic_no_tmp_left_behind() {
let target = tempfile_path("atomic.json");
save_snapshot(&target, &[dummy_entry()], 1, 1, [0; 32], 25).expect("save");
let parent = target.parent().unwrap();
let leftover_tmps: Vec<_> = std::fs::read_dir(parent)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.map(|n| {
n.starts_with(&format!(
".{}.tmp.",
target.file_name().unwrap().to_str().unwrap()
))
})
.unwrap_or(false)
})
.collect();
assert!(leftover_tmps.is_empty(), "tmp leftovers: {leftover_tmps:?}");
std::fs::remove_file(&target).ok();
}
}