bashkit 0.5.0

Awesomely fast virtual sandbox with bash and file system
Documentation
//! Threat-model tests for the `sqlite` builtin.
//!
//! Each `#[test]` here covers a distinct adversarial scenario from
//! `specs/threat-model.md` (or the new entries in `specs/sqlite-builtin.md`).
//! Aside from confirming current mitigations, these tests act as
//! regression guards: a future change that re-introduces an attack must
//! flip a test red.
//!
//! Threats covered:
//!
//! | ID            | Description                                              |
//! |---------------|----------------------------------------------------------|
//! | TM-SQL-001    | Default-disabled (BETA gate)                             |
//! | TM-SQL-002    | Sandbox escape via VFS — host paths sandboxed            |
//! | TM-SQL-003    | DoS via oversize SQL input                               |
//! | TM-SQL-004    | DoS via oversize result set                              |
//! | TM-SQL-005    | DoS via oversize DB file load                            |
//! | TM-SQL-006    | NULL byte / control char injection in identifiers        |
//! | TM-SQL-007    | Blob-in-CSV escape correctness                           |
//! | TM-SQL-008    | Recursive `.read` does not unbounded-recurse             |
//! | TM-SQL-009    | ATTACH/DETACH blocked by policy                          |
//! | TM-SQL-010    | PRAGMA deny list blocks resource/FS knobs                |

#![cfg(feature = "sqlite")]

use bashkit::{Bash, SqliteBackend, SqliteLimits};
use std::path::Path;

fn make_bash_with(limits: SqliteLimits) -> Bash {
    Bash::builder()
        .sqlite_with_limits(limits)
        .env("BASHKIT_ALLOW_INPROCESS_SQLITE", "1")
        .build()
}

fn make_bash_default() -> Bash {
    make_bash_with(SqliteLimits::default())
}

