use core::ffi::c_void;
use core::mem::size_of;
use libduckdb_sys::duckdb_ext_api_v1;
pub const STABLE_API_SLOT_COUNT: usize = 357;
type LayoutEntry = (u64, u64, u64, u64, usize);
const KNOWN_LAYOUTS: &[LayoutEntry] = &[
(1, 2, 0, 2, 408),
(1, 3, 0, 2, 428),
(1, 4, 0, 4, 459),
(1, 5, 0, 1, 545),
(1, 5, 2, 5, 546),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AbiCheck {
Compatible {
engine_version: String,
slots: usize,
},
StableOnly,
LayoutMismatch {
engine_version: String,
engine_slots: usize,
compiled_slots: usize,
},
UnknownEngineVersion {
engine_version: String,
compiled_slots: usize,
},
EngineVersionUnavailable {
compiled_slots: usize,
},
DeclaredVersionMismatch {
declared_version: String,
declared_slots: usize,
compiled_slots: usize,
},
}
impl AbiCheck {
#[must_use]
pub const fn is_compatible(&self) -> bool {
matches!(self, Self::Compatible { .. } | Self::StableOnly)
}
#[must_use]
pub fn error_message(&self) -> Option<String> {
match self {
Self::Compatible { .. } | Self::StableOnly => None,
Self::LayoutMismatch {
engine_version,
engine_slots,
compiled_slots,
} => Some(format!(
"DuckDB C extension API layout mismatch: this extension was built against a \
duckdb_ext_api_v1 with {compiled_slots} slots, but DuckDB {engine_version} \
provides {engine_slots}. The extension uses the unstable region of the C API \
(quack-rs feature `duckdb-1-5`), whose slot indices differ between these \
releases, so loading it would dispatch to the wrong functions. Rebuild the \
extension against DuckDB {engine_version}, and stamp it with \
`--abi-type C_STRUCT_UNSTABLE --duckdb-version {engine_version}` \
(or `USE_UNSTABLE_C_API=1` with extension-ci-tools) so this is caught at \
install time."
)),
Self::UnknownEngineVersion {
engine_version,
compiled_slots,
} => Some(format!(
"DuckDB C extension API layout cannot be verified: DuckDB reports version \
'{engine_version}', which this build of quack-rs has no verified \
duckdb_ext_api_v1 layout for (this extension was built against a \
{compiled_slots}-slot layout). The extension uses the unstable region of the \
C API (quack-rs feature `duckdb-1-5`), and DuckDB has changed that region's \
layout in every recent release, so loading is refused rather than risking \
mis-dispatch. Fix it in one of these ways, best first: rebuild against DuckDB \
{engine_version} and set QUACK_RS_TARGET_DUCKDB_VERSION={engine_version} so \
this check passes without waiting for a quack-rs release; upgrade quack-rs to \
a version whose layout table lists {engine_version}; or accept the risk with \
`AbiPolicy::AllowUnknownEngine`."
)),
Self::DeclaredVersionMismatch {
declared_version,
declared_slots,
compiled_slots,
} => Some(format!(
"QUACK_RS_TARGET_DUCKDB_VERSION says this extension was built against DuckDB \
{declared_version}, whose duckdb_ext_api_v1 has {declared_slots} slots, but the \
libduckdb-sys bindings compiled in have {compiled_slots}. The declaration and \
the resolved dependency disagree, so neither can be trusted. Check which \
libduckdb-sys version Cargo resolved (`cargo tree -p libduckdb-sys`) and make \
QUACK_RS_TARGET_DUCKDB_VERSION match it."
)),
Self::EngineVersionUnavailable { compiled_slots } => Some(format!(
"DuckDB C extension API layout cannot be verified: duckdb_library_version() \
returned no usable version string (this extension was built against a \
{compiled_slots}-slot layout). Refusing to call into the unstable region of \
the C API."
)),
}
}
}
#[must_use]
#[inline]
pub const fn compiled_slot_count() -> usize {
size_of::<duckdb_ext_api_v1>() / size_of::<*const c_void>()
}
#[must_use]
#[inline]
pub const fn uses_unstable_api() -> bool {
cfg!(feature = "duckdb-1-5")
}
#[must_use]
pub fn parse_version(version: &str) -> Option<(u64, u64, u64)> {
let trimmed = version.strip_prefix('v').unwrap_or(version);
let mut parts = trimmed.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some((major, minor, patch))
}
#[must_use]
pub fn expected_slot_count(version: &str) -> Option<usize> {
let (major, minor, patch) = parse_version(version)?;
KNOWN_LAYOUTS
.iter()
.find(|&&(ma, mi, lo, hi, _)| ma == major && mi == minor && patch >= lo && patch <= hi)
.map(|&(_, _, _, _, slots)| slots)
}
#[must_use]
pub unsafe fn engine_version() -> Option<String> {
let ptr = unsafe { libduckdb_sys::duckdb_library_version() };
if ptr.is_null() {
return None;
}
let cstr = unsafe { core::ffi::CStr::from_ptr(ptr) };
cstr.to_str().ok().map(str::to_owned)
}
#[must_use]
pub fn built_against_version() -> Option<&'static str> {
let declared = option_env!("QUACK_RS_BUILT_AGAINST_DUCKDB")?;
parse_version(declared).map(|_| declared)
}
#[must_use]
pub unsafe fn check() -> AbiCheck {
let compiled_slots = compiled_slot_count();
if !uses_unstable_api() {
return AbiCheck::StableOnly;
}
let engine = unsafe { engine_version() };
decide(compiled_slots, built_against_version(), engine.as_deref())
}
fn decide(compiled_slots: usize, declared: Option<&str>, engine: Option<&str>) -> AbiCheck {
if let Some(declared_version) = declared {
if let Some(declared_slots) = expected_slot_count(declared_version) {
if declared_slots != compiled_slots {
return AbiCheck::DeclaredVersionMismatch {
declared_version: declared_version.to_owned(),
declared_slots,
compiled_slots,
};
}
}
}
let Some(engine_version) = engine else {
return AbiCheck::EngineVersionUnavailable { compiled_slots };
};
if declared.is_some_and(|d| parse_version(d) == parse_version(engine_version)) {
return AbiCheck::Compatible {
engine_version: engine_version.to_owned(),
slots: compiled_slots,
};
}
match expected_slot_count(engine_version) {
Some(engine_slots) if engine_slots == compiled_slots => AbiCheck::Compatible {
engine_version: engine_version.to_owned(),
slots: compiled_slots,
},
Some(engine_slots) => AbiCheck::LayoutMismatch {
engine_version: engine_version.to_owned(),
engine_slots,
compiled_slots,
},
None => AbiCheck::UnknownEngineVersion {
engine_version: engine_version.to_owned(),
compiled_slots,
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AbiPolicy {
#[default]
Strict,
Warn,
AllowUnknownEngine,
Trust,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn struct_size_is_a_whole_number_of_pointers() {
assert_eq!(
size_of::<duckdb_ext_api_v1>() % size_of::<*const c_void>(),
0
);
}
#[test]
fn compiled_layout_is_one_we_have_verified() {
let slots = compiled_slot_count();
assert!(
KNOWN_LAYOUTS.iter().any(|&(_, _, _, _, s)| s == slots),
"libduckdb-sys resolved to a duckdb_ext_api_v1 with {slots} slots, which is not in \
KNOWN_LAYOUTS. Re-run scripts/check-abi-table.py and add the new DuckDB release."
);
}
#[test]
fn stable_prefix_fits_inside_every_known_layout() {
for &(_, _, _, _, slots) in KNOWN_LAYOUTS {
assert!(slots > STABLE_API_SLOT_COUNT);
}
}
#[test]
fn known_layouts_are_sorted_and_non_overlapping() {
let mut prev: Option<(u64, u64, u64)> = None;
for &(ma, mi, lo, hi, _) in KNOWN_LAYOUTS {
assert!(lo <= hi, "patch range {lo}..={hi} is inverted");
let key = (ma, mi, lo);
if let Some(p) = prev {
assert!(p < key, "KNOWN_LAYOUTS must be sorted: {p:?} !< {key:?}");
}
prev = Some(key);
}
}
#[test]
fn slot_count_uniquely_identifies_a_layout() {
let mut seen: Vec<usize> = KNOWN_LAYOUTS.iter().map(|&(.., s)| s).collect();
seen.sort_unstable();
let len_before = seen.len();
seen.dedup();
assert_eq!(
len_before,
seen.len(),
"duplicate slot counts in KNOWN_LAYOUTS"
);
}
#[test]
fn parses_version_strings() {
assert_eq!(parse_version("v1.5.4"), Some((1, 5, 4)));
assert_eq!(parse_version("1.5.4"), Some((1, 5, 4)));
assert_eq!(parse_version("v1.5"), None);
assert_eq!(parse_version("v1.5.4.1"), None);
assert_eq!(parse_version("v1.6.0-dev1234"), None);
assert_eq!(parse_version(""), None);
assert_eq!(parse_version("nonsense"), None);
}
#[test]
fn maps_every_documented_release_family() {
for (version, slots) in [
("v1.2.0", 408),
("v1.2.2", 408),
("v1.3.0", 428),
("v1.3.2", 428),
("v1.4.0", 459),
("v1.4.4", 459),
("v1.5.0", 545),
("v1.5.1", 545),
("v1.5.2", 546),
("v1.5.5", 546),
] {
assert_eq!(expected_slot_count(version), Some(slots), "for {version}");
}
}
#[test]
fn declared_version_mismatch_matches_the_ci_regression_test() {
for engine in [None, Some("v1.5.0"), Some("v1.5.5"), Some("v9.9.9")] {
let check = decide(545, Some("v1.2.0"), engine);
assert!(
matches!(check, AbiCheck::DeclaredVersionMismatch { .. }),
"engine {engine:?} gave {check:?}"
);
assert!(!check.is_compatible());
let msg = check.error_message().expect("a refusal carries a message");
assert!(
msg.contains(
"QUACK_RS_TARGET_DUCKDB_VERSION says this extension was built against DuckDB"
),
"the CI job greps for this exact prefix: {msg}"
);
}
}
#[test]
fn does_not_extrapolate_beyond_verified_patches() {
assert_eq!(expected_slot_count("v1.4.5"), None);
assert_eq!(expected_slot_count("v1.5.6"), None);
assert_eq!(expected_slot_count("v1.6.0"), None);
assert_eq!(expected_slot_count("v2.0.0"), None);
}
#[test]
fn compatible_and_stable_only_are_the_only_passing_states() {
assert!(AbiCheck::StableOnly.is_compatible());
assert!(AbiCheck::Compatible {
engine_version: "v1.5.4".into(),
slots: 546
}
.is_compatible());
assert!(!AbiCheck::LayoutMismatch {
engine_version: "v1.5.0".into(),
engine_slots: 545,
compiled_slots: 546
}
.is_compatible());
assert!(!AbiCheck::UnknownEngineVersion {
engine_version: "v9.9.9".into(),
compiled_slots: 546
}
.is_compatible());
assert!(!AbiCheck::EngineVersionUnavailable {
compiled_slots: 546
}
.is_compatible());
}
#[test]
fn passing_states_have_no_error_message() {
assert!(AbiCheck::StableOnly.error_message().is_none());
assert!(AbiCheck::Compatible {
engine_version: "v1.5.4".into(),
slots: 546
}
.error_message()
.is_none());
}
#[test]
fn mismatch_message_names_both_layouts_and_the_remedy() {
let msg = AbiCheck::LayoutMismatch {
engine_version: "v1.5.0".into(),
engine_slots: 545,
compiled_slots: 546,
}
.error_message()
.expect("mismatch must produce a message");
assert!(msg.contains("546"));
assert!(msg.contains("545"));
assert!(msg.contains("v1.5.0"));
assert!(msg.contains("C_STRUCT_UNSTABLE"));
}
#[test]
fn unavailable_version_message_mentions_the_missing_probe() {
let msg = AbiCheck::EngineVersionUnavailable {
compiled_slots: 546,
}
.error_message()
.expect("unavailable version must produce a message");
assert!(msg.contains("duckdb_library_version"));
}
#[test]
fn a_known_engine_with_a_matching_layout_is_compatible() {
assert!(matches!(
decide(546, None, Some("v1.5.4")),
AbiCheck::Compatible { slots: 546, .. }
));
}
#[test]
fn a_known_engine_with_a_different_layout_is_a_mismatch() {
assert!(matches!(
decide(546, None, Some("v1.5.0")),
AbiCheck::LayoutMismatch {
engine_slots: 545,
compiled_slots: 546,
..
}
));
}
#[test]
fn an_unknown_engine_is_unknown_without_a_declaration() {
assert!(matches!(
decide(546, None, Some("v1.6.0")),
AbiCheck::UnknownEngineVersion { .. }
));
}
#[test]
fn declaring_the_engine_version_makes_an_unknown_release_compatible() {
assert!(matches!(
decide(560, Some("v1.6.0"), Some("v1.6.0")),
AbiCheck::Compatible { slots: 560, .. }
));
}
#[test]
fn a_declaration_for_a_different_release_does_not_excuse_a_known_mismatch() {
assert!(matches!(
decide(546, Some("v1.5.4"), Some("v1.5.0")),
AbiCheck::LayoutMismatch {
engine_slots: 545,
compiled_slots: 546,
..
}
));
}
#[test]
fn a_declaration_contradicting_the_bindings_is_reported_first() {
assert!(matches!(
decide(545, Some("v1.5.5"), Some("v1.5.0")),
AbiCheck::DeclaredVersionMismatch {
declared_slots: 546,
compiled_slots: 545,
..
}
));
}
#[test]
fn an_unparseable_engine_version_is_unknown_not_a_match() {
assert!(matches!(
decide(546, Some("v1.6.0"), Some("v1.6.0-dev1234")),
AbiCheck::UnknownEngineVersion { .. }
));
}
#[test]
fn a_missing_engine_version_is_never_compatible() {
assert!(matches!(
decide(546, Some("v1.5.4"), None),
AbiCheck::EngineVersionUnavailable { .. }
));
}
#[test]
fn declared_version_is_none_unless_it_parses() {
assert_eq!(built_against_version(), None);
}
#[test]
fn declared_version_mismatch_is_a_failure_with_a_diagnostic() {
let check = AbiCheck::DeclaredVersionMismatch {
declared_version: "v1.5.0".into(),
declared_slots: 545,
compiled_slots: 546,
};
assert!(!check.is_compatible());
let msg = check.error_message().expect("must produce a message");
assert!(msg.contains("QUACK_RS_TARGET_DUCKDB_VERSION"), "{msg}");
assert!(msg.contains("545") && msg.contains("546"), "{msg}");
assert!(msg.contains("libduckdb-sys"), "{msg}");
}
#[test]
fn unknown_version_message_lists_every_remedy_in_order() {
let msg = AbiCheck::UnknownEngineVersion {
engine_version: "v1.6.0".into(),
compiled_slots: 546,
}
.error_message()
.expect("unknown version must produce a message");
let declare = msg
.find("QUACK_RS_TARGET_DUCKDB_VERSION")
.expect("declare remedy");
let upgrade = msg.find("upgrade quack-rs").expect("upgrade remedy");
let accept = msg.find("AllowUnknownEngine").expect("opt-out remedy");
assert!(declare < upgrade && upgrade < accept, "{msg}");
}
#[test]
fn policies_are_ordered_from_safest_to_loosest() {
assert_eq!(AbiPolicy::default(), AbiPolicy::Strict);
assert_ne!(AbiPolicy::AllowUnknownEngine, AbiPolicy::Trust);
assert_ne!(AbiPolicy::AllowUnknownEngine, AbiPolicy::Strict);
}
#[test]
fn default_policy_is_strict() {
assert_eq!(AbiPolicy::default(), AbiPolicy::Strict);
}
#[test]
fn unstable_api_flag_tracks_the_feature() {
assert_eq!(uses_unstable_api(), cfg!(feature = "duckdb-1-5"));
}
}
#[cfg(all(test, feature = "_duckdb-testing"))]
mod live_tests {
use super::*;
#[test]
fn engine_version_is_readable_and_parses() {
let _db = crate::testing::InMemoryDb::open().expect("open in-memory DuckDB");
let version = unsafe { engine_version() }.expect("duckdb_library_version()");
assert!(
parse_version(&version).is_some(),
"engine reported an unparseable version: {version:?}"
);
}
#[test]
fn linked_engine_layout_matches_the_compiled_bindings() {
let _db = crate::testing::InMemoryDb::open().expect("open in-memory DuckDB");
let version = unsafe { engine_version() }.expect("duckdb_library_version()");
assert_eq!(
expected_slot_count(&version),
Some(compiled_slot_count()),
"KNOWN_LAYOUTS says DuckDB {version} has a different duckdb_ext_api_v1 slot count \
than the libduckdb-sys bindings this crate compiled against ({} slots). Re-run \
scripts/check-abi-table.py.",
compiled_slot_count()
);
}
#[test]
fn check_reports_compatibility_against_the_linked_engine() {
let _db = crate::testing::InMemoryDb::open().expect("open in-memory DuckDB");
let result = unsafe { check() };
assert!(
result.is_compatible(),
"ABI check failed against the linked DuckDB: {result:?}"
);
assert!(result.error_message().is_none());
}
}