use std::collections::BTreeMap;
use dendro::archive::{sniff, sniff_bytes, Archive, ArchiveMut, Sniff, SourceMeta, WalRow};
use dendro::rewrite::{copy_sources_into, shared_sources, CopySpec};
use dendro::segment::{EncodeResult, SegmentEncoder};
use dendro::Error;
struct Never;
impl SegmentEncoder for Never {
fn encode(&self, _stream: &str, _rows: &[WalRow]) -> EncodeResult {
Ok(None)
}
}
fn source(name: &str) -> SourceMeta {
SourceMeta {
labels: BTreeMap::from([("source".to_string(), name.to_string())]),
metadata: BTreeMap::new(),
clock_anchor_wall_ns: 0,
}
}
fn foreign_sqlite(path: &std::path::Path) {
let conn = rusqlite::Connection::open(path).unwrap();
conn.execute_batch("CREATE TABLE moz_places(id INTEGER PRIMARY KEY, url TEXT);")
.unwrap();
}
fn restamp(path: &std::path::Path, application_id: i64, user_version: i64) {
let conn = rusqlite::Connection::open(path).unwrap();
conn.pragma_update(None, "application_id", application_id)
.unwrap();
conn.pragma_update(None, "user_version", user_version)
.unwrap();
}
fn is_not_an_archive(e: &Error) -> bool {
matches!(e, Error::NotAnArchive { .. })
}
#[test]
fn create_stamps_the_header_and_a_copy_carries_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.dendro");
let db = ArchiveMut::create(&path).unwrap();
assert_eq!(sniff(&path).unwrap(), Sniff::Stamped { version: 4 });
let mem = ArchiveMut::create_in_memory().unwrap();
assert_eq!(
sniff_bytes(&mem.serialize().unwrap()),
Sniff::Stamped { version: 4 }
);
let copy = dir.path().join("copy.dendro");
db.vacuum_into(©).unwrap();
assert_eq!(sniff(©).unwrap(), Sniff::Stamped { version: 4 });
Archive::open(©).unwrap();
}
#[test]
fn a_newer_schema_is_refused_by_name_on_every_open() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.dendro");
drop(ArchiveMut::create(&path).unwrap());
restamp(&path, i64::from(dendro::archive::APPLICATION_ID), 5);
assert_eq!(sniff(&path).unwrap(), Sniff::Stamped { version: 5 });
for result in [
Archive::open(&path).map(drop),
Archive::open(&path).map(drop),
Archive::open_bytes(std::fs::read(&path).unwrap()).map(drop),
] {
match result.unwrap_err() {
Error::UnsupportedSchema { found, writes, .. } => {
assert_eq!((found, writes), (5, 4));
}
other => panic!("expected UnsupportedSchema, got {other}"),
}
}
}
#[test]
fn another_applications_database_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("places.sqlite");
foreign_sqlite(&path);
assert_eq!(sniff(&path).unwrap(), Sniff::NotAnArchive);
let err = Archive::open(&path).unwrap_err();
assert!(is_not_an_archive(&err), "{err}");
assert!(err.to_string().contains("header stamp"), "{err}");
let conn = rusqlite::Connection::open(&path).unwrap();
let mode: String = conn
.pragma_query_value(None, "journal_mode", |r| r.get(0))
.unwrap();
assert_ne!(
mode.to_lowercase(),
"wal",
"the foreign file was left alone"
);
let stamped = dir.path().join("other.db");
foreign_sqlite(&stamped);
restamp(&stamped, 0x4142_4344, 7);
assert_eq!(sniff(&stamped).unwrap(), Sniff::NotAnArchive);
for result in [
Archive::open(&stamped).map(drop),
Archive::open(&stamped).map(drop),
Archive::open_bytes(std::fs::read(&stamped).unwrap()).map(drop),
] {
let err = result.unwrap_err();
assert!(is_not_an_archive(&err), "{err}");
assert!(err.to_string().contains("another application"), "{err}");
}
}
#[test]
fn a_file_that_is_not_sqlite_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.txt");
std::fs::write(
&path,
b"not a database, and longer than a header\n".repeat(4),
)
.unwrap();
assert_eq!(sniff(&path).unwrap(), Sniff::NotAnArchive);
assert!(is_not_an_archive(&Archive::open(&path).unwrap_err()));
assert!(is_not_an_archive(&Archive::open(&path).unwrap_err()));
assert!(is_not_an_archive(
&Archive::open_bytes(std::fs::read(&path).unwrap()).unwrap_err()
));
std::fs::write(&path, b"short").unwrap();
assert_eq!(sniff(&path).unwrap(), Sniff::NotAnArchive);
}
#[test]
fn an_unstamped_header_page_is_refused_by_its_stamp() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("copy.dendro");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
drop(conn);
let bytes = std::fs::read(&path).unwrap();
assert_eq!(bytes.len(), 4096, "header page only");
let err = Archive::open_bytes(bytes).unwrap_err();
assert!(is_not_an_archive(&err), "{err}");
assert!(err.to_string().contains("header stamp"), "{err}");
}
#[test]
fn an_unstamped_file_is_refused_even_with_a_full_catalog() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("old.dendro");
drop(ArchiveMut::create(&path).unwrap());
restamp(&path, 0, 0);
assert_eq!(sniff(&path).unwrap(), Sniff::NotAnArchive);
for result in [
Archive::open(&path).map(drop),
ArchiveMut::open(&path).map(drop),
Archive::open_bytes(std::fs::read(&path).unwrap()).map(drop),
] {
let err = result.unwrap_err();
assert!(is_not_an_archive(&err), "{err}");
assert!(err.to_string().contains("header stamp"), "{err}");
}
}
#[test]
fn a_source_is_minted_a_v4_uuid_and_a_copy_keeps_it() {
let dir = tempfile::tempdir().unwrap();
let src_path = dir.path().join("src.dendro");
let mut src = ArchiveMut::create(&src_path).unwrap();
src.insert_source(&source("a")).unwrap();
src.insert_source(&source("b")).unwrap();
let ids: Vec<String> = src
.read_sources()
.unwrap()
.into_iter()
.map(|s| s.uuid.expect("minted"))
.collect();
assert_ne!(ids[0], ids[1], "two inserts, two identities");
for u in &ids {
assert_eq!(u.len(), 36, "{u}");
assert_eq!(u.as_bytes()[14], b'4', "version nibble: {u}");
assert!(
matches!(u.as_bytes()[19], b'8' | b'9' | b'a' | b'b'),
"variant: {u}"
);
assert!(u.chars().all(|c| c == '-' || c.is_ascii_hexdigit()), "{u}");
}
let dst_path = dir.path().join("dst.dendro");
let mut dst = ArchiveMut::create(&dst_path).unwrap();
dst.transaction(|tx| copy_sources_into(&src, tx, &CopySpec::everything(), &Never))
.unwrap();
let copied: Vec<String> = dst
.read_sources()
.unwrap()
.into_iter()
.map(|s| s.uuid.unwrap())
.collect();
assert_eq!(copied, ids);
let mut shared = shared_sources(&src, &dst).unwrap();
shared.sort();
let mut expected = ids.clone();
expected.sort();
assert_eq!(shared, expected);
let other_path = dir.path().join("other.dendro");
let mut other = ArchiveMut::create(&other_path).unwrap();
other.insert_source(&source("a")).unwrap();
assert!(shared_sources(&src, &other).unwrap().is_empty());
}
#[test]
fn an_archive_without_the_uuid_column_reads_unknown_identity() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("old.dendro");
drop(ArchiveMut::create(&path).unwrap());
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("ALTER TABLE sources DROP COLUMN uuid;")
.unwrap();
conn.execute(
"INSERT INTO sources(labels, metadata, complete, clock_anchor_wall_ns) \
VALUES ('{}', '{}', 1, 0)",
[],
)
.unwrap();
drop(conn);
let db = Archive::open(&path).unwrap();
let rows = db.read_sources().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].uuid, None);
let dst_path = dir.path().join("dst.dendro");
let mut dst = ArchiveMut::create(&dst_path).unwrap();
dst.transaction(|tx| copy_sources_into(&db, tx, &CopySpec::everything(), &Never))
.unwrap();
assert!(dst.read_sources().unwrap()[0].uuid.is_some());
assert!(shared_sources(&db, &dst).unwrap().is_empty());
}