use crate::config;
use crate::data::client::merkle::MerkleBatchPaymentResult;
use crate::error::Result;
use std::fs::{self, DirEntry, File};
use std::hash::{Hash, Hasher};
use std::io::{BufReader, BufWriter};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, info, warn};
const PAYMENT_EXPIRATION_SECS: u64 = 7 * 24 * 60 * 60;
const PAYMENTS_SUBDIR: &str = "payments";
fn payments_dir() -> Result<PathBuf> {
let dir = config::data_dir()?.join(PAYMENTS_SUBDIR);
fs::create_dir_all(&dir)?;
Ok(dir)
}
fn file_hash_key(file_path: &str) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
file_path.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub fn save(file_path: &str, result: &MerkleBatchPaymentResult) -> Result<PathBuf> {
let dir = payments_dir()?;
let ts = if result.merkle_payment_timestamp > 0 {
result.merkle_payment_timestamp
} else {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
};
let path = dir.join(format!("{ts}_{}", file_hash_key(file_path)));
let handle = File::create(&path)?;
rmp_serde::encode::write(&mut BufWriter::new(handle), result)
.map_err(|e| crate::error::Error::Io(std::io::Error::other(e.to_string())))?;
debug!(
"Cached merkle payment receipt for {file_path:?} to {}",
path.display()
);
Ok(path)
}
pub fn try_save(file_path: &str, result: &MerkleBatchPaymentResult) {
if let Err(e) = save(file_path, result) {
warn!(
"Failed to cache merkle payment receipt for {file_path:?}: {e}. \
Upload will proceed without resume support."
);
}
}
pub fn load_for_file(file_path: &str) -> Result<Option<(PathBuf, MerkleBatchPaymentResult)>> {
cleanup_outdated();
let dir = payments_dir()?;
let key = file_hash_key(file_path);
let read_dir = match fs::read_dir(&dir) {
Ok(rd) => rd,
Err(e) => {
debug!("Could not read payments dir {}: {e}", dir.display());
return Ok(None);
}
};
for entry in read_dir.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.contains(&key) {
continue;
}
if is_expired_filename(name) {
continue;
}
match read_receipt(&path) {
Ok(receipt) => {
info!(
"Found previous merkle upload attempt for {file_path}, \
resuming with payment cached at {}",
path.display()
);
return Ok(Some((path, receipt)));
}
Err(e) => {
warn!(
"Cached merkle receipt at {} is unreadable ({e}). \
Ignoring and starting a fresh upload.",
path.display()
);
}
}
}
Ok(None)
}
pub fn try_load_for_file(file_path: &str) -> Option<(PathBuf, MerkleBatchPaymentResult)> {
match load_for_file(file_path) {
Ok(opt) => opt,
Err(e) => {
warn!(
"Failed to look up cached merkle receipt for {file_path:?}: {e}. \
Starting a fresh upload."
);
None
}
}
}
pub fn delete_for_file(file_path: &str) -> Result<()> {
let dir = payments_dir()?;
let key = file_hash_key(file_path);
if let Ok(read_dir) = fs::read_dir(&dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.contains(&key) {
let _ = fs::remove_file(&path);
debug!("Deleted cached merkle receipt {}", path.display());
}
}
}
}
Ok(())
}
pub fn try_delete_for_file(file_path: &str) {
if let Err(e) = delete_for_file(file_path) {
warn!(
"Failed to delete cached merkle receipt for {file_path:?}: {e}. \
Will be cleaned up after expiry."
);
}
}
pub fn cleanup_outdated() {
let Ok(dir) = payments_dir() else {
return;
};
let Ok(read_dir) = fs::read_dir(&dir) else {
return;
};
for entry in read_dir.flatten() {
if is_expired_entry(&entry) {
let path = entry.path();
info!(
"Removing expired cached merkle payment file: {}",
path.display()
);
let _ = fs::remove_file(path);
}
}
}
fn is_expired_entry(entry: &DirEntry) -> bool {
let path = entry.path();
if !path.is_file() {
return false;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
is_expired_filename(name)
}
fn is_expired_filename(name: &str) -> bool {
let ts_str = match name.split_once('_') {
Some((ts, _)) => ts,
None => return false,
};
let Ok(ts) = ts_str.parse::<u64>() else {
return false;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
now > ts.saturating_add(PAYMENT_EXPIRATION_SECS)
}
fn read_receipt(path: &Path) -> Result<MerkleBatchPaymentResult> {
let handle = File::open(path)?;
let receipt: MerkleBatchPaymentResult = rmp_serde::decode::from_read(BufReader::new(handle))
.map_err(|e| crate::error::Error::Io(std::io::Error::other(e.to_string())))?;
Ok(receipt)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn dummy_receipt(ts: u64) -> MerkleBatchPaymentResult {
let mut proofs: HashMap<[u8; 32], Vec<u8>> = HashMap::new();
proofs.insert([0u8; 32], vec![1, 2, 3]);
MerkleBatchPaymentResult {
proofs,
chunk_count: 1,
storage_cost_atto: "0".to_string(),
gas_cost_wei: 0,
merkle_payment_timestamp: ts,
}
}
#[test]
fn file_hash_key_is_stable() {
let a = file_hash_key("/tmp/some/file.bin");
let b = file_hash_key("/tmp/some/file.bin");
assert_eq!(a, b);
let c = file_hash_key("/tmp/some/other.bin");
assert_ne!(a, c);
}
#[test]
fn expired_filename_detected() {
let stale = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(PAYMENT_EXPIRATION_SECS + 60);
let name = format!("{stale}_abcd1234");
assert!(is_expired_filename(&name));
let fresh = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(60);
let name = format!("{fresh}_abcd1234");
assert!(!is_expired_filename(&name));
}
#[test]
fn malformed_filename_is_not_expired() {
assert!(!is_expired_filename("nonsense"));
assert!(!is_expired_filename("not_a_number_abcd1234"));
}
#[test]
fn roundtrip_save_load_delete() -> Result<()> {
let file_path = format!(
"/tmp/anselme-resumable-merkle-test-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let receipt = dummy_receipt(ts);
let saved_path = save(&file_path, &receipt)?;
assert!(saved_path.exists());
let loaded = load_for_file(&file_path)?;
let (loaded_path, loaded_receipt) = loaded.expect("receipt should be loadable");
assert_eq!(loaded_path, saved_path);
assert_eq!(loaded_receipt.chunk_count, receipt.chunk_count);
assert_eq!(loaded_receipt.merkle_payment_timestamp, ts);
delete_for_file(&file_path)?;
assert!(load_for_file(&file_path)?.is_none());
Ok(())
}
}