Skip to main content

readcon_core/
frame_index.rs

1//! Frame offset index and its checked `.rcidx` sidecar.
2//!
3//! The sidecar stores the byte offset of every frame start in an
4//! uncompressed CON file, so later reads skip the offset scan.
5//! A sidecar is only used when its checksum is intact, its stored size
6//! matches the data, and its offsets are the ones a scan finds: the frame
7//! skip from each offset must end at the next. A table that passes is the
8//! scan of the data however old the sidecar is, so neither the mtime nor
9//! a content hash is compared. The check runs per frame, in parallel with
10//! the `parallel` feature, and a table that fails it is rebuilt.
11//!
12//! Layout (all integers little-endian):
13//!
14//! | bytes | field                                  |
15//! |-------|----------------------------------------|
16//! | 8     | magic `RCIDX\0\0\0`                    |
17//! | 4     | format version (2)                     |
18//! | 4     | reserved (0)                           |
19//! | 8     | data file size in bytes                |
20//! | 8     | content hash of every data byte, written for earlier version 2 readers and not read here |
21//! | 8     | frame count `n`                        |
22//! | 8 n   | frame start offsets                    |
23//! | 8     | FNV-1a 64 of every preceding byte      |
24
25use std::io::{self, Write};
26use std::path::{Path, PathBuf};
27
28use crate::error::ParseError;
29use crate::iterators::ConFrameIterator;
30
31const MAGIC: &[u8; 8] = b"RCIDX\0\0\0";
32const VERSION: u32 = 2;
33const HEADER_LEN: usize = 8 + 4 + 4 + 8 + 8 + 8;
34const COUNT_AT: usize = HEADER_LEN - 8;
35
36/// Frame start offsets for one data file, plus its total length.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct FrameIndex {
39    /// Byte offset of each frame start, strictly increasing.
40    pub offsets: Vec<u64>,
41    /// Length of the data in bytes; the last frame ends here.
42    pub data_len: u64,
43}
44
45impl FrameIndex {
46    /// Number of frames.
47    pub fn len(&self) -> usize {
48        self.offsets.len()
49    }
50
51    /// True when the index has no frames.
52    pub fn is_empty(&self) -> bool {
53        self.offsets.is_empty()
54    }
55
56    /// Byte range `[start, end)` of frame `i`.
57    pub fn range(&self, i: usize) -> Option<std::ops::Range<usize>> {
58        let start = *self.offsets.get(i)? as usize;
59        let end = self.offsets.get(i + 1).copied().unwrap_or(self.data_len) as usize;
60        Some(start..end)
61    }
62
63    /// Scan `text` for frame starts, failing on the first malformed frame.
64    pub fn scan(text: &str) -> Result<Self, ParseError> {
65        Ok(FrameIndex {
66            offsets: frame_start_offsets_checked(text)?
67                .into_iter()
68                .map(|o| o as u64)
69                .collect(),
70            data_len: text.len() as u64,
71        })
72    }
73}
74
75/// Frame start offsets, returning the skip error instead of truncating.
76///
77/// [`crate::iterators::frame_start_offsets`] stops silently at the first
78/// malformed frame; an index written to disk must not do that.
79pub fn frame_start_offsets_checked(text: &str) -> Result<Vec<usize>, ParseError> {
80    let mut out = Vec::new();
81    let mut scanner = ConFrameIterator::new(text);
82    loop {
83        let start = scanner.byte_pos();
84        if no_frame_at(text, start) {
85            break;
86        }
87        out.push(start);
88        match scanner.forward_fast() {
89            Some(Ok(())) => {}
90            Some(Err(e)) => return Err(e),
91            None => return Err(ParseError::IncompleteFrame),
92        }
93    }
94    Ok(out)
95}
96
97/// True when the scan ends at `pos`: nothing or only whitespace follows.
98fn no_frame_at(text: &str, pos: usize) -> bool {
99    pos >= text.len() || text.as_bytes()[pos..].iter().all(u8::is_ascii_whitespace)
100}
101
102/// True when `offsets` is exactly what [`frame_start_offsets_checked`]
103/// returns for `text`.
104///
105/// The first offset must be 0, and the skip from each offset must end at
106/// the next one, or at the end of the data for the last. A skip reads no
107/// state from earlier frames, so the frames are checked independently.
108/// The skip gets the next offset as a hint, which changes only its speed.
109fn offsets_match_scan(text: &str, offsets: &[u64]) -> bool {
110    let Some(&first) = offsets.first() else {
111        return no_frame_at(text, 0);
112    };
113    let frame_ok = |i: usize| {
114        let start = offsets[i] as usize;
115        // Also starts the skip on a character boundary.
116        let starts_line = start == 0 || text.as_bytes().get(start - 1) == Some(&b'\n');
117        if !starts_line || no_frame_at(text, start) {
118            return false;
119        }
120        let end = offsets.get(i + 1).map_or(text.len(), |&o| o as usize);
121        let mut skip = ConFrameIterator::starting_at(text, start);
122        if !matches!(skip.forward_fast_to(Some(end)), Some(Ok(()))) {
123            return false;
124        }
125        match offsets.get(i + 1) {
126            Some(&next) => skip.byte_pos() as u64 == next,
127            None => no_frame_at(text, skip.byte_pos()),
128        }
129    };
130    if first != 0 {
131        return false;
132    }
133    #[cfg(feature = "parallel")]
134    {
135        use rayon::prelude::*;
136        (0..offsets.len()).into_par_iter().all(frame_ok)
137    }
138    #[cfg(not(feature = "parallel"))]
139    {
140        (0..offsets.len()).all(frame_ok)
141    }
142}
143
144/// Smallest slice before UTF-8 validation is split on newlines.
145const UTF8_SLICE_MIN: usize = 8 << 20;
146
147/// `k * len / n_slices` without wrapping the numerator.
148///
149/// The cut is always in `0..=len`. A wrapping `usize` product can point
150/// past the buffer on a huge file.
151fn utf8_slice_target(len: usize, index: usize, n_slices: usize) -> usize {
152    let n = n_slices.max(1) as u128;
153    let target = (index as u128) * (len as u128) / n;
154    usize::try_from(target).unwrap_or(len).min(len)
155}
156
157/// `err` came from `bytes[start..]`. Report it against the whole buffer.
158///
159/// `error_len` counts only the invalid sequence. A multi-byte lead followed
160/// by a non-continuation is `Some`, and the next byte is what proves it.
161/// A prefix that stops on the lead is an incomplete character, so
162/// `from_utf8` reports `None` and the error looks truncated.
163fn reject_with_absolute_utf8(
164    bytes: &[u8],
165    start: usize,
166    err: std::str::Utf8Error,
167) -> Result<(), std::str::Utf8Error> {
168    let end = match err.error_len() {
169        Some(len) => start
170            .saturating_add(err.valid_up_to())
171            .saturating_add(len)
172            .saturating_add(1),
173        None => bytes.len(),
174    };
175    let end = end.min(bytes.len());
176    if let Err(adjusted) = std::str::from_utf8(&bytes[..end]) {
177        return Err(adjusted);
178    }
179    std::str::from_utf8(bytes).map(|_| ())
180}
181
182/// Validate UTF-8 in newline-aligned slices, in parallel when available.
183///
184/// A newline byte never occurs inside a multi-byte UTF-8 sequence, so
185/// slices cut just after a `\n` validate independently.
186pub fn validate_utf8_sliced(bytes: &[u8]) -> Result<&str, std::str::Utf8Error> {
187    let n_slices = (bytes.len() / UTF8_SLICE_MIN).max(1);
188    let mut cuts = Vec::with_capacity(n_slices + 1);
189    cuts.push(0usize);
190    for k in 1..n_slices {
191        let target = utf8_slice_target(bytes.len(), k, n_slices);
192        let prev = *cuts.last().unwrap();
193        if target <= prev || target >= bytes.len() {
194            continue;
195        }
196        match memchr::memchr(b'\n', &bytes[target..]) {
197            Some(p) => cuts.push(target + p + 1),
198            None => break,
199        }
200    }
201    cuts.push(bytes.len());
202    cuts.dedup();
203    let check = |w: &[usize]| -> Result<(), std::str::Utf8Error> {
204        match std::str::from_utf8(&bytes[w[0]..w[1]]) {
205            Ok(_) => Ok(()),
206            Err(err) => reject_with_absolute_utf8(bytes, w[0], err),
207        }
208    };
209    #[cfg(feature = "parallel")]
210    {
211        use rayon::prelude::*;
212        let windows: Vec<&[usize]> = cuts.windows(2).collect();
213        windows.par_iter().try_for_each(|w| check(w))?;
214    }
215    #[cfg(not(feature = "parallel"))]
216    {
217        for w in cuts.windows(2) {
218            check(w)?;
219        }
220    }
221    // SAFETY: every byte lies in exactly one validated slice, and slice
222    // boundaries sit after ASCII newlines, so the whole buffer is UTF-8.
223    Ok(unsafe { std::str::from_utf8_unchecked(bytes) })
224}
225
226/// Path of the sidecar for `path`: `foo.con` becomes `foo.con.rcidx`.
227pub fn sidecar_path(path: &Path) -> PathBuf {
228    let mut s = path.as_os_str().to_owned();
229    s.push(".rcidx");
230    PathBuf::from(s)
231}
232
233fn fnv1a(mut h: u64, bytes: &[u8]) -> u64 {
234    for &b in bytes {
235        h ^= b as u64;
236        h = h.wrapping_mul(0x0000_0100_0000_01b3);
237    }
238    h
239}
240const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
241
242/// Bytes per block of [`content_hash`]. Blocks are hashed on their own and
243/// folded in order, so the value does not depend on the thread count.
244const HASH_BLOCK: usize = 1 << 20;
245
246/// Eight independent multiply and rotate lanes over 64-byte strides.
247///
248/// Each step is a bijection of its lane for a fixed word, so changing any
249/// one word of a block always changes that lane.
250fn block_hash(block: &[u8]) -> u64 {
251    const K: u64 = 0x9e37_79b9_7f4a_7c15;
252    let mut lanes: [u64; 8] = std::array::from_fn(|k| FNV_OFFSET ^ k as u64);
253    let (strides, rest) = block.as_chunks::<64>();
254    for stride in strides {
255        for (lane, w) in lanes.iter_mut().zip(stride.as_chunks::<8>().0) {
256            *lane = (*lane ^ u64::from_le_bytes(*w)).wrapping_mul(K).rotate_left(31);
257        }
258    }
259    let tail = fnv1a(FNV_OFFSET, rest);
260    lanes.iter().fold(tail, |h, l| fnv1a(h, &l.to_le_bytes()))
261}
262
263/// Hash of every byte: [`block_hash`] per [`HASH_BLOCK`], folded with the
264/// length by FNV-1a. Not cryptographic; it detects edits, not attacks.
265fn content_hash(bytes: &[u8]) -> u64 {
266    #[cfg(feature = "parallel")]
267    let blocks: Vec<u64> = {
268        use rayon::prelude::*;
269        bytes.par_chunks(HASH_BLOCK).map(block_hash).collect()
270    };
271    #[cfg(not(feature = "parallel"))]
272    let blocks: Vec<u64> = bytes.chunks(HASH_BLOCK).map(block_hash).collect();
273    let seed = fnv1a(FNV_OFFSET, &(bytes.len() as u64).to_le_bytes());
274    blocks.iter().fold(seed, |h, b| fnv1a(h, &b.to_le_bytes()))
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278struct Fingerprint {
279    size: u64,
280    content: u64,
281}
282
283impl Fingerprint {
284    fn of(data: &[u8]) -> Self {
285        Fingerprint {
286            size: data.len() as u64,
287            content: content_hash(data),
288        }
289    }
290}
291
292/// Call `f` on the bytes now at `path`.
293fn with_mapped<T>(path: &Path, f: impl FnOnce(&[u8]) -> T) -> io::Result<T> {
294    let file = std::fs::File::open(path)?;
295    if file.metadata()?.len() == 0 {
296        return Ok(f(&[]));
297    }
298    // SAFETY: as for every mapped reader in this crate, the file must not
299    // be truncated or rewritten while the map is alive.
300    let map = unsafe { memmap2::Mmap::map(&file)? };
301    Ok(f(&map))
302}
303
304fn fingerprint(path: &Path) -> io::Result<Fingerprint> {
305    with_mapped(path, Fingerprint::of)
306}
307
308fn encode(fp: Fingerprint, idx: &FrameIndex) -> Vec<u8> {
309    let mut out = Vec::with_capacity(HEADER_LEN + 8 * idx.offsets.len() + 8);
310    out.extend_from_slice(MAGIC);
311    out.extend_from_slice(&VERSION.to_le_bytes());
312    out.extend_from_slice(&0u32.to_le_bytes());
313    out.extend_from_slice(&fp.size.to_le_bytes());
314    out.extend_from_slice(&fp.content.to_le_bytes());
315    out.extend_from_slice(&(idx.offsets.len() as u64).to_le_bytes());
316    for o in &idx.offsets {
317        out.extend_from_slice(&o.to_le_bytes());
318    }
319    let sum = fnv1a(FNV_OFFSET, &out);
320    out.extend_from_slice(&sum.to_le_bytes());
321    out
322}
323
324fn u64_at(b: &[u8], at: usize) -> u64 {
325    u64::from_le_bytes(b[at..at + 8].try_into().unwrap())
326}
327
328/// Decode a sidecar and check it against `text`; `None` means stale or
329/// corrupt. The stored size is a fast reject and the content hash is not
330/// read, since [`offsets_match_scan`] alone decides the table.
331fn decode(bytes: &[u8], text: &str) -> Option<FrameIndex> {
332    if bytes.len() < HEADER_LEN + 8 || &bytes[..8] != MAGIC {
333        return None;
334    }
335    if u32::from_le_bytes(bytes[8..12].try_into().unwrap()) != VERSION {
336        return None;
337    }
338    let body = &bytes[..bytes.len() - 8];
339    if fnv1a(FNV_OFFSET, body) != u64_at(bytes, bytes.len() - 8) {
340        return None;
341    }
342    let size = u64_at(bytes, 16);
343    if size != text.len() as u64 {
344        return None;
345    }
346    let n = usize::try_from(u64_at(bytes, COUNT_AT)).ok()?;
347    let table = n.checked_mul(8)?;
348    let expected = HEADER_LEN.checked_add(table)?;
349    if body.len() != expected {
350        return None;
351    }
352    let offsets: Vec<u64> = (0..n).map(|i| u64_at(bytes, HEADER_LEN + i * 8)).collect();
353    let ordered = offsets.windows(2).all(|w| w[0] < w[1]);
354    if !ordered || offsets.last().is_some_and(|&o| o >= size) {
355        return None;
356    }
357    // A checksum cannot catch a table that was written whole but wrong.
358    // Readers also slice the text here without a new UTF-8 check.
359    if !offsets_match_scan(text, &offsets) {
360        return None;
361    }
362    Some(FrameIndex {
363        offsets,
364        data_len: size,
365    })
366}
367
368/// Read the sidecar for `path` if it exists and matches the data file.
369pub fn read_sidecar(path: &Path) -> io::Result<Option<FrameIndex>> {
370    let bytes = match std::fs::read(sidecar_path(path)) {
371        Ok(b) => b,
372        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
373        Err(e) => return Err(e),
374    };
375    with_mapped(path, |data| {
376        decode(&bytes, validate_utf8_sliced(data).ok()?)
377    })
378}
379
380fn write_encoded(path: &Path, bytes: &[u8]) -> io::Result<()> {
381    let dst = sidecar_path(path);
382    let mut tmp = dst.as_os_str().to_owned();
383    tmp.push(".tmp");
384    let tmp = PathBuf::from(tmp);
385    {
386        let mut f = std::fs::File::create(&tmp)?;
387        f.write_all(bytes)?;
388        f.sync_all()?;
389    }
390    std::fs::rename(&tmp, &dst)
391}
392
393/// Write the sidecar for `path` atomically (temp file plus rename).
394///
395/// The size and content hash describe `path` as it exists at this call.
396/// Scanning an earlier buffer and then calling this can store them with
397/// stale offsets. Readers then reject the table, since it is not a scan
398/// of the data, and rescan.
399pub fn write_sidecar(path: &Path, idx: &FrameIndex) -> io::Result<()> {
400    let fp = fingerprint(path)?;
401    if fp.size != idx.data_len {
402        return Err(io::Error::new(
403            io::ErrorKind::InvalidInput,
404            "frame index length does not match the data file size",
405        ));
406    }
407    write_encoded(path, &encode(fp, idx))
408}
409
410/// Return a trusted index for `text` (the contents of `path`).
411///
412/// Uses the sidecar when its table is the scan of `text`; otherwise scans
413/// and, when `write_back` is set, refreshes the sidecar. The data file is
414/// not read again, and the hash for a write is taken from `text`.
415/// Write errors are ignored, so read-only directories still work.
416pub fn load_or_build(path: &Path, text: &str, write_back: bool) -> Result<FrameIndex, ParseError> {
417    if let Ok(bytes) = std::fs::read(sidecar_path(path))
418        && let Some(idx) = decode(&bytes, text)
419    {
420        return Ok(idx);
421    }
422    let idx = FrameIndex::scan(text)?;
423    if write_back {
424        let fp = Fingerprint::of(text.as_bytes());
425        let _ = write_encoded(path, &encode(fp, &idx));
426    }
427    Ok(idx)
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    fn fixture(n: usize) -> String {
435        let one = std::fs::read_to_string(concat!(
436            env!("CARGO_MANIFEST_DIR"),
437            "/resources/test/tiny_cuh2.con"
438        ))
439        .unwrap();
440        one.repeat(n)
441    }
442
443    #[test]
444    fn checked_scan_matches_legacy_scan() {
445        let text = fixture(7);
446        let legacy = crate::iterators::frame_start_offsets(&text);
447        assert_eq!(frame_start_offsets_checked(&text).unwrap(), legacy);
448        assert_eq!(legacy.len(), 7);
449    }
450
451    #[test]
452    fn checked_scan_accepts_final_line_without_newline() {
453        for n in [1usize, 3] {
454            let text = fixture(n);
455            let bare = text
456                .strip_suffix('\n')
457                .expect("fixture frame ends with a newline");
458            let parsed = crate::iterators::ConFrameIterator::new(bare)
459                .collect::<Result<Vec<_>, _>>()
460                .expect("parser accepts the final line");
461            assert_eq!(parsed.len(), n);
462            let offsets =
463                frame_start_offsets_checked(bare).expect("checked scan accepts the final line");
464            assert_eq!(offsets, crate::iterators::frame_start_offsets(bare));
465            assert_eq!(offsets.len(), n);
466            let mut scanner = crate::iterators::ConFrameIterator::new(bare);
467            for _ in 0..n {
468                scanner
469                    .forward_fast()
470                    .unwrap()
471                    .expect("skip accepts the final line");
472            }
473            assert!(scanner.forward_fast().is_none());
474        }
475    }
476
477    #[test]
478    fn checked_scan_reports_truncation() {
479        let text = fixture(3);
480        // Drop the last coordinate line. A final line that is present but
481        // has no trailing newline is valid and is covered separately.
482        let end = text.strip_suffix('\n').unwrap().rfind('\n').unwrap() + 1;
483        let cut = &text[..end];
484        assert!(frame_start_offsets_checked(cut).is_err());
485        assert!(crate::iterators::ConFrameIterator::new(cut).any(|r| r.is_err()));
486    }
487
488    #[test]
489    fn sliced_utf8_matches_std() {
490        let mut s = "é\n".repeat(5 << 20);
491        s.push_str("ü end\n");
492        assert!(validate_utf8_sliced(s.as_bytes()).is_ok());
493        let mut bad = s.into_bytes();
494        let mid = bad.len() / 2;
495        bad[mid] = 0xff;
496        assert!(validate_utf8_sliced(&bad).is_err());
497    }
498
499    #[test]
500    fn sliced_utf8_error_offset_matches_std() {
501        let mut buf = vec![b'\n'; UTF8_SLICE_MIN * 2 + 64];
502        let at = UTF8_SLICE_MIN + UTF8_SLICE_MIN / 2;
503        buf[at] = 0xff;
504        let err = validate_utf8_sliced(&buf).unwrap_err();
505        let std_err = std::str::from_utf8(&buf).unwrap_err();
506        assert_eq!(err.valid_up_to(), std_err.valid_up_to());
507        assert_eq!(err.error_len(), std_err.error_len());
508        assert_eq!(err.valid_up_to(), at);
509        assert_eq!(err.error_len(), Some(1));
510
511        buf[at] = b'\n';
512        buf.push(0xc2);
513        let err = validate_utf8_sliced(&buf).unwrap_err();
514        let std_err = std::str::from_utf8(&buf).unwrap_err();
515        assert_eq!(err.valid_up_to(), std_err.valid_up_to());
516        assert_eq!(err.error_len(), std_err.error_len());
517        assert_eq!(err.valid_up_to(), buf.len() - 1);
518        assert_eq!(err.error_len(), None);
519    }
520
521    #[test]
522    fn sliced_utf8_lead_plus_non_continuation_is_not_truncated() {
523        // A multi-byte lead followed by a non-continuation is an invalid
524        // sequence in the middle of the buffer. `error_len` is `Some`
525        // (resume after the lead). `None` would mean the bytes ran out.
526        let mut buf = b"head\n".to_vec();
527        let at = buf.len();
528        buf.push(0xC2);
529        buf.extend_from_slice(b"A tail\n");
530        let err = validate_utf8_sliced(&buf).unwrap_err();
531        let std_err = std::str::from_utf8(&buf).unwrap_err();
532        assert_eq!(err.valid_up_to(), at);
533        assert_eq!(err.valid_up_to(), std_err.valid_up_to());
534        assert_eq!(err.error_len(), Some(1));
535        assert_eq!(err.error_len(), std_err.error_len());
536
537        // Same shape for a three-byte lead whose second byte is a
538        // continuation and whose third is not. The invalid sequence is
539        // two bytes; stopping on those two still looks truncated.
540        let mut buf = b"head\n".to_vec();
541        let at = buf.len();
542        buf.extend_from_slice(&[0xE1, 0x80, b'Z']);
543        buf.extend_from_slice(b" tail\n");
544        let err = validate_utf8_sliced(&buf).unwrap_err();
545        let std_err = std::str::from_utf8(&buf).unwrap_err();
546        assert_eq!(err.valid_up_to(), at);
547        assert_eq!(err.valid_up_to(), std_err.valid_up_to());
548        assert_eq!(err.error_len(), Some(2));
549        assert_eq!(err.error_len(), std_err.error_len());
550    }
551
552    #[test]
553    fn utf8_slice_target_stays_inside_huge_buffers() {
554        let len = 20 << 20;
555        let n = (len / UTF8_SLICE_MIN).max(1);
556        for k in 1..n {
557            assert_eq!(utf8_slice_target(len, k, n), k * len / n);
558            assert!(utf8_slice_target(len, k, n) <= len);
559        }
560        let len = usize::MAX - 1024;
561        let n_slices = (len / UTF8_SLICE_MIN).max(2);
562        let k = n_slices - 1;
563        assert!(k.checked_mul(len).is_none());
564        let target = utf8_slice_target(len, k, n_slices);
565        assert!(target <= len);
566        let expected = (k as u128 * len as u128 / n_slices as u128) as usize;
567        assert_eq!(target, expected);
568    }
569
570    #[test]
571    fn sidecar_roundtrip_and_staleness() {
572        let dir = std::env::temp_dir().join(format!("rcidx-test-{}", std::process::id()));
573        std::fs::create_dir_all(&dir).unwrap();
574        let con = dir.join("t.con");
575        let text = fixture(5);
576        std::fs::write(&con, &text).unwrap();
577        let idx = load_or_build(&con, &text, true).unwrap();
578        assert_eq!(read_sidecar(&con).unwrap(), Some(idx.clone()));
579        for i in 0..idx.len() {
580            let r = idx.range(i).unwrap();
581            assert!(
582                crate::iterators::ConFrameIterator::new(&text[r])
583                    .next()
584                    .unwrap()
585                    .is_ok()
586            );
587        }
588        // Corrupt one offset byte: checksum must reject it.
589        let side = sidecar_path(&con);
590        let mut b = std::fs::read(&side).unwrap();
591        b[HEADER_LEN] ^= 1;
592        std::fs::write(&side, &b).unwrap();
593        assert_eq!(read_sidecar(&con).unwrap(), None);
594        // Change the data file: the size check must reject the old sidecar.
595        write_sidecar(&con, &idx).unwrap();
596        let text2 = fixture(6);
597        std::fs::write(&con, &text2).unwrap();
598        assert_eq!(read_sidecar(&con).unwrap(), None);
599        assert_eq!(load_or_build(&con, &text2, true).unwrap().len(), 6);
600        let _ = std::fs::remove_dir_all(&dir);
601    }
602
603    #[test]
604    fn content_hash_matches_file_fingerprint() {
605        let dir = std::env::temp_dir().join(format!("rcidx-hash-{}", std::process::id()));
606        std::fs::create_dir_all(&dir).unwrap();
607        let con = dir.join("t.con");
608        for text in [String::new(), fixture(40)] {
609            std::fs::write(&con, &text).unwrap();
610            assert_eq!(fingerprint(&con).unwrap(), Fingerprint::of(text.as_bytes()));
611        }
612        let _ = std::fs::remove_dir_all(&dir);
613    }
614
615    #[test]
616    fn content_hash_sees_every_byte_and_block_order() {
617        let base: Vec<u8> = (0..HASH_BLOCK * 3 + 77).map(|i| (i % 251) as u8).collect();
618        let h = content_hash(&base);
619        for at in [0, 63, 64, HASH_BLOCK - 1, HASH_BLOCK, 2 * HASH_BLOCK + 5, base.len() - 1] {
620            let mut b = base.clone();
621            b[at] ^= 0x20;
622            assert_ne!(content_hash(&b), h, "byte {at}");
623        }
624        let mut swapped = base.clone();
625        swapped[..2 * HASH_BLOCK].rotate_left(HASH_BLOCK);
626        assert_ne!(content_hash(&swapped), h);
627        assert_ne!(content_hash(&base[..base.len() - 1]), h);
628        let serial = base.chunks(HASH_BLOCK).map(block_hash).fold(
629            fnv1a(FNV_OFFSET, &(base.len() as u64).to_le_bytes()),
630            |acc, b| fnv1a(acc, &b.to_le_bytes()),
631        );
632        assert_eq!(serial, h);
633    }
634
635    /// Frames parsed from the ranges of `idx`, in order.
636    fn frames_via(idx: &FrameIndex, text: &str) -> Vec<crate::types::ConFrame> {
637        (0..idx.len())
638            .map(|i| {
639                let r = idx.range(i).unwrap();
640                ConFrameIterator::new(&text[r]).next().unwrap().unwrap()
641            })
642            .collect()
643    }
644
645    fn scanned_frames(text: &str) -> Vec<crate::types::ConFrame> {
646        ConFrameIterator::new(text)
647            .collect::<Result<Vec<_>, _>>()
648            .unwrap()
649    }
650
651    /// Publish a sidecar scanned from `old`, then put `new` (same length)
652    /// on disk and open it with and without write-back.
653    fn reopen_after_edit(name: &str, old: &str, new: &str) -> Vec<FrameIndex> {
654        assert_eq!(old.len(), new.len());
655        let dir = std::env::temp_dir().join(format!("rcidx-{name}-{}", std::process::id()));
656        std::fs::create_dir_all(&dir).unwrap();
657        let con = dir.join("t.con");
658        std::fs::write(&con, old).unwrap();
659        load_or_build(&con, old, true).unwrap();
660        assert!(read_sidecar(&con).unwrap().is_some());
661        std::fs::write(&con, new).unwrap();
662        let opened = vec![
663            load_or_build(&con, new, false).unwrap(),
664            load_or_build(&con, new, true).unwrap(),
665            load_or_build(&con, new, true).unwrap(),
666        ];
667        let _ = std::fs::remove_dir_all(&dir);
668        opened
669    }
670
671    #[test]
672    fn same_length_middle_change_reads_match_a_scan() {
673        let text = fixture(40);
674        let mut changed = text.as_bytes().to_vec();
675        let at = (4096..changed.len() - 4096)
676            .find(|&i| changed[i].is_ascii_alphabetic())
677            .expect("middle of the file has a letter");
678        changed[at] = if changed[at] == b'A' { b'B' } else { b'A' };
679        let new_text = std::str::from_utf8(&changed).unwrap();
680        let scan = FrameIndex::scan(new_text).unwrap();
681        let expected = scanned_frames(new_text);
682        assert_ne!(expected, scanned_frames(&text));
683        for idx in reopen_after_edit("letter", &text, new_text) {
684            assert_eq!(idx, scan);
685            assert_eq!(frames_via(&idx, new_text), expected);
686        }
687    }
688
689    #[test]
690    fn same_length_change_that_moves_a_boundary_is_rescanned() {
691        let text = fixture(40);
692        let old = FrameIndex::scan(&text).unwrap();
693        let frame = |i: usize| &text[old.range(i).unwrap()];
694        // Move the first H row of frame 21 to the end of frame 20 and fix
695        // both H counts. The length and the line starts stay the same.
696        let row_at = frame(21).find("Coordinates of Component 2\n").unwrap()
697            + "Coordinates of Component 2\n".len();
698        let row_len = frame(21)[row_at..].find('\n').unwrap() + 1;
699        let row = &frame(21)[row_at..row_at + row_len];
700        let mut moved = String::new();
701        for i in 0..old.len() {
702            let f = frame(i);
703            match i {
704                20 => {
705                    moved.push_str(&f.replacen("\n2 2\n", "\n2 3\n", 1));
706                    moved.push_str(row);
707                }
708                21 => {
709                    let f = f.replacen("\n2 2\n", "\n2 1\n", 1);
710                    let at = f.find(row).unwrap();
711                    moved.push_str(&f[..at]);
712                    moved.push_str(&f[at + row_len..]);
713                }
714                _ => moved.push_str(f),
715            }
716        }
717        let scan = FrameIndex::scan(&moved).unwrap();
718        assert_eq!(scan.len(), old.len());
719        assert_ne!(scan, old);
720        assert!(
721            old.offsets
722                .iter()
723                .all(|&o| o == 0 || moved.as_bytes()[o as usize - 1] == b'\n')
724        );
725        assert!(!offsets_match_scan(&moved, &old.offsets));
726        let fp = Fingerprint::of(moved.as_bytes());
727        assert_eq!(decode(&encode(fp, &old), &moved), None);
728        let expected = scanned_frames(&moved);
729        assert_eq!(expected[20].atom_data.len(), 5);
730        assert_eq!(expected[21].atom_data.len(), 3);
731        for idx in reopen_after_edit("moved", &text, &moved) {
732            assert_eq!(idx, scan);
733            assert_eq!(frames_via(&idx, &moved), expected);
734        }
735    }
736
737    #[test]
738    fn only_the_scanned_table_matches() {
739        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/resources/test");
740        let mut checked = 0;
741        for entry in std::fs::read_dir(dir).unwrap() {
742            let path = entry.unwrap().path();
743            let Ok(one) = std::fs::read_to_string(&path) else {
744                continue;
745            };
746            let text = one.repeat(3);
747            let Ok(scan) = frame_start_offsets_checked(&text) else {
748                continue;
749            };
750            let good: Vec<u64> = scan.iter().map(|&o| o as u64).collect();
751            assert!(offsets_match_scan(&text, &good), "{}", path.display());
752            for drop in 0..good.len() {
753                let mut t = good.clone();
754                t.remove(drop);
755                assert!(
756                    !offsets_match_scan(&text, &t),
757                    "{} drop {drop}",
758                    path.display()
759                );
760            }
761            let mut longer = good.clone();
762            longer.push(text.len() as u64);
763            assert!(!offsets_match_scan(&text, &longer), "{}", path.display());
764            checked += 1;
765        }
766        assert!(checked >= 10, "{checked} fixtures");
767        assert!(offsets_match_scan(" \n\n", &[]));
768        assert!(!offsets_match_scan(&fixture(1), &[]));
769    }
770
771    #[test]
772    fn end_hint_never_changes_the_skip() {
773        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/resources/test");
774        let mut checked = 0;
775        for entry in std::fs::read_dir(dir).unwrap() {
776            let path = entry.unwrap().path();
777            let Ok(one) = std::fs::read_to_string(&path) else {
778                continue;
779            };
780            let text = one.repeat(3);
781            let line_starts: Vec<usize> = std::iter::once(0)
782                .chain(text.match_indices('\n').map(|(i, _)| i + 1))
783                .collect();
784            let mut starts = crate::iterators::frame_start_offsets(&text);
785            starts.extend(line_starts.iter().copied().take(40));
786            starts.retain(|&s| s < text.len());
787            for start in starts {
788                let mut plain = ConFrameIterator::starting_at(&text, start);
789                let want = format!("{:?}", plain.forward_fast());
790                let end = plain.byte_pos();
791                let mut hints = vec![None, Some(0), Some(text.len() + 1)];
792                hints.extend([end.saturating_sub(1), end + 1].map(Some));
793                hints.extend(line_starts.iter().map(|&s| Some(s)));
794                for &hint in &hints {
795                    let mut hinted = ConFrameIterator::starting_at(&text, start);
796                    let got = format!("{:?}", hinted.forward_fast_to(hint));
797                    assert_eq!(got, want, "{} at {start}, hint {hint:?}", path.display());
798                    assert_eq!(hinted.byte_pos(), plain.byte_pos());
799                }
800            }
801            checked += 1;
802        }
803        assert!(checked >= 10, "{checked} fixtures");
804    }
805
806    #[test]
807    fn version_1_sidecar_is_rebuilt() {
808        let text = fixture(2);
809        let idx = FrameIndex::scan(&text).unwrap();
810        let fp = Fingerprint::of(text.as_bytes());
811        let mut bytes = encode(fp, &idx);
812        assert_eq!(decode(&bytes, &text), Some(idx.clone()));
813        bytes[8..12].copy_from_slice(&1u32.to_le_bytes());
814        rechecksum(&mut bytes);
815        assert_eq!(decode(&bytes, &text), None);
816    }
817
818    fn rechecksum(bytes: &mut [u8]) {
819        let body_len = bytes.len() - 8;
820        let sum = fnv1a(FNV_OFFSET, &bytes[..body_len]);
821        bytes[body_len..].copy_from_slice(&sum.to_le_bytes());
822    }
823
824    #[test]
825    fn checksum_valid_short_table_is_rejected() {
826        let text = fixture(2);
827        let idx = FrameIndex::scan(&text).unwrap();
828        let fp = Fingerprint::of(text.as_bytes());
829        let mut bytes = encode(fp, &idx);
830        let n = (idx.len() as u64).saturating_add(1);
831        bytes[COUNT_AT..COUNT_AT + 8].copy_from_slice(&n.to_le_bytes());
832        rechecksum(&mut bytes);
833        assert_eq!(decode(&bytes, &text), None);
834    }
835
836    #[test]
837    fn wrapping_frame_count_is_rejected() {
838        let text = fixture(2);
839        let idx = FrameIndex::scan(&text).unwrap();
840        let fp = Fingerprint::of(text.as_bytes());
841        let mut bytes = encode(fp, &idx);
842        let payload = bytes.len() - 8 - HEADER_LEN;
843        let n = (1usize << (usize::BITS - 3)).wrapping_add(payload / 8);
844        assert_eq!(8usize.wrapping_mul(n), payload);
845        assert!(n > idx.len());
846        bytes[COUNT_AT..COUNT_AT + 8].copy_from_slice(&(n as u64).to_le_bytes());
847        rechecksum(&mut bytes);
848        assert_eq!(decode(&bytes, &text), None);
849    }
850}