ffsync 1.2.0

Chunk-level file synchronization for big files and fast links
Documentation
//! Persistent per-file chunk index (D15): the warm-start state.
//!
//! One sidecar file per synced target, `<target>.ffsync-index`, storing what
//! the last successful sync knew about the file: identity (size, mtime,
//! chunk-tree root) and the full ordered chunk list — the same
//! `(hash, offset, length)` triples the wire manifest uses.
//!
//! Invariants (D15):
//! - Written only from verified state: the receiver writes at commit (after
//!   VERIFY + rename), the sender after a full scan (content is the source
//!   of truth on the sender side by definition).
//! - Validated, never trusted: a warm start requires size AND mtime to match
//!   the index; otherwise the index is treated as missing (cold scan).
//! - Never used as write evidence without hash verification (PLAN_LOCAL's
//!   hash field covers reads on the receiver; D12).

use std::fs::File;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};

use blake3::Hash;

use crate::chunking::{Chunk, chunk_root};

/// Index format magic + version: `FFSYIX02` (v2, D19 — the header carries
/// the CDC parameters that produced the chunk list; a v1 `FFSYIX01` file is
/// not an index to this reader — treated as missing, rewritten at the next
/// commit, no migration machinery). A mismatch means the file is not an
/// index or was written by an incompatible version — treat as missing.
const MAGIC: [u8; 8] = *b"FFSYIX02";

/// Fixed-size header: magic (8) + file size (8) + mtime secs (8) + mtime
/// subsec nanos (4) + CDC min/avg/max (12, D19) + root (32) + chunk count
/// (8) = 80 bytes.
const HEADER_LEN: usize = 8 + 8 + 8 + 4 + 12 + 32 + 8;

/// One chunk entry: hash 32 B + offset u64 + length u32 = 44 bytes (the
/// wire-manifest layout, D15).
const ENTRY_LEN: usize = 32 + 8 + 4;

/// The persisted state of one file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileIndex {
    /// File size the index was written for.
    pub file_size: u64,
    /// File mtime (unix seconds + subsec nanos) at the time the index was
    /// written. Nanosecond granularity matters: whole-second mtimes make two
    /// writes within the same second indistinguishable, so a stale index
    /// would validate against modified content (found by the warm-start
    /// loopback test — the mtime convention must match `--checksum`
    /// semantics, D11/D15).
    pub file_mtime: (u64, u32),
    /// The CDC parameters (min, avg, max) that produced the chunk list
    /// (D19). The warm start serves the list only when the session's
    /// parameters match — a foreign layout would hash-match poorly and
    /// could never reproduce the root.
    pub cdc: (u32, u32, u32),
    /// Chunk-tree root of the indexed content.
    pub root: Hash,
    /// Ordered chunk list (canonical file order — the root is
    /// order-sensitive, see `chunking::types::chunk_root`).
    pub chunks: Vec<Chunk>,
}

/// Reads a file's mtime as (unix secs, subsec nanos), or `None` if
/// unavailable.
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 {
    /// Builds an index from a scanned chunk list and the file's metadata.
    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,
        }
    }

    /// Serializes to bytes (little-endian, hand-rolled per D1/D15).
    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
    }

    /// Parses an index from bytes. Returns `None` on any structural problem
    /// (bad magic, truncation, trailing garbage, root mismatch) — callers
    /// treat the index as missing, never as partially valid.
    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()?);
        // Overflow guard: count × 44 must fit the remaining bytes exactly.
        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; // trailing garbage
        }
        // Self-integrity check (D15): the stored root must equal the root
        // derived from the decoded chunk list. This catches corruption in
        // the root *or* any chunk entry at load time — O(chunk count) of
        // in-memory hashing, no file I/O — so a corrupt index is reliably
        // refused rather than silently mis-serving warm starts. (Detection
        // is not strictly required for correctness — plan reads are
        // hash-verified and VERIFY is the oracle — but a warm start that
        // fails late is expensive; failing at load is free.)
        if chunk_root(&chunks) != root {
            return None;
        }
        Some(Self {
            file_size,
            file_mtime: (secs, nanos),
            cdc,
            root,
            chunks,
        })
    }

    /// Validates the index against the file's current stat AND the session's
    /// CDC parameters (D19). Size must match exactly, mtime must match at
    /// nanosecond granularity, and the stored parameters must equal the
    /// ones the session chunks with — a foreign layout would serve a chunk
    /// list the current session cannot reproduce. Any mismatch or missing
    /// file ⇒ `None` (cold start).
    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(())
    }
}

/// The sidecar index path for a target path (single source of truth for the
/// naming convention, like `Reconstructor::temp_path_for`). The suffix is
/// *appended*, not substituted for the last extension: `a.mkv` becomes
/// `a.mkv.ffsync-index`, so the target name is recoverable from the index
/// name by stripping the suffix — required by the orphan cleanup, and the
/// convention the concept doc originally described (D6's deviation note
/// applies only to the temp file, which needs no reverse mapping).
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)
}

/// Writes the index atomically: temp file + rename (same pattern as the
/// reconstruction commit, D6). A crash mid-write leaves the old index (or a
/// `<path>.ffsync-index-tmp` remnant, harmless).
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(); // durability of the resume/warm state is cheap here
    }
    std::fs::rename(&tmp_path, &idx_path)
}

/// Loads and returns the index for `path` if it exists, parses cleanly, and
/// validates against the file's current stat and the session's CDC
/// parameters (D19). Any failure ⇒ `None` (warm start refused, cold scan).
/// Best-effort removal of a corrupt index is NOT done here — the next
/// successful commit overwrites it anyway.
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)
}

/// Loads an index without parameter validation — for callers that validate
/// later against session parameters they learn from the wire (or for the
/// corrupt-index probes in tests).
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)
}

/// Removes the index for a target (called when a sync replaces or removes
/// the target state, and by orphan cleanup).
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();
        // Truncation.
        bytes.truncate(bytes.len() - 10);
        assert!(FileIndex::decode(&bytes).is_none());
        // Trailing garbage.
        let mut bytes = idx.encode();
        bytes.extend_from_slice(&[0u8; 3]);
        assert!(FileIndex::decode(&bytes).is_none());
        // Wrong magic.
        let mut bytes = idx.encode();
        bytes[0] = b'X';
        assert!(FileIndex::decode(&bytes).is_none());
        // Chunk count beyond the payload.
        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"
        );

        // Size mismatch.
        std::fs::write(&path, vec![0u8; 999]).unwrap();
        assert!(
            idx.validate(&path, (64, 256, 1024)).is_none(),
            "size mismatch refuses"
        );

        // Missing file.
        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);

        // Corrupt the index on disk: load must refuse (not panic).
        let idx_path = index_path_for(&path);
        let mut bytes = std::fs::read(&idx_path).unwrap();
        bytes[40] ^= 0xFF; // inside the root hash
        std::fs::write(&idx_path, &bytes).unwrap();
        assert!(load_index(&path).is_none(), "corrupt index refuses");

        std::fs::remove_dir_all(&dir).unwrap();
    }
}