#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use sqlite_core::{Database, Value};
fn sqlite3_bin() -> Option<String> {
let bin = std::env::var("SQLITE3_BIN").unwrap_or_else(|_| "sqlite3".to_string());
Command::new(&bin)
.arg("--version")
.output()
.ok()
.filter(|o| o.status.success())
.map(|_| bin)
}
fn scratch(tag: &str) -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let mut p = std::env::temp_dir();
p.push(format!("sqlite4n6_walsnap_{tag}_{nonce}"));
std::fs::create_dir_all(&p).unwrap();
p
}
fn writer_sql(bin: &str, db: &Path, sql: &str) {
let out = Command::new(bin).arg(db).arg(sql).output().unwrap();
assert!(
out.status.success(),
"sqlite3 writer failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
pub fn oracle_rows(bin: &str, db: &Path) -> Vec<(i64, String, String)> {
let wal_src = db.with_file_name(format!("{}-wal", db.file_name().unwrap().to_string_lossy()));
let tmp_db = db.with_file_name(format!(
"oracle_{}",
db.file_name().unwrap().to_string_lossy()
));
let tmp_wal = tmp_db.with_file_name(format!(
"{}-wal",
tmp_db.file_name().unwrap().to_string_lossy()
));
std::fs::copy(db, &tmp_db).unwrap();
std::fs::copy(&wal_src, &tmp_wal).unwrap();
let out = Command::new(bin)
.arg("-separator")
.arg("\u{1f}")
.arg(&tmp_db)
.arg("SELECT id, name, quote(big) FROM t ORDER BY id")
.output()
.unwrap();
let _ = std::fs::remove_file(&tmp_db);
let _ = std::fs::remove_file(&tmp_wal);
assert!(
out.status.success(),
"sqlite3 oracle failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|line| {
let mut f = line.split('\u{1f}');
let id: i64 = f.next().unwrap().parse().unwrap();
let name = f.next().unwrap().to_string();
let big = f.next().unwrap().to_string();
(id, name, big)
})
.collect()
}
pub struct Snap {
pub db: PathBuf,
pub wal: PathBuf,
}
fn build_incremental_fixture(bin: &str, dir: &Path) -> (std::process::Child, Vec<Snap>) {
let db = dir.join("ev.db");
let wal = dir.join("ev.db-wal");
let mut reader = Command::new(bin)
.arg(&db)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut rin = reader.stdin.take().unwrap();
writeln!(
rin,
"PRAGMA journal_mode=WAL;\nPRAGMA wal_autocheckpoint=0;\nPRAGMA secure_delete=OFF;\n\
CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT, big BLOB);"
)
.unwrap();
rin.flush().unwrap();
std::thread::sleep(std::time::Duration::from_millis(400));
writeln!(rin, "BEGIN;\nSELECT count(*) FROM t;").unwrap();
rin.flush().unwrap();
std::thread::sleep(std::time::Duration::from_millis(400));
reader.stdin = Some(rin);
let mut snaps = Vec::new();
let snap = |dir: &Path, n: usize, db: &Path, wal: &Path| -> Snap {
let sdb = dir.join(format!("snap{n}.db"));
let swal = dir.join(format!("snap{n}.db-wal"));
std::fs::copy(db, &sdb).unwrap();
std::fs::copy(wal, &swal).unwrap();
Snap { db: sdb, wal: swal }
};
writer_sql(
bin,
&db,
"PRAGMA wal_autocheckpoint=0; \
INSERT INTO t VALUES(1,'alice', zeroblob(9000)); \
INSERT INTO t VALUES(2,'bob', x'aabb');",
);
snaps.push(snap(dir, 1, &db, &wal));
writer_sql(
bin,
&db,
"PRAGMA wal_autocheckpoint=0; \
UPDATE t SET name='ALICE', big=randomblob(12000) WHERE id=1; \
DELETE FROM t WHERE id=2; \
INSERT INTO t VALUES(3,'carol',x'00');",
);
snaps.push(snap(dir, 2, &db, &wal));
(reader, snaps)
}
fn teardown(mut reader: std::process::Child, dir: &Path) {
if let Some(mut rin) = reader.stdin.take() {
let _ = writeln!(rin, "COMMIT;\n.quit");
}
let _ = reader.wait();
let _ = std::fs::remove_dir_all(dir);
}
pub fn quote_blob(v: &Value) -> String {
match v {
Value::Null => "NULL".to_string(),
Value::Blob(b) => {
use std::fmt::Write as _;
let mut s = String::from("X'");
for byte in b {
let _ = write!(s, "{byte:02X}");
}
s.push('\'');
s
}
other => panic!("unexpected non-blob big value: {other:?}"),
}
}
fn snapshot_rows(snap: &Snap) -> Vec<(i64, String, String)> {
let main = std::fs::read(&snap.db).unwrap();
let walb = std::fs::read(&snap.wal).expect("snapshot -wal present");
assert!(!walb.is_empty(), "the -wal must be retained (non-empty)");
let db = Database::open_with_wal(main, &walb).expect("open with wal");
let tl = db.wal_timeline().expect("timeline present");
let snapshot = tl
.commit_snapshots()
.last()
.expect("at least one commit snapshot");
let t = snapshot
.tables()
.into_iter()
.find(|t| t.name == "t")
.expect("table t present in snapshot schema");
let mut rows: Vec<(i64, String, String)> = snapshot
.read_table(t.rootpage, t.columns.len())
.expect("snapshot-scoped read")
.into_iter()
.map(|(rowid, values)| {
let id = match values.first() {
Some(Value::Integer(i)) => *i,
_ => rowid,
};
let name = match values.get(1) {
Some(Value::Text(s)) => s.clone(),
_ => String::new(),
};
let big = values.get(2).map_or_else(|| "NULL".to_string(), quote_blob);
(id, name, big)
})
.collect();
rows.sort_by_key(|r| r.0);
rows
}
#[test]
fn snapshot_reads_match_sqlite3_oracle_per_commit() {
let Some(bin) = sqlite3_bin() else {
eprintln!(
"SKIP snapshot_reads_match_sqlite3_oracle_per_commit: no sqlite3 (set SQLITE3_BIN)"
);
return;
};
let dir = scratch("oracle");
let (reader, snaps) = build_incremental_fixture(&bin, &dir);
for (n, snap) in snaps.iter().enumerate() {
let expected = oracle_rows(&bin, &snap.db);
let got = snapshot_rows(snap);
assert_eq!(
got,
expected,
"snapshot {} table state must match the sqlite3 oracle",
n + 1
);
}
let last = oracle_rows(&bin, &snaps[1].db);
let row1 = last.iter().find(|r| r.0 == 1).expect("row 1 at commit 2");
assert_eq!(
row1.2.len(),
24000 + 3,
"row 1 big blob is the 12000-byte overflow value"
);
teardown(reader, &dir);
}
#[test]
fn every_clean_commit_is_checksum_valid_and_a_flip_breaks_the_chain() {
let Some(bin) = sqlite3_bin() else {
eprintln!(
"SKIP every_clean_commit_is_checksum_valid_and_a_flip_breaks_the_chain: no sqlite3"
);
return;
};
let dir = scratch("cksum");
let (reader, snaps) = build_incremental_fixture(&bin, &dir);
let main = std::fs::read(&snaps[1].db).unwrap();
let walb = std::fs::read(&snaps[1].wal).unwrap();
assert!(!walb.is_empty(), "-wal retained (non-empty)");
let db = Database::open_with_wal(main.clone(), &walb).expect("open with wal");
let tl = db.wal_timeline().expect("timeline");
assert!(
!tl.commit_snapshots().is_empty(),
"fixture has commit snapshots"
);
assert!(
tl.commit_snapshots()
.iter()
.all(sqlite_core::CommitSnapshot::checksum_valid),
"every commit in a clean WAL must be checksum-valid"
);
let frame_stride = 24 + db.header().page_size as usize;
assert!(
walb.len() >= 32 + frame_stride,
"at least one frame present"
);
let n_frames = (walb.len() - 32) / frame_stride;
let last_frame_off = 32 + (n_frames - 1) * frame_stride;
let flip_at = last_frame_off + 24 + 16; let mut tampered = walb.clone();
tampered[flip_at] ^= 0x01;
let db2 = Database::open_with_wal(main, &tampered).expect("open with tampered wal");
let tl2 = db2.wal_timeline().expect("timeline");
assert!(
!tl2.commit_snapshots()
.last()
.expect("last commit")
.checksum_valid(),
"a flipped page-data byte must invalidate the last commit's checksum chain"
);
teardown(reader, &dir);
}