use std::fs::File;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use blake3::Hash;
use crate::chunking::{Chunk, chunk_root};
const MAGIC: [u8; 8] = *b"FFSYIX02";
const HEADER_LEN: usize = 8 + 8 + 8 + 4 + 12 + 32 + 8;
const ENTRY_LEN: usize = 32 + 8 + 4;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileIndex {
pub file_size: u64,
pub file_mtime: (u64, u32),
pub cdc: (u32, u32, u32),
pub root: Hash,
pub chunks: Vec<Chunk>,
}
pub fn stat_mtime(path: &Path) -> Option<(u64, u32)> {
let meta = std::fs::metadata(path).ok()?;
let d = meta
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?;
Some((d.as_secs(), d.subsec_nanos()))
}
impl FileIndex {
pub fn from_scan(
chunks: Vec<Chunk>,
file_size: u64,
file_mtime: (u64, u32),
cdc: (u32, u32, u32),
) -> Self {
let root = chunk_root(&chunks);
Self {
file_size,
file_mtime,
cdc,
root,
chunks,
}
}
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(HEADER_LEN + self.chunks.len() * ENTRY_LEN);
out.extend_from_slice(&MAGIC);
out.extend_from_slice(&self.file_size.to_le_bytes());
out.extend_from_slice(&self.file_mtime.0.to_le_bytes());
out.extend_from_slice(&self.file_mtime.1.to_le_bytes());
out.extend_from_slice(&self.cdc.0.to_le_bytes());
out.extend_from_slice(&self.cdc.1.to_le_bytes());
out.extend_from_slice(&self.cdc.2.to_le_bytes());
out.extend_from_slice(self.root.as_bytes());
out.extend_from_slice(&(self.chunks.len() as u64).to_le_bytes());
for c in &self.chunks {
out.extend_from_slice(c.hash.as_bytes());
out.extend_from_slice(&c.offset.to_le_bytes());
out.extend_from_slice(&(c.length as u32).to_le_bytes());
}
out
}
pub fn decode(bytes: &[u8]) -> Option<Self> {
if bytes.len() < HEADER_LEN || bytes[0..8] != MAGIC {
return None;
}
let file_size = u64::from_le_bytes(bytes[8..16].try_into().ok()?);
let secs = u64::from_le_bytes(bytes[16..24].try_into().ok()?);
let nanos = u32::from_le_bytes(bytes[24..28].try_into().ok()?);
let cdc = (
u32::from_le_bytes(bytes[28..32].try_into().ok()?),
u32::from_le_bytes(bytes[32..36].try_into().ok()?),
u32::from_le_bytes(bytes[36..40].try_into().ok()?),
);
let root = Hash::from_bytes(bytes[40..72].try_into().ok()?);
let count = u64::from_le_bytes(bytes[72..80].try_into().ok()?);
if count > (bytes.len() - HEADER_LEN) as u64 / ENTRY_LEN as u64 {
return None;
}
let mut chunks = Vec::with_capacity(count as usize);
let mut pos = HEADER_LEN;
for _ in 0..count {
let hash = Hash::from_bytes(bytes[pos..pos + 32].try_into().ok()?);
let offset = u64::from_le_bytes(bytes[pos + 32..pos + 40].try_into().ok()?);
let length = u32::from_le_bytes(bytes[pos + 40..pos + 44].try_into().ok()?);
chunks.push(Chunk {
offset,
length: length as usize,
hash,
});
pos += ENTRY_LEN;
}
if pos != bytes.len() {
return None; }
if chunk_root(&chunks) != root {
return None;
}
Some(Self {
file_size,
file_mtime: (secs, nanos),
cdc,
root,
chunks,
})
}
pub fn validate(&self, path: &Path, cdc: (u32, u32, u32)) -> Option<()> {
if self.cdc != cdc {
return None;
}
let meta = std::fs::metadata(path).ok()?;
if meta.len() != self.file_size {
return None;
}
if stat_mtime(path)? != self.file_mtime {
return None;
}
Some(())
}
}
pub fn index_path_for(target_path: &Path) -> PathBuf {
let mut name = target_path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(".ffsync-index");
target_path.with_file_name(name)
}
pub fn write_index(path: &Path, index: &FileIndex) -> io::Result<()> {
let idx_path = index_path_for(path);
let tmp_path = {
let mut name = idx_path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push("-tmp");
idx_path.with_file_name(name)
};
let bytes = index.encode();
{
let mut f = File::create(&tmp_path)?;
f.write_all(&bytes)?;
f.sync_all().ok(); }
std::fs::rename(&tmp_path, &idx_path)
}
pub fn load_index_with_cdc(path: &Path, cdc: (u32, u32, u32)) -> Option<FileIndex> {
let idx_path = index_path_for(path);
let mut f = File::open(&idx_path).ok()?;
let mut bytes = Vec::new();
f.read_to_end(&mut bytes).ok()?;
let index = FileIndex::decode(&bytes)?;
index.validate(path, cdc)?;
Some(index)
}
pub fn load_index(path: &Path) -> Option<FileIndex> {
let idx_path = index_path_for(path);
let mut f = File::open(&idx_path).ok()?;
let mut bytes = Vec::new();
f.read_to_end(&mut bytes).ok()?;
FileIndex::decode(&bytes)
}
pub fn remove_index(path: &Path) {
let _ = std::fs::remove_file(index_path_for(path));
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_chunks(n: usize) -> Vec<Chunk> {
(0..n)
.map(|i| Chunk {
offset: (i * 256) as u64,
length: 256,
hash: blake3::hash(format!("chunk-{i}").as_bytes()),
})
.collect()
}
#[test]
fn index_round_trip() {
let chunks = sample_chunks(7);
let idx = FileIndex::from_scan(chunks, 7 * 256, (1_700_000_000, 0), (64, 256, 1024));
let bytes = idx.encode();
assert_eq!(bytes.len(), HEADER_LEN + 7 * ENTRY_LEN);
let back = FileIndex::decode(&bytes).expect("decode");
assert_eq!(back, idx);
}
#[test]
fn index_decode_rejects_garbage() {
assert!(FileIndex::decode(b"").is_none());
assert!(FileIndex::decode(b"nonsense").is_none());
let idx = FileIndex::from_scan(sample_chunks(3), 768, (42, 0), (64, 256, 1024));
let mut bytes = idx.encode();
bytes.truncate(bytes.len() - 10);
assert!(FileIndex::decode(&bytes).is_none());
let mut bytes = idx.encode();
bytes.extend_from_slice(&[0u8; 3]);
assert!(FileIndex::decode(&bytes).is_none());
let mut bytes = idx.encode();
bytes[0] = b'X';
assert!(FileIndex::decode(&bytes).is_none());
let mut bytes = idx.encode();
bytes[72..80].copy_from_slice(&999u64.to_le_bytes());
assert!(FileIndex::decode(&bytes).is_none());
}
#[test]
fn index_validate_checks_size_mtime_and_cdc() {
let dir = std::env::temp_dir().join(format!("ffsync_idx_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("file.bin");
std::fs::write(&path, vec![0u8; 1000]).unwrap();
let mtime = crate::index::stat_mtime(&path).unwrap();
let idx = FileIndex::from_scan(sample_chunks(4), 1000, mtime, (64, 256, 1024));
assert!(
idx.validate(&path, (64, 256, 1024)).is_some(),
"matching stat + parameters validate"
);
assert!(
idx.validate(&path, (16, 64, 256)).is_none(),
"parameter mismatch refuses (D19): the stored layout is foreign"
);
std::fs::write(&path, vec![0u8; 999]).unwrap();
assert!(
idx.validate(&path, (64, 256, 1024)).is_none(),
"size mismatch refuses"
);
std::fs::remove_file(&path).unwrap();
assert!(
idx.validate(&path, (64, 256, 1024)).is_none(),
"missing file refuses"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn index_write_and_load_round_trip() {
let dir = std::env::temp_dir().join(format!("ffsync_idx_test2_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("file.bin");
std::fs::write(&path, vec![7u8; 512]).unwrap();
let mtime = crate::index::stat_mtime(&path).unwrap();
let idx = FileIndex::from_scan(sample_chunks(2), 512, mtime, (64, 256, 1024));
write_index(&path, &idx).unwrap();
let loaded = load_index(&path).expect("valid index loads");
assert_eq!(loaded, idx);
let idx_path = index_path_for(&path);
let mut bytes = std::fs::read(&idx_path).unwrap();
bytes[40] ^= 0xFF; std::fs::write(&idx_path, &bytes).unwrap();
assert!(load_index(&path).is_none(), "corrupt index refuses");
std::fs::remove_dir_all(&dir).unwrap();
}
}