gedcomkit 0.1.11

A byte-preserving GEDCOM document model: decoding, parsing, readings, version conversion, plausibility checks, and the GEDZIP container, for GEDCOM 5.5 through 7.x.
Documentation
//! The long adversarial run: corpus-seeded mutation fuzzing on stable Rust.
//!
//! `fuzz/` holds the coverage-guided libFuzzer targets, which need nightly
//! plus a platform libFuzzer runtime (on Windows MSVC, the Visual Studio x64
//! `AddressSanitizer` component). This test chases the same invariants with
//! dumb-but-deterministic mutation of the vendored corpus, so a long
//! adversarial campaign can run anywhere `cargo test` does:
//!
//! ```text
//! GEDCOM_ENDURANCE_SECONDS=1800 cargo test -p gedcomkit --test endurance -- --ignored --nocapture
//! ```
//!
//! Ignored by default: at its default five minutes it is far too slow for the
//! ordinary suite, which covers the same properties with fixed iteration
//! counts in `properties.rs`.

#![allow(
    clippy::cast_possible_truncation,
    reason = "mutation offsets are reduced modulo tiny lengths"
)]

use std::time::{Duration, Instant};

use gedcomkit::{Document, Limits, decode_gedcom};

struct Rng(u64);

impl Rng {
    const fn next(&mut self) -> u64 {
        let mut value = self.0;
        value ^= value >> 12;
        value ^= value << 25;
        value ^= value >> 27;
        self.0 = value;
        value.wrapping_mul(0x2545_F491_4F6C_DD1D)
    }

    const fn below(&mut self, ceiling: u64) -> u64 {
        if ceiling == 0 {
            0
        } else {
            self.next() % ceiling
        }
    }
}

fn seeds() -> Vec<Vec<u8>> {
    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/vendored");
    let mut seeds = Vec::new();
    for directory in ["gedcom7code/5", "gedcom7code/7", "zip-interop"] {
        let Ok(entries) = std::fs::read_dir(root.join(directory)) else {
            continue;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            let extension = path
                .extension()
                .and_then(|extension| extension.to_str())
                .unwrap_or_default();
            if matches!(extension, "ged" | "gdz")
                && let Ok(bytes) = std::fs::read(&path)
            {
                seeds.push(bytes);
            }
        }
    }
    assert!(seeds.len() > 40, "the vendored corpus is the seed set");
    seeds
}

/// One mutation: flip, overwrite, truncate, duplicate, or splice.
fn mutate(rng: &mut Rng, input: &mut Vec<u8>, other: &[u8]) {
    if input.is_empty() {
        input.extend_from_slice(&other[..other.len().min(64)]);
        return;
    }
    match rng.below(6) {
        0 => {
            let at = rng.below(input.len() as u64) as usize;
            input[at] ^= (rng.next() & 0xFF) as u8;
        }
        1 => {
            let at = rng.below(input.len() as u64) as usize;
            input[at] = (rng.next() & 0xFF) as u8;
        }
        2 => {
            let keep = rng.below(input.len() as u64) as usize;
            input.truncate(keep);
        }
        3 => {
            let at = rng.below(input.len() as u64) as usize;
            let run = (rng.below(16) + 1) as usize;
            let piece: Vec<u8> = input[at..input.len().min(at + run)].to_vec();
            input.splice(at..at, piece);
        }
        4 => {
            // Splice a window of another seed in, which is what carries whole
            // valid structures into the wrong context.
            let from = rng.below(other.len() as u64) as usize;
            let run = (rng.below(64) + 1) as usize;
            let piece = &other[from..other.len().min(from + run)];
            let at = rng.below(input.len() as u64) as usize;
            input.splice(at..at, piece.iter().copied());
        }
        _ => {
            let at = rng.below(input.len() as u64) as usize;
            input.insert(
                at,
                [b'\n', b'\r', b'@', b'0', b' ', 0xFF][rng.below(6) as usize],
            );
        }
    }
}

#[test]
#[ignore = "the long adversarial run; set GEDCOM_ENDURANCE_SECONDS and run explicitly"]
fn mutation_endurance_holds_every_invariant() {
    let budget = std::env::var("GEDCOM_ENDURANCE_SECONDS")
        .ok()
        .and_then(|value| value.parse().ok())
        .unwrap_or(300);
    let deadline = Instant::now() + Duration::from_secs(budget);
    let limits = Limits::DEFAULT
        .with_input_bytes(4 << 20)
        .with_records(100_000)
        .with_structures(1_000_000);

    let seeds = seeds();
    let mut rng = Rng(0x0F0A_57EE_D000_0001 | 1);
    let mut input = Vec::new();
    let mut cases = 0u64;
    let mut accepted = 0u64;

    while Instant::now() < deadline {
        let seed = &seeds[rng.below(seeds.len() as u64) as usize];
        let other = &seeds[rng.below(seeds.len() as u64) as usize];
        input.clear();
        input.extend_from_slice(seed);
        for _ in 0..=rng.below(8) {
            mutate(&mut rng, &mut input, other);
        }
        if input.len() > (4 << 20) {
            input.truncate(4 << 20);
        }
        cases += 1;

        // Surface 1: the archive reader, bounded, every entry read.
        #[cfg(feature = "gedzip")]
        if let Ok(archive) = gedcomkit::gedzip::Archive::open_with(
            &input,
            gedcomkit::gedzip::ArchiveLimits::DEFAULT
                .with_entries(1_000)
                .with_entry_bytes(1 << 20)
                .with_total_bytes(1 << 24),
        ) {
            let _ = archive.document_name();
            for entry in archive.entries().to_vec() {
                let _ = archive.read_entry(&entry);
            }
        }

        // Surface 2: the decoder, then the parser, then the invariant: what
        // the writer produces must re-parse to exactly itself.
        let Ok((text, _report)) = gedcomkit::decode::decode_gedcom_with(&input, limits) else {
            continue;
        };
        let Ok(document) = Document::parse_with(&text, limits) else {
            continue;
        };
        accepted += 1;
        let written = document.to_text();
        let again =
            Document::parse_with(&written, limits).expect("what the writer produced must parse");
        assert_eq!(
            again.to_text(),
            written,
            "write→parse→write moved on case {cases}"
        );

        // Surface 3: the readings never panic on what parsing accepted.
        for record in &document.records {
            let _ = gedcomkit::view::RecordView::from_node(record);
        }
        let _ = decode_gedcom(written.as_bytes());
    }

    println!(
        "endurance: {cases} mutated cases over {budget}s, {accepted} accepted and round-tripped"
    );
    assert!(cases > 1_000, "the run must actually have run: {cases}");
}