use crate::sync::error::{SyncError, SyncResult, UPDATE_REQUIRED_MESSAGE};
use crate::sync::persistence::{
acquire_persistence_lock, atomic_write, atomic_write_with, reject_symlink,
};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{BufRead, BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub const SYNC_CHECKPOINT_FORMAT_V1: &str = "# appcore-sync-checkpoint-v1";
pub const MAX_CHECKPOINT_FILE_BYTES: u64 = 8 * 1024 * 1024;
pub const MAX_CHECKPOINT_PEER_ID_BYTES: usize = 256;
pub const MAX_CHECKPOINT_RECORDS: usize = 65_536;
const MAX_CHECKPOINT_LINE_BYTES: usize = MAX_CHECKPOINT_PEER_ID_BYTES + 87;
const CHECKPOINT_BUFFER_BYTES: usize = 16 * 1024;
type Checkpoint = (u64, String);
type CheckpointMap = BTreeMap<String, Checkpoint>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LineStatus {
Complete,
Partial,
End,
}
pub trait SyncCheckpointStore: Send + Sync {
fn get_checkpoint(&self, peer_id: &str) -> SyncResult<Option<(u64, String)>>;
fn set_checkpoint(&self, peer_id: &str, sequence: u64, hash: &str) -> SyncResult<()>;
fn get_last_sequence(&self, peer_id: &str) -> SyncResult<u64> {
Ok(self
.get_checkpoint(peer_id)?
.map(|(seq, _)| seq)
.unwrap_or(0))
}
fn set_last_sequence(&self, peer_id: &str, sequence: u64) -> SyncResult<()> {
let hash = self
.get_checkpoint(peer_id)?
.map(|(_, h)| h)
.unwrap_or_default();
self.set_checkpoint(peer_id, sequence, &hash)
}
}
#[derive(Debug, Clone, Default)]
pub struct InMemorySyncCheckpointStore {
checkpoints: Arc<Mutex<CheckpointMap>>,
}
impl InMemorySyncCheckpointStore {
pub fn new() -> Self {
Self {
checkpoints: Arc::new(Mutex::new(BTreeMap::new())),
}
}
}
impl SyncCheckpointStore for InMemorySyncCheckpointStore {
fn get_checkpoint(&self, peer_id: &str) -> SyncResult<Option<(u64, String)>> {
validate_peer_id(peer_id)?;
let guard = self.checkpoints.lock();
Ok(guard.get(peer_id).cloned())
}
fn set_checkpoint(&self, peer_id: &str, sequence: u64, hash: &str) -> SyncResult<()> {
validate_peer_id(peer_id)?;
validate_checkpoint_hash(hash)?;
let mut guard = self.checkpoints.lock();
guard.insert(peer_id.to_string(), (sequence, hash.to_string()));
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct FileSyncCheckpointStore {
file_path: PathBuf,
lock: Arc<Mutex<()>>,
}
impl FileSyncCheckpointStore {
pub fn new(file_path: impl Into<PathBuf>) -> SyncResult<Self> {
let file_path = file_path.into();
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)
.map_err(|err| crate::sync::error::SyncError::ReplicationFailed(err.to_string()))?;
}
let _process_lock = acquire_persistence_lock(&file_path)?;
if !file_path.exists() {
atomic_write(
&file_path,
format!("{SYNC_CHECKPOINT_FORMAT_V1}\n").as_bytes(),
)?;
}
let store = Self {
file_path,
lock: Arc::new(Mutex::new(())),
};
store.validate_state()?;
Ok(store)
}
pub fn file_path(&self) -> &Path {
&self.file_path
}
fn validate_state(&self) -> SyncResult<()> {
scan_checkpoint_file(&self.file_path, |_, _, _| Ok(()))
}
fn read_map(&self) -> SyncResult<CheckpointMap> {
let mut map = BTreeMap::new();
scan_checkpoint_file(&self.file_path, |peer_id, sequence, hash| {
map.insert(peer_id.to_string(), (sequence, hash.to_string()));
Ok(())
})?;
Ok(map)
}
fn write_map(&self, map: &CheckpointMap) -> SyncResult<()> {
if map.len() > MAX_CHECKPOINT_RECORDS {
return Err(checkpoint_failure("checkpoint record limit exceeded"));
}
let encoded_bytes = encoded_checkpoint_bytes(map)?;
if encoded_bytes > MAX_CHECKPOINT_FILE_BYTES {
return Err(checkpoint_failure(
"persistent file exceeds configured limit",
));
}
atomic_write_with(&self.file_path, |file| write_checkpoint_map(file, map))
}
}
impl SyncCheckpointStore for FileSyncCheckpointStore {
fn get_checkpoint(&self, peer_id: &str) -> SyncResult<Option<(u64, String)>> {
validate_peer_id(peer_id)?;
let _guard = self.lock.lock();
let _process_lock = acquire_persistence_lock(&self.file_path)?;
let mut checkpoint = None;
scan_checkpoint_file(&self.file_path, |stored_peer, sequence, hash| {
if stored_peer == peer_id {
checkpoint = Some((sequence, hash.to_string()));
}
Ok(())
})?;
Ok(checkpoint)
}
fn set_checkpoint(&self, peer_id: &str, sequence: u64, hash: &str) -> SyncResult<()> {
validate_peer_id(peer_id)?;
validate_checkpoint_hash(hash)?;
let _guard = self.lock.lock();
let _process_lock = acquire_persistence_lock(&self.file_path)?;
let mut map = self.read_map()?;
map.insert(peer_id.to_string(), (sequence, hash.to_string()));
self.write_map(&map)
}
}
fn validate_peer_id(peer_id: &str) -> SyncResult<()> {
if peer_id.is_empty() || peer_id.len() > MAX_CHECKPOINT_PEER_ID_BYTES {
return Err(crate::sync::error::SyncError::InvalidPeerId);
}
if !peer_id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | ':' | '-'))
{
return Err(crate::sync::error::SyncError::InvalidPeerId);
}
Ok(())
}
fn validate_checkpoint_hash(hash: &str) -> SyncResult<()> {
if hash.is_empty() || (hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())) {
return Ok(());
}
Err(crate::sync::error::SyncError::ReplicationFailed(
"invalid checkpoint hash".to_string(),
))
}
fn scan_checkpoint_file(
path: &Path,
mut visit: impl FnMut(&str, u64, &str) -> SyncResult<()>,
) -> SyncResult<()> {
reject_symlink(path)?;
let file = File::open(path).map_err(checkpoint_io)?;
if file.metadata().map_err(checkpoint_io)?.len() > MAX_CHECKPOINT_FILE_BYTES {
return Err(checkpoint_failure(
"persistent file exceeds configured limit",
));
}
let limited = file.take(MAX_CHECKPOINT_FILE_BYTES.saturating_add(1));
let mut reader = BufReader::with_capacity(CHECKPOINT_BUFFER_BYTES, limited);
let mut line = Vec::with_capacity(MAX_CHECKPOINT_LINE_BYTES);
let mut consumed = 0u64;
let marker = read_checkpoint_line(&mut reader, &mut line, &mut consumed)?;
validate_marker(marker, &line)?;
if marker == LineStatus::Partial {
return Ok(());
}
let mut records = 0usize;
loop {
let status = read_checkpoint_line(&mut reader, &mut line, &mut consumed)?;
if status == LineStatus::End {
return Ok(());
}
let text = checkpoint_line_text(&line)?;
if !text.trim().is_empty() {
records = records
.checked_add(1)
.filter(|count| *count <= MAX_CHECKPOINT_RECORDS)
.ok_or_else(|| checkpoint_failure("checkpoint record limit exceeded"))?;
let (peer_id, sequence, hash) = parse_checkpoint_line(text)?;
visit(peer_id, sequence, hash)?;
}
if status == LineStatus::Partial {
return Ok(());
}
}
}
fn read_checkpoint_line<R: BufRead>(
reader: &mut R,
line: &mut Vec<u8>,
consumed: &mut u64,
) -> SyncResult<LineStatus> {
line.clear();
loop {
let available = reader.fill_buf().map_err(checkpoint_io)?;
if available.is_empty() {
return Ok(if line.is_empty() {
LineStatus::End
} else {
LineStatus::Partial
});
}
let newline = available.iter().position(|byte| *byte == b'\n');
let used = newline.map_or(available.len(), |index| index + 1);
let body_bytes = newline.unwrap_or(available.len());
if line.len().saturating_add(body_bytes) > MAX_CHECKPOINT_LINE_BYTES {
return Err(checkpoint_failure("checkpoint line exceeds size limit"));
}
line.extend_from_slice(&available[..body_bytes]);
reader.consume(used);
*consumed = consumed.saturating_add(used as u64);
if *consumed > MAX_CHECKPOINT_FILE_BYTES {
return Err(checkpoint_failure(
"persistent file exceeds configured limit",
));
}
if newline.is_some() {
return Ok(LineStatus::Complete);
}
}
}
fn validate_marker(status: LineStatus, line: &[u8]) -> SyncResult<()> {
let marker =
std::str::from_utf8(line).map_err(|_| checkpoint_failure("invalid checkpoint UTF-8"))?;
if status == LineStatus::End || marker != SYNC_CHECKPOINT_FORMAT_V1 {
return Err(checkpoint_failure(UPDATE_REQUIRED_MESSAGE));
}
Ok(())
}
fn checkpoint_line_text(line: &[u8]) -> SyncResult<&str> {
let line = line.strip_suffix(b"\r").unwrap_or(line);
std::str::from_utf8(line).map_err(|_| checkpoint_failure("invalid checkpoint UTF-8"))
}
fn parse_checkpoint_line(line: &str) -> SyncResult<(&str, u64, &str)> {
let (peer_id, rest) = line
.split_once('=')
.ok_or_else(|| checkpoint_failure("invalid checkpoint line"))?;
validate_peer_id(peer_id)?;
let (sequence_text, hash) = rest.split_once(',').unwrap_or((rest, ""));
let sequence = sequence_text
.parse::<u64>()
.map_err(|_| checkpoint_failure("invalid checkpoint sequence"))?;
validate_checkpoint_hash(hash)?;
Ok((peer_id, sequence, hash))
}
fn encoded_checkpoint_bytes(map: &CheckpointMap) -> SyncResult<u64> {
let mut bytes = (SYNC_CHECKPOINT_FORMAT_V1.len() + 1) as u64;
for (peer_id, (sequence, hash)) in map {
let record_bytes = peer_id
.len()
.checked_add(decimal_digits(*sequence))
.and_then(|size| size.checked_add(hash.len() + 3))
.ok_or_else(|| checkpoint_failure("checkpoint size overflow"))?;
bytes = bytes
.checked_add(record_bytes as u64)
.ok_or_else(|| checkpoint_failure("checkpoint size overflow"))?;
}
Ok(bytes)
}
fn write_checkpoint_map(file: &mut File, map: &CheckpointMap) -> SyncResult<()> {
let mut writer = BufWriter::with_capacity(CHECKPOINT_BUFFER_BYTES, file);
writeln!(writer, "{SYNC_CHECKPOINT_FORMAT_V1}").map_err(checkpoint_io)?;
for (peer_id, (sequence, hash)) in map {
writeln!(writer, "{peer_id}={sequence},{hash}").map_err(checkpoint_io)?;
}
writer.flush().map_err(checkpoint_io)
}
fn checkpoint_io(error: std::io::Error) -> SyncError {
SyncError::ReplicationFailed(error.to_string())
}
fn checkpoint_failure(message: &str) -> SyncError {
SyncError::ReplicationFailed(message.to_string())
}
fn decimal_digits(mut value: u64) -> usize {
let mut digits = 1;
while value >= 10 {
value /= 10;
digits += 1;
}
digits
}