Skip to content

sqlite: invalid UTF-8 column names can reach safe Column::name() via from_utf8_unchecked #4192

@meng-xu-cs

Description

@meng-xu-cs

I have found these related issues/pull requests

N/A

Description

sqlx-sqlite currently assumes that SQLite column metadata strings are always valid UTF-8:

  • StatementHandle::column_name() calls sqlite3_column_name() and converts the returned bytes with from_utf8_unchecked.
  • VirtualStatement::prepare_next() copies that &str into UStr / SqliteColumn metadata.
  • SqliteColumn::name() then exposes it through the safe public Column::name() -> &str API.
  • describe() also calls the same column_name() helper.

That is unsound for SQLite databases whose schema contains invalid UTF-8 names.

SQLite explicitly documents that schema names (including column names) may contain invalid UTF and that SQLite will continue operating normally because it treats those names as byte sequences. In practice, sqlite3_column_name() can return those bytes unchanged.

Because Rust &str must always be valid UTF-8, constructing one with from_utf8_unchecked from schema-derived bytes violates the str invariant and can lead to undefined behavior later.

Reproduction steps

  1. Create a SQLite database with an invalid-UTF-8 column name using raw SQLite C APIs.
  2. Open that database through safe sqlx APIs and run a normal query.
  3. sqlx constructs column metadata during statement preparation and converts the invalid bytes into &str with from_utf8_unchecked.
use std::ffi::{c_char, CString};
use std::path::PathBuf;
use std::ptr::null_mut;

use sqlx::{Column, Row, SqlitePool};

unsafe fn create_db_with_invalid_column_name(path: &PathBuf) {
    use libsqlite3_sys::{
        sqlite3, sqlite3_close, sqlite3_exec, sqlite3_open,
    };

    let mut db: *mut sqlite3 = null_mut();
    let path_c = CString::new(path.to_string_lossy().as_bytes()).unwrap();
    assert_eq!(sqlite3_open(path_c.as_ptr(), &mut db), 0);

    // Column name bytes are: 62 61 64 ff 63 6f 6c == "bad\xffcol"
    let create_sql = b"CREATE TABLE ok_table(\"bad\xffcol\" INTEGER);\0";
    let insert_sql = b"INSERT INTO ok_table VALUES(123);\0";

    assert_eq!(sqlite3_exec(db, create_sql.as_ptr() as *const c_char, None, null_mut(), null_mut()), 0);
    assert_eq!(sqlite3_exec(db, insert_sql.as_ptr() as *const c_char, None, null_mut(), null_mut()), 0);

    assert_eq!(sqlite3_close(db), 0);
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let path = std::env::temp_dir().join("sqlx-invalid-column-name.db");
    let _ = std::fs::remove_file(&path);

    unsafe { create_db_with_invalid_column_name(&path) };

    let url = format!("sqlite://{}", path.display());
    let pool = SqlitePool::connect(&url).await?;

    let row = sqlx::query("SELECT * FROM ok_table")
        .fetch_one(&pool)
        .await?;

    // Current main reaches here with a logically-invalid `&str`.
    // Any downstream use is now operating on a `str` that may not be valid UTF-8.
    let name = row.columns()[0].name();
    println!("column name bytes: {:x?}", name.as_bytes());
    println!("column name debug: {:?}", name);

    Ok(())
}

SQLx version

main branch

Enabled SQLx features

default

Database server and version

SQLite

Operating system

MacOS

Rust version

1.94.0 (4a4ef493e 2026-03-02)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions