gunnar-crypt 1.6.0

Encrypted repositories: the content envelope, the per-repo key ring, its members, the server's plaintext check, each person's one encryption key and its unlock methods, and the ring's wrap to members' encryption keys
Documentation
//! **The v2 opener never crashes** (owner, 2026-10-06: "it's OK if it's
//! slower, but it must not crash"). Fuzz-style, deterministic (a fixed-seed
//! xorshift, so a failure replays): random truncations, bit flips, inserted
//! and appended bytes, a huge declared chunk size, and lengths that would
//! overflow a block count. Every one must be REFUSED — an `Err`, never a
//! panic — and no input makes the opener allocate more than it must: the
//! in-memory open holds at most the envelope's own length, the streamed open
//! at most one declared chunk, capped at `MAX_CHUNK`. The allocation bound is
//! measured by a counting allocator on this test's own thread.
//!
//! Native and wasm32, like the other gunnar-crypt suites.

#[cfg(target_arch = "wasm32")]
use wasm_bindgen_test::wasm_bindgen_test as test;

use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;

use gunnar_crypt::envelope::{self, MAX_CHUNK, TAG_LEN, V2_HEADER_LEN};
use gunnar_crypt::keyring::Keyring;

// ── the largest single allocation made by the tracked code ─────────────────

struct Counting;

// Both the switch and the high-water mark are THIS thread's: the harness runs the
// tests on parallel threads, and a shared mark took another test's 64 KiB buffer
// for this one's (round 5 on dwarf: 1 red in 3 runs of the file).
thread_local! {
    static TRACK: Cell<bool> = const { Cell::new(false) };
    static LARGEST: Cell<usize> = const { Cell::new(0) };
}

fn note(size: usize) {
    if TRACK.with(Cell::get) {
        LARGEST.with(|l| l.set(l.get().max(size)));
    }
}

// SAFETY: every call is passed straight to `System`; the counter only reads
// the requested size.
unsafe impl GlobalAlloc for Counting {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        note(layout.size());
        unsafe { System.alloc(layout) }
    }
    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
        note(layout.size());
        unsafe { System.alloc_zeroed(layout) }
    }
    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
        note(new_size);
        unsafe { System.realloc(ptr, layout, new_size) }
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        unsafe { System.dealloc(ptr, layout) }
    }
}

#[global_allocator]
static ALLOC: Counting = Counting;

/// The largest single allocation `f` makes on this thread, and its result.
fn largest_alloc<T>(f: impl FnOnce() -> T) -> (usize, T) {
    LARGEST.with(|l| l.set(0));
    TRACK.with(|t| t.set(true));
    let out = f();
    TRACK.with(|t| t.set(false));
    (LARGEST.with(Cell::get), out)
}

// ── a fixed-seed generator ──────────────────────────────────────────────────

struct Rng(u64);

impl Rng {
    fn next(&mut self) -> u64 {
        // xorshift64*
        self.0 ^= self.0 >> 12;
        self.0 ^= self.0 << 25;
        self.0 ^= self.0 >> 27;
        self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
    }
    fn below(&mut self, n: usize) -> usize {
        if n == 0 {
            0
        } else {
            (self.next() % n as u64) as usize
        }
    }
}

fn ring() -> Keyring {
    Keyring::parse(include_str!("vectors/keyring.txt")).unwrap()
}

/// Open `e` both ways; both must refuse, and the in-memory open must not
/// allocate beyond the envelope's length (plus a little bookkeeping).
fn must_refuse(ring: &Keyring, e: &[u8], what: &str) {
    let (big, mem) = largest_alloc(|| envelope::open(ring, e));
    assert!(mem.is_err(), "{what}: opened in memory");
    assert!(
        big <= e.len() + 4096,
        "{what}: the in-memory open of {} bytes allocated {big} at once",
        e.len()
    );
    let mut sink = Vec::new();
    let (big, streamed) = largest_alloc(|| envelope::open_stream(ring, &mut &e[..], &mut sink));
    assert!(streamed.is_err(), "{what}: opened streamed");
    assert!(
        big <= MAX_CHUNK as usize + TAG_LEN + 4096,
        "{what}: the streamed open allocated {big} at once"
    );
    // The key-less checks never panic and never claim more than is there.
    if let Some(n) = envelope::plaintext_len(e, e.len() as u64) {
        assert!(n <= e.len() as u64, "{what}");
    }
    let _ = envelope::is_envelope(e);
    let _ = envelope::epoch_of(e);
    let _ = envelope::version_of(e);
}

