liteboxfs 0.2.0

A modern POSIX filesystem in a SQLite database
Documentation
mod testing;

use std::io::{Seek, SeekFrom, Write};

use xpct::{be_empty, be_err, be_ok, equal, expect, match_pattern, pattern};

use liteboxfs::{
    Connection, CreateOptions, Error, FileBy, FileOrigin,
    metadata::{FileKind, Owner},
};
use testing::{Case, random_string};

#[test]
fn empty_file_has_no_holes() -> liteboxfs::Result<()> {
    let mut conn = Connection::open_in_memory(&CreateOptions::new())?;

    conn.exec(|fs| {
        let mut file = fs.create("test.txt", FileKind::Regular, Owner::ROOT)?;

        expect!(file.holes()).to(be_ok()).to(be_empty());

        liteboxfs::Result::Ok(())
    })?;

    Ok(())
}

#[test]
fn file_with_only_data_has_no_holes() -> liteboxfs::Result<()> {
    let mut conn = Connection::open_in_memory(&CreateOptions::new())?;

    let data = random_string(100, Case::Lower);

    conn.exec(|fs| {
        let mut file = fs.create("test.txt", FileKind::Regular, Owner::ROOT)?;

        file.write_all(data.as_bytes())?;

        expect!(file.holes()).to(be_ok()).to(be_empty());

        liteboxfs::Result::Ok(())
    })?;

    Ok(())
}

#[test]
fn set_len_on_empty_file_creates_one_hole() -> liteboxfs::Result<()> {
    let mut conn = Connection::open_in_memory(&CreateOptions::new())?;

    conn.exec(|fs| {
        let mut file = fs.create("test.txt", FileKind::Regular, Owner::ROOT)?;

        file.set_len(100)?;

        expect!(file.holes()).to(be_ok()).to(equal(vec![0..100]));

        liteboxfs::Result::Ok(())
    })?;

    Ok(())
}

#[test]
fn hole_after_data() -> liteboxfs::Result<()> {
    let mut conn = Connection::open_in_memory(&CreateOptions::new())?;

    let data = random_string(50, Case::Lower);

    conn.exec(|fs| {
        let mut file = fs.create("test.txt", FileKind::Regular, Owner::ROOT)?;

        file.write_all(data.as_bytes())?;
        file.set_len(200)?;

        expect!(file.holes()).to(be_ok()).to(equal(vec![50..200]));

        liteboxfs::Result::Ok(())
    })?;

    Ok(())
}

#[test]
fn hole_between_data_regions() -> liteboxfs::Result<()> {
    let mut conn = Connection::open_in_memory(&CreateOptions::new())?;

    let prefix = random_string(10, Case::Lower);
    let suffix = random_string(10, Case::Upper);

    conn.exec(|fs| {
        let mut file = fs.create("test.txt", FileKind::Regular, Owner::ROOT)?;

        // Write prefix, extend to create hole, then write suffix at the end.
        file.write_all(prefix.as_bytes())?;
        file.set_len(100)?;
        file.seek(SeekFrom::Start(100))?;
        file.write_all(suffix.as_bytes())?;

        expect!(file.holes()).to(be_ok()).to(equal(vec![10..100]));

        liteboxfs::Result::Ok(())
    })?;

    Ok(())
}

#[test]
fn truncating_removes_holes() -> liteboxfs::Result<()> {
    let mut conn = Connection::open_in_memory(&CreateOptions::new())?;

    let data = random_string(50, Case::Lower);

    conn.exec(|fs| {
        let mut file = fs.create("test.txt", FileKind::Regular, Owner::ROOT)?;

        // Create data followed by a hole.
        file.write_all(data.as_bytes())?;
        file.set_len(200)?;

        // Truncate back to just the data.
        file.set_len(50)?;

        expect!(file.holes()).to(be_ok()).to(be_empty());

        liteboxfs::Result::Ok(())
    })?;

    Ok(())
}

#[test]
fn dedup_data_block_with_same_length_hole() -> liteboxfs::Result<()> {
    // This is a regression test. When the block store deduplicated a data block whose byte length
    // matched an existing hole block, the fallback lookup query (after `INSERT OR IGNORE`) would
    // match both rows and fail with "Query returned more than one row".
    //
    // In this test, the default block size is used so that writing exactly that many bytes
    // produces a single block whose length equals the hole created by `File::set_len`.
    const BLOCK_SIZE: usize = 32 * 1024;

    let mut conn = Connection::open_in_memory(&CreateOptions::new())?;
    let data = random_string(BLOCK_SIZE, Case::Lower);

    conn.exec(|fs| {
        // Store a data block of length BLOCK_SIZE.
        let mut file_a = fs.create("a.txt", FileKind::Regular, Owner::ROOT)?;
        file_a.write_all(data.as_bytes())?;
        drop(file_a);

        // Store a hole block of the same length.
        let mut file_b = fs.create("b.txt", FileKind::Regular, Owner::ROOT)?;
        file_b.set_len(BLOCK_SIZE as u64)?;
        drop(file_b);

        // Writing the same data again triggers deduplication. Before the fix, the fallback lookup
        // matched both the data block (by hash) and the hole (by length), causing a query error.
        let mut file_c = fs.create("c.txt", FileKind::Regular, Owner::ROOT)?;
        expect!(file_c.write_all(data.as_bytes())).to(be_ok());

        liteboxfs::Result::Ok(())
    })?;

    Ok(())
}

#[test]
fn adjacent_hole_blocks_appear_as_one_range() -> liteboxfs::Result<()> {
    // Two consecutive `set_len` extensions each store a hole block; `File::holes` should merge
    // them into a single range.
    let mut conn = Connection::open_in_memory(&CreateOptions::new())?;

    conn.exec(|fs| {
        let mut file = fs.create("test.txt", FileKind::Regular, Owner::ROOT)?;

        file.set_len(50)?;
        file.set_len(150)?;

        expect!(file.holes()).to(be_ok()).to(equal(vec![0..150]));

        liteboxfs::Result::Ok(())
    })?;

    Ok(())
}

#[test]
fn holes_on_directory_returns_not_a_regular_file() -> liteboxfs::Result<()> {
    let mut conn = Connection::open_in_memory(&CreateOptions::new())?;

    conn.exec(|fs| {
        let mut file = fs.create("dir", FileKind::Dir, Owner::ROOT)?;

        expect!(file.holes())
            .to(be_err())
            .to(match_pattern(pattern!(
                Error::NotARegularFile {
                    file: FileOrigin::Litebox { locator, .. }, ..
                } if locator == &FileBy::from(file.file_id())
            )));

        liteboxfs::Result::Ok(())
    })?;

    Ok(())
}