#[tokio::test]
async fn tm_sql_001_default_disabled_without_opt_in() {
    // No env opt-in → builtin refuses to run.
    let mut bash = Bash::builder().sqlite().build();
    let r = bash.exec(r#"sqlite :memory: 'SELECT 1'"#).await.unwrap();
    assert_eq!(r.exit_code, 1);
    assert!(r.stderr.contains("disabled"));
}

#[tokio::test]
async fn tm_sql_002_paths_resolve_only_to_vfs() {
    // Even though we pass an absolute path that *would* be readable on the
    // host (/etc/passwd), the engine's IO is wired to bashkit's VFS only.
    // The VFS doesn't contain that file, so we either get a "not found"
    // style error or the file is created fresh inside the VFS (depending on
    // backend). Either way we must not read the host's /etc/passwd.
    let mut bash = make_bash_default();
    let r = bash
        .exec(r#"sqlite -backend vfs /etc/passwd '.tables' 2>&1 || true"#)
        .await
        .unwrap();
    // Whatever happens, we should not have leaked content from the host.
    assert!(
        !r.stdout.contains("root:x:") && !r.stderr.contains("root:x:"),
        "leaked host /etc/passwd!\nstdout={:?}\nstderr={:?}",
        r.stdout,
        r.stderr,
    );
}

#[tokio::test]
async fn tm_sql_003_oversize_script_rejected() {
    let mut bash = make_bash_with(SqliteLimits::default().max_script_bytes(1024));
    let big_query = "SELECT 1; ".repeat(1000); // ~10 KiB
    let cmd = format!("sqlite :memory: '{big_query}' 2>&1 || echo SAW_REJECTION");
    let r = bash.exec(&cmd).await.unwrap();
    assert!(
        r.stdout.contains("SAW_REJECTION") || r.stderr.contains("script too large"),
        "stdout={:?} stderr={:?}",
        r.stdout,
        r.stderr,
    );
}

#[tokio::test]
async fn tm_sql_004_oversize_result_set_aborts() {
    let mut bash = make_bash_with(SqliteLimits::default().max_rows_per_query(10));
    let r = bash
        .exec(
            r#"sqlite :memory: '
            CREATE TABLE big AS WITH RECURSIVE r(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM r WHERE n<100) SELECT n FROM r;
            SELECT * FROM big' 2>&1 || echo SAW_ROW_CAP"#,
        )
        .await
        .unwrap();
    // Either turso supports recursive CTE and we hit our cap, OR it returns
    // an error. Both outcomes prove the cap is wired and the user gets
    // back to the shell.
    assert!(
        r.stdout.contains("SAW_ROW_CAP")
            || r.stderr.contains("row cap")
            || r.stderr.contains("sqlite:"),
        "stdout={:?} stderr={:?}",
        r.stdout,
        r.stderr,
    );
}

#[tokio::test]
async fn tm_sql_005_oversize_db_file_rejected() {
    let mut bash = make_bash_with(SqliteLimits::default().max_db_bytes(1024));
    // Plant a 4 KiB blob masquerading as a database, then try to open it.
    bash.exec(r#"head -c 4096 /dev/urandom > /tmp/big.sqlite"#)
        .await
        .unwrap();
    let r = bash
        .exec(r#"sqlite /tmp/big.sqlite '.tables' 2>&1 || echo SAW_TOO_LARGE"#)
        .await
        .unwrap();
    assert!(
        r.stdout.contains("SAW_TOO_LARGE") || r.stderr.contains("too large"),
        "stdout={:?} stderr={:?}",
        r.stdout,
        r.stderr,
    );
}

#[tokio::test]
async fn tm_sql_006_null_bytes_in_text_safely_round_trip() {
    // Inserting binary including embedded NUL via X'..' literal must round-
    // trip without truncation or panic. SQLite uses X'..' (single quotes)
    // for blob literals; we feed the SQL via stdin to avoid bash quoting
    // gymnastics.
    let mut bash = make_bash_default();
    let r = bash
        .exec(
            "sqlite :memory: <<'EOF'\n\
             CREATE TABLE t(b BLOB);\n\
             INSERT INTO t VALUES (X'DEADBEEF00CAFE');\n\
             SELECT length(b) FROM t;\n\
             EOF",
        )
        .await
        .unwrap();
    assert_eq!(r.exit_code, 0, "stderr: {}", r.stderr);
    assert_eq!(r.stdout.trim(), "7");
}

#[tokio::test]
async fn tm_sql_007_blob_with_separator_quoted_in_csv() {
    // Blob whose contents include a comma (0x2C) must not break CSV parsing
    // when `-csv` mode is active.
    let mut bash = make_bash_default();
    let r = bash
        .exec(
            "sqlite -csv -header :memory: <<'EOF'\n\
             CREATE TABLE t(b BLOB);\n\
             INSERT INTO t VALUES (X'2C2C2C');\n\
             SELECT * FROM t;\n\
             EOF",
        )
        .await
        .unwrap();
    assert_eq!(r.exit_code, 0, "stderr: {}", r.stderr);
    let last = r.stdout.lines().last().unwrap();
    assert!(last.starts_with('"') && last.ends_with('"'));
}

#[tokio::test]
async fn tm_sql_008_recursive_dot_read_eventually_terminates() {
    // A `.read` script that .reads itself back must not hang forever or
    // overflow the stack. We rely on either turso refusing repeated opens
    // or our own size cap. Bound the test with a small timeout via the
    // `timeout` builtin so a regression doesn't hang CI.
    let mut bash = make_bash_default();
    bash.exec(r#"echo '.read /tmp/loop.sql' > /tmp/loop.sql"#)
        .await
        .unwrap();
    let r = bash
        .exec(
            r#"timeout --preserve-status 5 sqlite :memory: '.read /tmp/loop.sql' 2>&1 || echo SAW_TERMINATION"#,
        )
        .await
        .unwrap();
    // We don't care which path triggers (stack overflow guard, parser
    // recursion limit, or our own runtime cap); we only need confirmation
    // that the shell regains control.
    assert!(
        r.stdout.contains("SAW_TERMINATION") || r.stderr.contains("sqlite:") || r.exit_code != 0,
        "stdout={:?} stderr={:?}",
        r.stdout,
        r.stderr,
    );
    let _ = (Path::new("/tmp/loop.sql"), bash.fs());
}

#[tokio::test]
async fn tm_sql_009_attach_detach_rejected() {
    // ATTACH and DETACH would let scripted SQL reach VFS paths the operator
    // never staged for read/write — and on the VFS backend, opening a new
    // file path through ATTACH would also invent fresh `MemoryIO` state
    // outside our isolation. The policy rejects both keywords up-front.
    let mut bash = make_bash_default();
    let r = bash
        .exec(r#"sqlite :memory: "ATTACH DATABASE '/tmp/other.db' AS other""#)
        .await
        .unwrap();
    assert_eq!(r.exit_code, 1);
    assert!(r.stderr.contains("ATTACH/DETACH is not supported"));

    let r = bash
        .exec(r#"sqlite :memory: 'DETACH DATABASE other'"#)
        .await
        .unwrap();
    assert_eq!(r.exit_code, 1);
    assert!(r.stderr.contains("ATTACH/DETACH is not supported"));
}

#[tokio::test]
async fn tm_sql_010_pragma_deny_blocks_resource_knobs() {
    // The default deny list exists so a script can't push past
    // `max_db_bytes` by ballooning the page cache, or fingerprint the host
    // build via `compile_options`. Probe a representative entry from each
    // bucket and assert the rejection comes from the policy (not turso).
    let mut bash = make_bash_default();
    for pragma in [
        "PRAGMA cache_size = -100000",
        "PRAGMA mmap_size = 268435456",
        "PRAGMA temp_store_directory = '/tmp/host'",
        "PRAGMA compile_options",
    ] {
        let cmd = format!("sqlite :memory: \"{pragma}\"");
        let r = bash.exec(&cmd).await.unwrap();
        assert_eq!(r.exit_code, 1, "{pragma} stderr: {}", r.stderr);
        assert!(
            r.stderr.contains("denied by SqliteLimits::pragma_deny"),
            "{pragma} did not match policy: {}",
            r.stderr
        );
    }
}

#[tokio::test]
async fn tm_sql_002b_vfs_backend_isolated_to_bash_fs() {
    // Phase 2 backend must not bypass the VFS. We open a file in the VFS,
    // verify it is reachable from inside SQL via .tables (i.e. the engine
    // sees only the VFS), then confirm host /etc/passwd is unreadable.
    let mut bash = Bash::builder()
        .sqlite_with_limits(SqliteLimits::default().backend(SqliteBackend::Vfs))
        .env("BASHKIT_ALLOW_INPROCESS_SQLITE", "1")
        .build();
    bash.exec(r#"sqlite /tmp/iso.sqlite 'CREATE TABLE marker(a)'"#)
        .await
        .unwrap();
    assert!(
        bash.fs()
            .exists(Path::new("/tmp/iso.sqlite"))
            .await
            .unwrap()
    );
}