#[test]
fn random_mutations_of_v2_envelopes_are_refused_without_a_panic() {
    let ring = ring();
    let mut rng = Rng(0x6775_6e6e_6172_2d76);
    let mut refused = 0;
    for round in 0..3000 {
        let len = rng.below(200);
        // A real 1 MiB chunk one time in eight: its streamed open holds a
        // whole chunk, which is what makes a debug run slow.
        let chunk = [1u32, 7, 16, 64, 1, 7, 16, envelope::CHUNK][rng.below(8)];
        let plain: Vec<u8> = (0..len).map(|_| rng.next() as u8).collect();
        let env = envelope::seal_with_chunk(ring.newest(), chunk, &plain);
        let mut m = env.clone();
        let kind = rng.below(7);
        match kind {
            0 => m.truncate(rng.below(env.len())),
            1 => {
                for _ in 0..1 + rng.below(4) {
                    let i = rng.below(m.len());
                    m[i] ^= 1 << rng.below(8);
                }
            }
            2 => {
                let i = rng.below(m.len() + 1);
                m.insert(i, rng.next() as u8);
            }
            3 => {
                for _ in 0..1 + rng.below(40) {
                    m.push(rng.next() as u8);
                }
            }
            4 => {
                let declared =
                    [0, 1, MAX_CHUNK, MAX_CHUNK + 1, u32::MAX, rng.next() as u32][rng.below(6)];
                m[13..17].copy_from_slice(&declared.to_be_bytes());
            }
            5 => m[8] = rng.next() as u8,
            _ => {
                // Duplicate a random range somewhere (a block-sized splice).
                let a = rng.below(m.len());
                let b = (a + rng.below(48)).min(m.len());
                let piece = m[a..b].to_vec();
                let at = rng.below(m.len() + 1);
                m.splice(at..at, piece);
            }
        }
        if m == env {
            continue;
        }
        must_refuse(&ring, &m, &format!("round {round}, mutation {kind}"));
        refused += 1;
    }
    assert!(refused > 2500, "the mutations did something ({refused})");
}

#[test]
fn a_huge_declared_chunk_size_is_refused_and_never_allocated() {
    let ring = ring();
    let env = envelope::seal(ring.newest(), b"small");
    for declared in [MAX_CHUNK, MAX_CHUNK + 1, u32::MAX / 2, u32::MAX] {
        let mut e = env.clone();
        e[13..17].copy_from_slice(&declared.to_be_bytes());
        must_refuse(&ring, &e, &format!("chunk {declared}"));
        // Streamed, the length is not known up front: the buffer still grows
        // only as far as the envelope proves to be long (64 KiB first), and a
        // chunk over the limit is refused from the header alone.
        let mut sink = Vec::new();
        let (big, r) = largest_alloc(|| envelope::open_stream(&ring, &mut &e[..], &mut sink));
        assert!(r.is_err());
        let bound = if declared > MAX_CHUNK {
            4096
        } else {
            (64 << 10) + 4096
        };
        assert!(big <= bound, "chunk {declared}: {big} bytes allocated");
    }
    // A real small file in a 1 MiB-chunk envelope: the in-memory open holds
    // the file, not a whole chunk.
    let (big, plain) = largest_alloc(|| envelope::open(&ring, &env));
    assert_eq!(plain.unwrap(), b"small");
    assert!(big < 4096, "{big}");
}

#[test]
fn lengths_that_would_overflow_a_block_count_are_answered_without_a_panic() {
    let ring = ring();
    let env = envelope::seal_with_chunk(ring.newest(), 1, b"abc");
    for total in [
        0,
        1,
        V2_HEADER_LEN as u64,
        V2_HEADER_LEN as u64 + TAG_LEN as u64,
        u64::MAX - 1,
        u64::MAX,
        (u64::MAX - 100) / 17 * 17 + V2_HEADER_LEN as u64,
    ] {
        if let Some(n) = envelope::plaintext_len(&env, total) {
            assert!(n <= total, "total {total}: {n}");
        }
    }
    for declared in [1u32, MAX_CHUNK] {
        let mut head = env[..V2_HEADER_LEN].to_vec();
        head[13..17].copy_from_slice(&declared.to_be_bytes());
        for total in [u64::MAX, u64::MAX - 7, 1 << 63] {
            if let Some(n) = envelope::plaintext_len(&head, total) {
                assert!(n < total);
            }
        }
    }
    // A header with no blocks at all, and one byte of a block.
    must_refuse(&ring, &env[..V2_HEADER_LEN], "no block");
    must_refuse(&ring, &env[..V2_HEADER_LEN + 1], "one byte of a tag");
}