Changelog
All notable changes to quack-rs, mirrored from
CHANGELOG.md.
The format follows Keep a Changelog. quack-rs adheres to Semantic Versioning.
Unreleased
0.18.0 — 2026-09-30
This release comes out of a second production-readiness audit (AUDIT.md
section 7) and the three passes that followed it (sections 8 to 10). Each
code defect below was either reproduced against a real DuckDB before it was
fixed or, where nothing could trigger it, derived from DuckDB's source;
AUDIT.md records which (VALIDATED or PROVEN). Each code fix has a
regression test where one could be written, and the trait-bound fixes are
pinned by compile_fail doctests. The exceptions: the fixes AUDIT.md
marks PROVEN only, among them the entry points' NULL duckdb_database*,
AbiPolicy::Warn's eprintln! and catalog lookups in a catalog that is not
"duckdb". The FileHandle drop fix is tested by swapping failing C API
stubs into the dispatch table, since no file system DuckDB ships throws
from Close(). The 32-bit size fixes are unit-tested on wasm32 itself (CI's
wasm job runs the unit tests under node); what DuckDB does with an
oversized allocation there is derived from its source, as no wasm32 build
of DuckDB is available to test against.
Several fixes close holes in the safe API — places where safe code could
cause undefined behaviour, a data race or a process abort — and those needed
signature or trait-bound changes, so this is a breaking release; it follows
0.16.0. Each such entry is marked Breaking:.
0.17.0 was prepared but never published; its changes are included here, and the entries below describe the change from 0.16.0.
A third pass followed before anything was published (AUDIT.md section 8).
It fixed further defects, reproducing them against a real DuckDB wherever that
was possible, added CI gates that compile the Rust examples in the book and the
README and check the book's links, and corrected the documentation's claim that
an aggregate's update never sees NULL rows (see Fixed).
A fourth pass followed that (AUDIT.md section 9). It fixed process aborts,
out-of-bounds reads and writes, and wrong answers. Each was reproduced against
a real DuckDB before it was fixed, and its regression test was shown failing
without the fix; AUDIT.md names the DuckDB versions per finding. The pass
also documented thirteen DuckDB defects in docs/upstream-duckdb-reports.md, each
with a plain-C reproducer.
A fifth pass followed (AUDIT.md section 10). Each code fix has a regression
test shown failing without the fix, except as noted above, and each defect
that involves DuckDB was reproduced against a real DuckDB first, except the
catalog lookup and the 32-bit size fixes, which are PROVEN. It documented
eighteen DuckDB defects (items 20 to 37 of docs/upstream-duckdb-reports.md),
each with a plain-C reproducer run on the releases it names.
Within Added, Changed, Fixed and Security, entries are grouped by the pass that produced them: Fifth audit, Fourth audit, and Earlier passes (the 0.17.0 work, the second audit and the third). Security has no Fifth audit group; that pass's memory-safety fixes are under Fixed. Dependencies is not grouped.
Added
Fifth audit
tests/aggregate_leaks.rs, a test binary with a counting global allocator, andtests/handle_leaks.rs, which bounds the C heap (glibcmallinfo2) across many create-and-drop rounds of 15 handles whoseDropno functional test observed (DbConfig,Value,Appender,TableDescription,OwnedDataChunkandPreparedStatement::parameter_name; withduckdb-1-5alsoExpression,ClientContext,FileOpenOptions,FileSystem,FileHandle,SelectionVector,InstanceCache,ErrorDataandCatalog). It runs only on Linux with glibc.vector::max_child_capacity: the most elementsDuckDBcan hold in a list vector's child buffer, whichListBuildernow respects.- A
value_renderfuzz target (fuzz/, featurelive) that renders arbitrary temporal payloads, alone and nested in lists, through a realDuckDB. - CI:
test-older-enginesalso runs the suite against 1.4.5 (the last 1.4 release) and against 1.5.3 and 1.5.4 with theduckdb-1-5-3andduckdb-1-5-4features: the first releases whose bindings those features compile against (the job pins the bindings to the engine's release, which the test shim requires; aduckdb-1-5-4build needs only a 1.5.0+ engine at run time).tests/append_metadata_cli.rsruns theappend_metadatabinary end to end. - CI: the
mirijob also runs the library tests on a big-endian target (s390x-unknown-linux-gnu, interpreted), which is where the string decoder's byte-order defect shows (see Fixed).
Fourth audit
scalar_bind_callback!/scalar_init_callback!(duckdb-1-5).validate_parameter_name,DUCKDB_UNCALLABLE_KEYWORDS,DUCKDB_UNREFERENCEABLE_PARAMETER_KEYWORDS.value::UNRENDERABLE,callback::EMPTY_PANIC_PLACEHOLDER,callback::panic_c_message,CopyGlobalInitInfo::get_file_path_bytes,scalar::info::EMPTY_ERROR_PLACEHOLDER,aggregate::info::EMPTY_ERROR_PLACEHOLDER.- CI:
test-older-enginesruns the suite against DuckDB 1.4.4 (default features) and 1.5.0 (duckdb-1-5), the oldest release each can load into; every engine-specific defect below went unnoticed without it. A weekly scheduled run; the scaffold job runs the generated project'smake configure release test;check-abi-table.pyfingerprints whole signatures, not names.
Earlier passes (0.17.0, second and third audits)
-
Scalar functions as safe Rust closures.
ScalarFunctionBuilder::map1/map2/map1_str/map2_str/map1_opt/map2_opttake an ordinary closure; parameter and return types come from its signature, NULLs propagate correctly, and a panic becomes a SQL error.VARCHARgets its own constructors so the closure can borrow a&strstraight out of the vector. One indirect call per chunk, not per row. Each returnsResult<TypedScalarFunctionBuilder, _>, which offersname(),volatile()andregister(con)but cannot change the signature (code that passes it to aRegistrarcallsRegistrar::register_typed_scalar), and the trampoline re-checks each chunk's vector types, so the declared result width always matches the closure's. Building one does not call DuckDB, so it works in a unit test withMockRegistrar;registerchecks the types. -
Valuegained constructors for the remaining integer and float widths, the rest of the temporal family,INTERVAL,BLOB,DECIMAL, and the composites (STRUCT,LIST,ARRAY,ENUM;MAPandUNIONbehindduckdb-1-5); there is still none forBITorBIGNUM. Alsois_sql_null(distinct fromis_null, which asks about the handle) andas_enum_index.struct_valuechecks the field count first, becauseduckdb_create_struct_valuetakes no count and reads one value per field of the type.list_value/array_valuetake the element type:duckdb.hcontradicts itself here, and the implementation settles it. The element type may itself be aLISTorARRAY(lists of lists, arrays of arrays); the error explaining the element-type rule appears only when DuckDB itself refuses the value.decimalchecks width (1..=38), scale and the unscaled value's digit count before calling DuckDB, which would otherwise abort the process or store a different number. The temporal constructors other thandateandintervalreturnResultand refuse a payload DuckDB cannot render (see theValue::time_ns/Value::timestampentry under Changed). -
PreparedStatementgained 16 more typed binds andbind_value, the escape hatch for every composite type.bind_decimalvalidates likeValue::decimal:duckdb_bind_decimalchecks nothing, and forwidth <= 18keeps only the low 64 bits of the unscaled value. -
Cancellation and progress.
OwnedConnection::interrupt_handlereturns aSend + SyncInterruptHandle, lifetime-tied to the connection, whosecancela watchdog thread can use to stop a running query and whoseprogressreads itsQueryProgress.OwnedConnection::interrupt/progressdo the same through the connection itself, and theunsafequery::interrupt/query::query_progresstake a raw connection. -
Streaming results.
PreparedStatement::execute_streamingandQueryResult::is_streaming(duckdb-1-5). -
QueryResult::column_logical_typekeeps the nested structure thatcolumn_typecollapses, andresult_kindseparates rows from row counts (the newquery::ResultKind). -
LogicalType::register—CREATE TYPEfrom the C API, so an extension can ship a namedENUMorSTRUCT. Stable-prefix; no feature needed. -
ScalarBindData<T>/ScalarLocalState<T>(duckdb-1-5) — typed bind data and per-thread local state for scalar functions, with theduckdb_delete_callback_tgenerated and panic-safe. The rawset_bind_data/set_stateroute makes the extension author write their ownunsafe extern "C" fnaroundBox::from_raw, which is the abort hazard under Security relocated into user code. DuckDB reads scalar bind data from every executing thread at once, soScalarBindData<T>requiresT: Send + Sync + 'staticandScalarLocalState<T>T: Send + 'static.ScalarBindData::setalso requiresT: Clone(wrap other data inArc<T>): it registers a generated, panic-safe copy callback, without which the bind data is lost whenever the optimizer copies the bound expression — a wrong answer, not an error (Pitfall L10; seeScalarBindInfo::set_bind_data_copyunder Fixed). A secondsetleaks, rather than drops, the first value. -
vector::ops(duckdb-1-5) makesSelectionVectorusable:copy_selected,slice,reference_value,reference_vectorandOwnedVector. Documents thatsliceproduces a dictionary vector, after which every reader in this crate reads the wrong rows.OwnedVector::newwalks the type, child vectors included, with checked arithmetic and refuses a capacity abovevector::ops::MAX_CAPACITYbefore DuckDB allocates: DuckDB computes the buffer size with an unchecked multiply, so(HUGEINT, 2^60 + 2)would get a 32-byte buffer. -
Arrow C Data Interface bridge — the new
arrowmodule behind a newduckdb-1-5-4feature, wrapping the eight-function conversion family already in DuckDB 1.4.4's C API:ArrowOptions<'conn>, which borrows its connection so that a conversion cannot read freed memory after the connection closes (the safefrom_connection(&'conn OwnedConnection), and theunsafefrom_raw_connection,from_resultandQueryResult::arrow_options), owningArrowSchema/ArrowArray/ArrowConvertedSchemaRAII types, andto_arrow_schema/data_chunk_to_arrow/schema_from_arrow/data_chunk_from_arrow. Noarrowcrate dependency: the module works on the ABI recordslibduckdb-sysdefines, which have arrow-rs'sFFI_ArrowSchema/FFI_ArrowArraylayout, so bridging is a pointer cast —ArrowArray::take_frommoves a record out of a foreign wrapper and leaves a released placeholder, so only one side ever callsrelease.The ownership rules were read out of
arrow-c.cppandarrow_converter.cpprather than inferred:duckdb_data_chunk_from_arrowsetsarrow_array->release = nullptrbefore the conversion loop body, so it claims the array on the error path too — hencedata_chunk_from_arrowtakes the array by value, and the by-value binding still releases it in the one case (a zero-column schema) where the loop never runs.to_arrow_schemaanddata_chunk_to_arrowinstallreleaselast, after everything that can throw, so a failed conversion leaves nothing to free.Two crashes DuckDB does not guard are refused here instead:
duckdb_data_chunk_from_arrowindexesarrow_array->children[i]once per schema column with no bounds check and dereferences an already-released array, soArrowConvertedSchemaremembers its column count and both are checked first.data_chunk_from_arrowalso returnsInvalidInputfor a negative length, a nonzero top-level offset (DuckDB ignores it and imports the wrong rows), a nullchildrenpointer, a null child, and a child shorter than the array's length, each of which DuckDB would dereference, read out of bounds or misread, and for a zero-row array, which DuckDB passes on as a zero-byte allocation that a debug build asserts against. It refuses a length, or a nested row count, abovevector::ops::MAX_CAPACITY, and the valid Arrow layouts DuckDB imports from the wrong rows or out of bounds (seesrc/arrow/import_layout.rsand the Fifth audit entries under Fixed). Its Safety section requires that the array conform to the schema — nothing in an Arrow array records its type, so a child whose buffers do not match the type its schema declares cannot be checked — and that the length be the array's true row count: DuckDB allocates the chunk before itstryblock, so a length below that ceiling that the system cannot allocate still aborts the process. Dictionary-encoded and null-type columns are copied into flat vectors before it returns; run-end-encoded children are expanded by DuckDB. TIMETZ comes back as TIME without its offset and BIT as BLOB.The
duckdb-1-5-4feature's floor is set by the bindings, not by DuckDB: the eight functions predate 1.5, and the feature needs only the 1.5.0+ engine thatduckdb-1-5does at run time, butlibduckdb-sysdeclaredArrowSchema/ArrowArrayas opaque zero-sized bindgen placeholders until 1.10504.0.src/arrow.rscarries aconstassertion that says so. -
COPY … FROM—CopyFunctionBuilder::copy_fromattaches a quack-rs table function as a format's reader, so an extension can implement loading as well as writing. Supporting pieces:TableFunctionBuilder::build_handlereturns a configured, unregisteredTableFunctionHandle—registeris now that plusduckdb_register_table_function— because aCOPY … FROMreader is attached to a copy function rather than registered on its own.BindInfo::result_column_count/result_column_name/result_column_type(duckdb-1-5) read the target table's schema, whichCOPY … FROMfixes before the bind callback runs.duckdb.his explicit that such a bind "should not define its own result columns".CopyBindInfo::optionsexposes theCOPY … TOoptions as the STRUCT value DuckDB builds, andValue::struct_field_nameswalks the field names off the value's borrowed logical type without exposing the handle (pitfall P11); for a UNION it returns an empty name for the tag, then the member names.CopyFunctionBuilder::extra_info, with the same ownership-until-transfer guarantee as the other builders.
duckdb_copy_function_set_copy_from_functionreports every rejection by doing nothing at all, socopy_fromchecksduckdb.h's stated precondition — "the table function must take a single VARCHAR parameter (the file path)" — which DuckDB never enforces:CCopyFromBindbuilds the argument list itself and never consultstf.arguments, so a mismatch surfaces much later inside the reader's own bind callback. -
TableFunctionBuilder::with_bind_init(bind, init): immutable bind dataB: Send + Syncand a fresh scan stateS: Sendper execution, noCloneneeded. -
TypedScalarFunctionBuilder;Registrar::register_typed_scalar(with a default implementation, so existingRegistrars compile). -
StructWriter::set_row_null. -
callback::drop_panic_payload,callback::take_panic_messageandcallback::MAX_NESTED_PAYLOAD_DROPS. -
selection_vector::MAX_LEN;datetime::is_valid_date,MICROS_PER_DAY,TIME_TZ_MAX_OFFSET_SECONDS,DECIMAL_MAX_WIDTH. -
CatalogEntryType::is_lookup_supported;ReplacementScanInfo::EMPTY_ERROR_PLACEHOLDER. -
Aggregate function sets support a different return type per overload (#121).
DuckDBresolves an aggregate overload from its parameter types and arity only — the return type takes no part in resolution — so members of one set are free to return different types, which is howDuckDB's ownarg_max(ANY, ANY) -> ANYandarg_max(ANY, ANY, ANY) -> ANY[]coexist. quack-rs had the return type onAggregateFunctionSetBuilderalone, which made that impossible to express.AggregateOverloadBuildernow carriesreturns/returns_logical, andAggregateFunctionSetBuilder::overload(..)takes one fully-configured overload — mirroringScalarFunctionSetBuilder::overload/ScalarOverloadBuilder, which already worked this way:#![allow(unused)] fn main() { AggregateFunctionSetBuilder::new("my_agg") .overload( AggregateOverloadBuilder::new() .param(TypeId::Integer) .returns(TypeId::Integer) // ... callbacks ) .overload( AggregateOverloadBuilder::new() .param(TypeId::Varchar) .returns(TypeId::Varchar) // ... callbacks ) .register(con)?; }This is additive.
returns/returns_logicalon the set now act as a default for every overload that does not set its own, so existingreturns(..).overloads(range, ..)code keeps working unchanged. As for return types, registration fails, naming the overload index, only when an overload has neither its own nor a set-level default; a missing callback, a duplicate signature or a compositeTypeIdfails it too. -
Unit tests asserting that every
AggregateOverloadBuildercallback setter stores into its own field. These setters areconst fn, which makes their cargo-mutants mutants unviable rather than caught —Default::default()cannot be called in a const context, so the replacement fails to compile and the mutation gate is structurally silent about them. That is a property of the gate, not evidence the setters work. -
An AddressSanitizer job (
ci.yml; blocking — it was informational until its first green run).leak-checkanswers "did we forget a destructor"; ASAN answers "did we write outside an allocation, or use one after free" — the class behind the two heap-corruption defects fixed in v0.16.0, and the one a crate doing raw pointer arithmetic into DuckDB's memory is most exposed to. Miri cannot reach those paths: they call foreign functions. -
scripts/duckdb-version-from-lock.sh— derives the DuckDB release tag from thelibduckdb-syspin inCargo.lock(1.10505.0 → v1.5.5). Two CI jobs hard-codedv1.5.4next to a comment asking the next person to keep it in sync with the dependency; bumping the lockfile would have left both linking a libduckdb one release older than the bindings being generated against it.test-older-engineschecks its output against the engine each of its five matrix entries names, two of them in the pre-1.5 scheme. -
scripts/sync-book-changelog.py— the book's changelog page is now generated fromCHANGELOG.md, with a--checkmode wired into thedocjob. Kept by hand, the mirror had fallen ~19 KB behind and the published 0.16.0 entry was missing its entire "Portability and feature-combination breakage" subsection. The deliberate differences — the book page's own preamble, an em dash in release headings, and links to repository files rewritten as GitHub URLs — are applied by the script. -
timeout-minuteson everyci.ymljob (36 at this release). The default is 360 per job, so a hang intest-bundled(which compiles DuckDB from C++ source) orleak-check(-Zbuild-std) burned six hours of runner time. -
First end-to-end coverage of the aggregate function-set registration path (
tests/ffi_roundtrip.rs). Neither the aggregate nor the scalar set builder had an E2E test, despite Pitfall L6 — a set member whose name is unset is dropped silently. Four new tests register a real three-overload set (BIGINT -> BIGINT,VARCHAR -> VARCHAR,(BIGINT, BIGINT) -> DECIMAL(18,2)viareturns_logical), asserttypeof(..)per overload, check the computed values across multiple chunks and underGROUP BY, and cover the set-level default and both rejection paths. -
LogicalType::try_get_type_id, which returnsNonefor a type id this crate does not know;get_type_idpanics there, and now documents that it does. -
A fallible form of every
LogicalTypeconstructor: the newtry_decimal,try_array,try_array_from_logical,try_list_from_logicalandtry_map_from_logicaljoin the existingtry_*functions. Alsotypes::logical_type::MAX_UNION_MEMBERS(255: DuckDB 1.5.6 lowered its limit from 256, and asserts it when building the type). -
VectorWriter::try_write_varchar/try_write_blob, which return an error and write nothing for a value longer than the newvector::string::MAX_STRING_LEN(u32::MAXbytes, DuckDB's string length limit); alsovector::string::check_string_len. -
ListBuilder::with_element_limit, to cap list lengths that come from input at what fits in memory. Staying under the builder's own ceiling is not enough —MAX_LIST_CHILD_CAPACITYbytes per child buffer, whichmax_child_capacityturns into an element limit for the child's type (see the Fifth audit entries under Fixed):duckdb_list_vector_reservehas notry/catch, so a failed allocation below that ceiling also aborts the process.ListVector::reservenow documents this. -
vector::ops::MAX_CAPACITY, the largest capacityOwnedVector::newaccepts (2^37 elements, DuckDB'sMAX_VECTOR_SIZE, on a 64-bit target; 2^28 - 1 on a 32-bit one). -
MockVectorWriter::set_validandMockVectorWriter::is_written(see theMockVectorWriterentry under Changed). -
CatalogEntryType::may_autoload_extension, the name check behind the new catalog-lookup refusal (see Changed). -
An
EMPTY_ERROR_PLACEHOLDERfor table functions (table::info::EMPTY_ERROR_PLACEHOLDER), copy functions (copy_function::info::EMPTY_ERROR_PLACEHOLDER) and casts (CastFunctionInfo::EMPTY_ERROR_PLACEHOLDER), andcallback::CAST_FAILED_WITHOUT_MESSAGE(see Fixed). -
InstanceCacheisSend + Sync. DuckDB's instance cache guards its own state with a mutex, so the wrapper was!Sendonly because it holds a raw pointer. -
WarningSeverityimplementsPartialOrdandOrdin the orderInfo < Low < Medium < High < Critical, soseverity >= WarningSeverity::Highselects the warnings that need attention. -
ScalarOverloadBuildergainsvolatile,varargs,varargs_logicaland, withduckdb-1-5,bind/init;AggregateOverloadBuildergainsextra_info. Each behaves as it does on the single-function builder, including who freesextra_infowhen registration fails. -
The prelude exports
ScalarBindDataandScalarLocalState(withduckdb-1-5, besideScalarBindInfo/ScalarInitInfo). Its documentation now lists every re-export, and a unit test fails when one is missing. -
validate_spdx_licenseaccepts<license> WITH <exception>(for exampleApache-2.0 WITH LLVM-exception), which it used to reject. The exception must be on the SPDX exception list, now public asvalidate::spdx::SPDX_LICENSE_EXCEPTIONS, or anAdditionRef-id. -
classify_extension_versionaccepts one leadingvon the semantic-version forms (v1.0.0): the spelling DuckDB's versioning documentation uses, and what extension-ci-tools stamps whenHEADcarries avX.Y.Ztag. -
Pitfall L12 (
LESSONS.md,book/src/reference/pitfalls.md): an aggregate'supdatereceives NULL rows under the default NULL handling too. See Fixed. -
Projects generated by
generate_scaffoldtest something. The generated project'scargo testran zero tests and itstest/sql/<name>.testheld onlyrequireand commented-out examples, so both CI steps passed whatever the extension did. The project now has a unit test and a SQLLogicTest that queries<name>_helloand checks its output. The generatedMakefilechecks thatextension-ci-toolsis checked out and names both commands:git submodule addin a new repository (where the documentedgit submodule update --initclones nothing) andupdate --initin a clone (Pitfall P4). -
The book and the README are compiled as doctests.
book/doctestis a standalone crate that turns every page underbook/src(except the changelog) andREADME.mdinto rustdoc input, so every Rust block not markedignorecompiles against the working copy, and runs unless it is markedno_run;test-bundled-prebuiltruns it. Each remainingignoreblock says why.mdbook testcannot do this: it passes no--extern quack_rs, so no block that uses the crate resolves it. The blocks that failed, and the content errors that turned up, are listed under Fixed.release.yml's gate also runs the crate's own doctests now (cargo test --doc --features duckdb-1-5-4). -
New CI jobs and checks (
ci.yml):bookbuilds the book with mdBook on every pull request, not only after a merge tomain, then runsscripts/check-book-links.py, which checks every relative link and anchor in the book and README and every docs.rs link against this checkout's rustdoc. mdBook checks neither.autoload-entriesrunsscripts/check-autoload-entries.py, which fails when a DuckDB release from v1.5.0 on autoloads an extension for a type or collation name missing from the lists behind the catalog-lookup refusal.abi-guard-layoutexercises the realLayoutMismatchpath: aduckdb-1-5build stampedC_STRUCTmust be refused by DuckDB v1.5.0 and v1.4.4 and must load into the release its bindings come from. The existingabi-guardjob only ever reached the declared-version check.dependency-floorresolvesduckdb/libduckdb-systo the declared 1.4.4 floor and runscargo test --lib, on stable: at that floor the dependency tree needs a newer rustc than the 1.86.0 MSRV.extension-loadrunsscripts/check-hello-ext.py, which executes every statement documented inexamples/hello-ext/README.mdagainst the loaded extension and compares the output withexamples/hello-ext/sql_checks.txt.semverfails when cargo-semver-checks ran 0 checks against the published baseline for a bump that is not breaking; before, 0 checks passed. For a breaking bump it runs none by design, so a new informational step compares againstorigin/mainwith--release-type patchand lists every breaking change on the branch in the log and the job summary.- Integration tests fail when a documented "N pitfalls" count differs from
LESSONS.md, when the source trees inCONTRIBUTING.mdand the book miss a file undersrc/ortests/or list one that does not exist, and when a documentation paragraph mentionspanic = "abort"without warning against it.
Changed
Fifth audit
- Breaking:
FfiState<T>stores a smallTinDuckDB's state bytes. ATof at most 256 bytes, aligned no more strictly thanusize, is kept inline; a larger one is still boxed. In 0.16.0FfiState<T>was a one-word#[repr(C)]struct with a publicinner: *mut Tfield that always boxedT; its layout is now private — a tag derived fromT'sTypeId(see Security), thenTor the box. Use its callbacks andwith_state/with_state_mutas before. See Fixed. - Breaking:
AggregateStaterequiresSync. A window's segment tree shares its states between threads ascombinesources. A state type holding aCellorRefCellmust switch to atomics or aMutex, or keep that data outside the state. - Documentation: bind-time arguments are seen before the cast to the
parameter type (
ScalarBindInfo::argument); a Safety clause ondata_chunk_from_arrow(validity bitmaps are read one byte past their rows); Known Limitations entries for aggregate statesDuckDBnever destroys, abandoned streams and out-of-memory aborts. Value::as_str,display_stringandDebugrender only types whose every payloadDuckDBcan render; everything else (VARIANT,GEOMETRY, a type quack-rs does not know, andARRAY/UNIONvalues that could hold one) getsUNRENDERABLE. See Fixed.- Breaking: scalar function bind and init callbacks take their own
argument types,
RawScalarBindInfoandRawScalarInitInfo(#[repr(transparent)]overduckdb_bind_info/duckdb_init_info), inScalarBindFn,ScalarInitFn,scalar_bind_callback!,scalar_init_callback!,ScalarBindInfo::newandScalarInitInfo::new. A table function's bind and init callbacks receive the same C types, butDuckDBcasts them to a different, larger struct, sotable_bind_callback!output registered on a scalar function (which safe code could do) wrote past the scalar bind info when it reported a panic. The two kinds no longer type-check in each other's slots (compile_faildoctests). A hand-written raw scalar callback changes its parameter type only. AggregateFunctionBuilder::ffi_state::<T>()andAggregateOverloadBuilder::ffi_state::<T>()installFfiState<T>'sstate_size,initanddestructorcallbacks together. Set one by one, a size callback for oneTwith an init callback for a larger one wrote pastDuckDB's allocation; the setters remain, and the aggregateregistermethods andRegistrarnow state that pairing as a Safety obligation (theRegistrardocs said a call on the entry point's connection was "always sound"). The README and prelude examples use the new method; the prelude's had no destructor, so it leaked every state.- Breaking:
ArrowConvertedSchema::from_rawtakes the Arrow schema the handle was built from (&ArrowSchema) instead of a column count, and is no longerconst:data_chunk_from_arrowchecks each array against that schema's shape (see Fixed). data_chunk_to_arrowrefuses a chunk holding a valueDuckDBwould export as a different value (see Fixed).- Breaking, not named until now (found by
cargo semver-checksagainst 0.16.0, run as a patch release so that it reports every break):AppenderandConnectionare no longerRefUnwindSafe(they gained aCelland aRefCellin earlier passes: the appender's row bookkeeping and the collision check's catalog snapshot), andvalidate::description_yml::DescriptionYmlis#[non_exhaustive], so it cannot be built with a struct literal. Forcatch_unwind, wrap a closure that captures anAppenderor aConnectioninAssertUnwindSafe. - Breaking: a callback macro's body must have type
()(exceptcast_callback!'s, which returns the cast'sbool). The body runs insidecatch_unwind, and its value was discarded: a body that used?compiled, and the error it returned was dropped without being reported. It is now a type error; report the error through the callback'sset_error. - Breaking: a callback macro's body is no longer an
unsafecontext. The body was a closure inside the generatedunsafe extern "C" fn, and a closure inherits its function's unsafe context, so a raw-pointer dereference or anunsafe fncall in a "safe" callback compiled with nounsafekeyword and, on edition 2021 (which the scaffold generates), no warning. The body is now expanded as a nested ordinaryfn; wrap unsafe operations inunsafeblocks, as the documented examples already do.compile_faildoctests pin it. - Breaking: catalog lookups are refused in a catalog
DuckDBdoes not implement itself. For a catalog a storage extension attaches,duckdb_catalog_get_entrystarts that extension's transaction and runs its schema lookup with notry, so an exception there would abort the process.CatalogEntry::lookupandCatalog::get_entrynow return an error for any catalog whose type is not"duckdb".DuckDB's own catalogs (the database's,tempandsystem) all have that type and are unaffected. Look up entries in another catalog with SQL instead, as the error says. CombineFndocuments thatcombinemust leave its source states unchanged (see Fixed), and no longer advises moving out of them.Catalog::type_nameno longer gives"system"as an example type: thesystemandtempcatalogs are of type"duckdb".data_chunk_from_arrow's Safety section requires a fixed-width dictionary's values buffer to be readable one element past its length when the indices can be NULL:DuckDBpoints NULL indices at a sentinel entry there, and the flattening copy reads it (found with an AddressSanitizer-builtlibduckdb; item 29). Its Errors section now lists the layout refusals.- Documentation corrections from a mechanical check of the docs' universal
and numeric claims: the Arrow conversion functions are already in
DuckDB1.4.4's C API (not added in 1.5.0); the unstable API region did not change "in every recent release" (1.4.4 and 1.4.5, 1.5.0 and 1.5.1, and 1.5.2 to 1.5.5 are byte-identical); it was not changed by middle insertions "in four of the last four" versions (two). LESSONS.mdand the book's pitfall catalogue gained L15 (combinemust leave its source states unchanged), L16 (a valid Arrow array is not always oneDuckDBimports correctly), L17 (aCOPY … FROMreader must not declare result columns) and L18 (aLISTreserve moves every buffer below its child): 30 documented pitfalls. The README's and the book's summary tables, which stopped at L14, list all 30.DestroyFn,FfiStateand the book's known limitations document thatDuckDBnever destroys one aggregate state per row of a window frame withEXCLUDE(item 35: 5000 of a 5000-row window, every release from 1.4.4).DestroyFn's docs said it was called for every stateDuckDBcreated.query::prepareand the book's known limitations document that an allocation failure insideduckdb_preparecan hand back a statementDuckDBhas already freed, which the error path then reads and frees (item 36: the last 3 of its allocations, every release from 1.4.4, found by failing each allocation in turn).ClientContext::config_optiondocuments thatDuckDB's function has notry, and why no built-in setting's getter throws through it in practice.FfiInitData::setandFfiLocalInitData::setname the callback each must be called from: both store throughduckdb_init_set_init_data, so the wrong one sets the other kind of init data, which the othergetthen reads as the wrong type.FfiBindData::setsays a typed table function's bind sets the bind data itself.ReplacementScanBuilder::registersaysDuckDBcallsdelete_callbackeven whenextra_datais null, unlike its other destructor slots (a new test observes the call).data_chunk_from_arrowsays which column holds its claim on the Arrow array:DuckDBgivesreleaseto column 0 alone, so a vector made to reference another column (reference_vector) does not keep the producer's buffers alive (a new test observes both cases).StructWriter'swrite_*,set_nullandset_validstate in their Safety sections that no field writer was replaced throughfield_mut: swapping two writers is safe code, and the next write would go to the wrong child vector.OwnedConnection'sSendjustification saidDuckDBforbids concurrent use of one connection; it serialises it. The comment now gives the real reasons, and a test queries and drops a connection on another thread.docs/upstream-duckdb-reports.mdgained items 20 to 37, and item 16 gained aHUGEINTreproducer.- Test gaps the mutation sweeps exposed. The full sweep left 22 mutants
alive, and the end-to-end run over the files
mutants.tomlexcludes left more. Each is now killed by a test, excluded with the reason inmutants.toml, or recorded inAUDIT.mdsection 10 as equivalent with the argument. The new tests coverFfiState's tag structure, a release profile withoutpanic = "unwind", an overload's own return type, every typedValuegetter against a value of its own type, theTIMETZandTIME_NSrange guards, theAppendermethods a no-op replacement survived (another schema,column_type,clear_columns,append_default_to_chunk),StructWriter's child vectors,InMemoryDb::execute's row count,QueryResult::result_kindfor a statement that returns nothing, a createdErrorData,MockVectorWriter::len, andappend_metadata's handling of=in a path, a lone-, signed version numbers, commit hashes of the wrong length, case or alphabet, and a footer whose magic field is wrong.tests/handle_leaks.rskills theDropmutants that survived every functional test. Two comparisons moved into smallconst fns so that a unit test can reach their boundary: the aggregate-state salt and the appender'su32::MAX-inclusive length limit. - The ABI layout table covers
DuckDBv1.5.6, which shares the 546-slot layout of v1.5.2 – v1.5.5. Without it aduckdb-1-5extension was refused by v1.5.6 asUnknownEngineVersionunder the defaultAbiPolicy::Strict. v1.5.6 declares every slot stable for extensions targeting C API v1.5.6; quack-rs still targets v1.2.0, which v1.5.6 loads. scripts/check-abi-table.pyreads the v1.5.6 header correctly: it evaluates the newDUCKDB_API_VERSION_AT_LEAST(...)bands, no longer folds a preprocessor continuation line into the first declaration's fingerprint, and checks that the firstSTABLE_API_SLOT_COUNTdeclarations are identical in every release (the v1.4.0varint→bignumrename aside) instead of requiring an unchanged stable count, which v1.5.6 raised from 357 to 404.- CI runs what the release workflow runs before a tag is pushed. The first
v0.18.0tag failed on Windows whilemainwas green, because no PR job ran theduckdb-1-5*tests on macOS or Windows:test-bundlednow also runscargo test --all-targets --all-featureson all three, and on Linuxcargo doc --all-features, the only rustdoc pass over the test-only items. Thetestjob runs the unit tests with Cargo's default release codegen (16 codegen units, no fat LTO), the build that exposed theFfiStatesalt defect (see Fixed); every other build is debug or has one codegen unit and fat LTO. The release workflow's test steps andtest-bundled's pass--no-fail-fast, so one failing test binary no longer hides failures in the ones after it. release.yml:gh release create --verify-tag, so a missing tag fails instead of being created at the default branch's HEAD; a re-run ofpublishrecognises current cargo's "already exists on crates.io index" (it matched only the older "already uploaded"); every job has atimeout-minutes.- The unit tests run on wasm32, the one 32-bit target quack-rs supports: the
wasmjob installs emsdk 6.0.10 and runscargo test --libunder node, with and withoutduckdb-1-5-4. Before, it only compiled for wasm32, so the 32-bit size limits were only ever tested with 64-bit values. Two tests assumed a 64-bit target and were corrected.criterionis now a non-wasm dev-dependency (its rayon dependency does not build for wasm32). tests/file_handle_close.rstestsFileHandle'sDropwhen the close fails, by swapping stubs into the C API dispatch table; it fails on the oldDrop, which destroyed without closing first.tests/ffi_roundtrip/file_errors.rsruns on every platform: a failed write through a read-only handle, and a seek pasti64::MAX, which is asserted to be refused withInvalidInput. Only the/dev/fullsync failure, which has no portable trigger, stays Linux-only. The over-4 GiB string test runs on macOS as well as Linux..gitattributes: text files are LF in every checkout, so Windows CI tests the same bytes as Linux; fuzz seeds and images are binary.- The book's aggregate examples and
hello-extregister state withffi_state::<T>()instead of wiringFfiState's three callbacks by hand. - The book was reviewed page by page against the source. Corrected, among
others: pages that said every extension binary is tied to one DuckDB
release and should pin
libduckdb-sysexactly (true only for builds that use the unstable API), an out-of-boundsChunkWriterexample, the instance cache's lifetime (it holds weak references),AbiPolicy::Warn(it prints to stderr), and links that rendered as literal brackets. - The site's custom
<head>was never rendered (book.tomldid not point mdBook atbook/theme), so every page shipped an empty description and no canonical or Open Graph tags.scripts/seo-postbuild.py, run bydocs.ymland by CI'sbookjob, now writes a canonical URL and a specific description to each page and fails on a missing or duplicate one; the preview image is a PNG; headings are no longer rendered at half opacity; diagrams follow the theme; mermaid is pinned to 11.17.2. - The crate, README, book, logo and social-preview image no longer call
quack-rs "production-grade" or the logo "FAST"; neither was backed by
evidence, and the audits behind this release found serious defects. The
crates.io description is now "Rust SDK for building DuckDB loadable
extensions on the DuckDB C Extension API". A new README Status section
and the FAQ state what the record shows instead: pre-1.0 API churn, the
defects each audit found (
AUDIT.md), DuckDB's own limits, and what CI checks.
Fourth audit
- CI's "the refused extension registered nothing" checks could not fail
(
duckdb -cstops at the failedLOAD); they feed the statements on stdin and require a marker row. AbiPolicyhandling is a purepolicy_verdict, tested against every check result. The docs ofenforce_abi_policyno longer sayWarngoes throughset_error.- Arrow export documents the INTERVAL and UHUGEINT values
DuckDBcorrupts. docs/upstream-duckdb-reports.mdgained items 7 to 19.
Earlier passes (0.17.0, second and third audits)
-
Breaking:
CopyFunctionBuilderis no longerSendorSync. SupportingCOPY … FROMgave it two new fields that each carry a raw pointer —copy_from: Option<TableFunctionHandle>(aduckdb_table_function) andextra_info: Option<ExtraInfo>(a*mut c_void) — and a raw pointer is neitherSendnorSync.cargo-semver-checksclassifies this asauto_trait_impl_removed, a major break, one of the reasons this release bumps the minor version: for a pre-1.0 crate Cargo treats the leftmost non-zero component as the major, so the minor position is where a break goes (RELEASING.md, "Semantic versioning policy").The change aligns the type with its three siblings rather than making it an outlier —
ScalarFunctionBuilder,TableFunctionBuilderandAggregateFunctionBuilderwere already!Send + !Syncin 0.16.0, each because it holds a rawDuckDBhandle. A builder is a short-lived object constructed and registered insideduckdb_init_c_api, and noDuckDBC API accepts one from another thread, so the traits were never usable for anything real. Code that moved aCopyFunctionBuilderbetween threads must now construct it on the thread that registers it. -
Flaky tests: a shared counter raced across parallel tests. The
extra_infotests and the newarrowones each reset onestatic AtomicUsizeand then asserted on it, butcargo testruns tests in parallel — so one test's reset could land between another's drop and its assertion.dropping_an_untransferred_extra_info_frees_itfailed in CI while the identical job on the identical commit passed. Each test now owns its counter:extra_infocarries it in the allocation, andarrowcarries it in the record's ownprivate_data, which is what that field is for. Verified with 100 repeat runs, zero failures. -
128-bit splitting and reassembly in
ValueandPreparedStatementwas open-coded.Value::as_i128/as_u128/as_uuid/as_decimal/uuidandPreparedStatement::bind_i128/bind_u128/bind_decimaleach did their own<< 64/>> 64word arithmetic, where being silently wrong is easy — a shift in the wrong direction still compiles, still round-trips zero, and still round-trips anything that fits in 64 bits. Those call sites now route through fourpub(crate)helpers (hugeint_from_i128/hugeint_to_i128/uhugeint_from_u128/uhugeint_to_u128) that live next to each other and are unit-tested in both directions, at the extremes and against hand-built records. Mutation testing confirms all seven shift mutants across the three modules are now killed. The appender,datetimeand the vector reader and writer still split and reassemble 128-bit values themselves. -
Test gaps the mutation sweep exposed, once it could see the files. Chief among them the shift direction in
hugeint_from_i128/uhugeint_from_u128: swapping>>for<<still compiles, still round-trips zero and still round-trips anything that fits in 64 bits, so nothing in the suite noticed. AlsoTypeId::composite_constructor_hint's per-variant arms,LogicalType::check_slot's rejection path,composite_message,LogicalTypeError::api_func, thearrowaccessors against a populated record rather than only an empty one, andmap2_str's NULL propagation when just one argument is NULL.With the three gate defects fixed and the two FFI-wrapper modules excluded, the incremental sweep over this branch's 40 changed source files reports 404 mutants — 250 caught, 154 unviable, none missed. What survives after that is annotated in the source with
#[mutants::skip]and a reason, rather than filtered out of sight: the bare FFI reads (DataChunk::size/column_count,ScalarBindData::set,ScalarLocalState::set), twoDropimpls whose effect is only visible in freed memory or to a leak checker (OwnedVector,SecretEntry), the deprecatedFfiBindData::get_from_bindwhose mutant is the function (it returnsNoneunconditionally, becauseDuckDBhas noduckdb_bind_get_bind_data),map2/map2_strwhose per-row NULL check only runs insideDuckDB's expression executor, andValue::as_str_or_default, whose null-handle answer is exactly the mutant'sString::new().Two of those turned into real work rather than an annotation.
SecretEntry's zeroize-on-drop is a security property the crate advertises and nothing asserted — its body is nowSecretEntry::zeroize_in_place, tested directly, withDropleft as a one-line delegation. Andhugeint_to_i128combined its halves with|; because the halves occupy disjoint bits,|→^cannot change the result for any input, so that mutant was unkillable by construction. The halves are added instead: identical here, incapable of overflowing (i64::MIN << 64is exactlyi128::MIN, and the round trips at the extremes would panic in a debug build if that were wrong), and-or*in its place dies at once. -
The mutation-testing gate always reported 100% and always passed.
cargo mutants --output DIRwrites its results toDIR/mutants.out/, so--output mutants.output them inmutants.out/mutants.out/. The report step countedmutants.out/caught.txtand friends, found nothing, computedSCORE=100%fromTOTAL=0, and skipped itsexit 1becauseMISSEDwas also 0. The run on this PR printedMUTATION SCORE: 100%and passed while cargo-mutants' own summary line in the same log read229 missed, 238 caught, 218 unviable. Both jobs now pass--output .. -
Incremental mutation testing skipped every top-level
src/*.rs. The job selected changed files with the pathspecsrc/**/*.rs, but git's default wildmatch lets*cross/, so that pattern requires at least one directory component aftersrc/and matches no top-level file at all.src/value.rs,src/query.rs,src/appender.rsand every other module directly undersrc/were silently excluded while the job reported success. It now filters to.rsin the shell. -
The mutation gate's Display/Debug filter matched nothing.
mutants.tomlcarriedexclude_re = ["^fmt::"], commented "Display/Debug impls". cargo-mutants matches that regex against the whole line it prints for a mutant —src/x.rs:1: replace <impl core::fmt::Debug for T>::fmt -> … with …— which begins with the file path, so a pattern anchored atfmt::can never match and seventeen unkillableDebug::fmt -> Ok(Default::default())mutants survived every sweep. cargo-mutants' own documented form,impl Debug, does not match this crate either: it writesimpl core::fmt::Debug for T, and the qualified path lands in the mutant name. The pattern is nowimpl [a-z:]*Debug for.Displayis deliberately not excluded — those impls render error text that tests assert on, so their mutants die and belong in the gate.The incremental job now also re-applies
exclude_refrommutants.tomlas CLI flags, the way it already did forexclude_globs: cargo-mutants only combines a CLI--exclude-rewith the config file from 27.0.0 onwards, and that job passes one. -
src/value.rsandsrc/query.rsare excluded from mutation testing, and their pure logic moved out so that it is not. Every function left in those two files wraps aDuckDBC call, which the gate's--cargo-arg=--librun — no live engine — structurally cannot reach; that is the same rationalemutants.tomlalready carried for nine sibling modules, and between them the two files accounted for 206 of the 220 survivors. Excluding them wholesale would have swallowed the pure code too, so it moved to siblings that stay in the gate:src/value/hugeint.rs(the four 128-bit word helpers),src/value/defaults.rs(the fourteenas_*_oraccessors, as a second inherentimpl Value) andsrc/query/cstr.rs(to_c_sql,c_str_to_owned). Seven of theas_*_oraccessors had no unit test at all, and nothing exercisedc_str_to_owned's non-null path — all of them were among the 220 — so nine new tests turn those survivors into kills rather than hiding them. No public item moved: the re-export keeps every existing path. -
Four CI jobs never actually ran.
rust-toolchain.tomlpinschannel = "stable", and a rustup toolchain file overrides the defaultdtolnay/rust-toolchainsets — so themiri,leak-check,fuzzandnightlyjobs all resolved a barecargoto stable. Miri and LeakSanitizer failed loudly (the 'miri' component ... is not available for the 'stable-...' toolchain;the -Z flag is only accepted on the nightly channel); the informational nightly job failed silently, re-testing stable. All four now invokecargo +nightlyexplicitly, with a comment saying why.The
test-bundled-prebuiltjob's clippy step was also missing theenv:block its sibling test step has, sobuild.rspanicked looking forduckdb.hppbefore clippy ran. -
cargo docwith-D warningsfailed. Six intra-doc links were broken or redundant —QueryResult::column_logical_type,scalar::typed,vector::ops(twice),TableFunctionBuilder::build_handle— andvector::opscarried a doc comment on both thepub moddeclaration and the file's own//!header, so its module docs were resolved in the parent's scope and every link into the module failed with no source location. Three more links inappenderandtable_descriptionpointed atduckdb-1-5-gated methods and so broke the default-feature doc build.cargo docis now clean under-D warningson all four feature sets. -
Pitfall L9 —
duckdb_data_chunk_from_arrowtakes the array even when it fails.duckdb.hreads like a success-path statement;arrow-c.cppnullsarrow_array->releaseinside the per-column loop, before the work that can throw. Guessing either way gives you a bug — double release, or a leaked Arrow buffer tree when a zero-column schema means the loop never runs. Documented inLESSONS.mdand the book, and made impossible by the by-value signature.The pitfall count in the README, the crate docs and the FAQ was stale at 17, and the book's catalogue was missing L8 and L9. All four now agree with
LESSONS.md, and an integration test fails when a documented count differs from it (see Added). -
A copy function may now implement
COPY … FROMalone.CopyFunctionBuilder::registerused to requirebind,sinkandfinalize, which made a read-only format impossible even thoughduckdb_register_copy_functionaccepts one: it decides what a copy function supports frominfo.sink != nullptrandcopy_from_bind != nullptrindependently. Leaving all three unset is now valid whencopy_fromis set; setting only some of them is an error that says so. ExistingCOPY … TOfunctions are unaffected. -
CI gains four jobs:
miri(546 unit tests under the interpreter),leak-check(LeakSanitizer over the end-to-end suite against a reallibduckdb, now leak-clean),fuzz(cargo-fuzzover the description.yml parser, theduckdb_string_tdecoder and the validators) andsemver(cargo-semver-checks).tests/ffi_roundtrip.rsis now linted — it is feature-gated, so the plain clippy job had been compiling it away to nothing. -
extension-loadis now a matrix overDuckDBv1.4.4, v1.5.0, v1.5.5 andlatest, rather than onereleases/latestrun that silently retargeted wheneverDuckDBshipped. That is the README's compatibility claim, proven rather than sampled. -
New end-to-end coverage for nested types as scalar-function input —
LISToffsets that are cumulative rather than uniform, NULL elements inside a list, aMAPkey miss, and anARRAY's fixed stride across rows. Writing them was already covered; reading them does raw offset arithmetic against a layout onlyDuckDBdefines, which a mock cannot check. -
AUDIT.mdrecords the full review: what was read, what was probed, what was verified correct, and what is still open. -
CI: the AddressSanitizer job is now blocking, as planned when it was added. It passed on
mainand over the merged 125-test end-to-end suite with no reports and no suppressions. -
CI: the informational beta clippy job now also lints the end-to-end tests, with
bundled-test-prebuilt,duckdb-1-5-4against a pre-built libduckdb. Those tests compile only withbundled-test-prebuilt, so beta's newassert_is_emptylint fired on four of them while the job stayed green. -
Mutation testing: the configuration moved to
.cargo/mutants.toml. At the repository root cargo-mutants never read it — so the full sweep ran without its exclusions or features (2,164 mutants listed instead of 1,348) — and it carried two keys cargo-mutants 27.1.0 rejects (cap_timeout,jobs). Itsexamine_globsis gone too: once the file was read, that key overrode the incremental job's--fileflags instead of being narrowed by them. -
Breaking:
TableFunctionBuilder::with_staterequiresS: Clone + Send: the statebindreturns is a template and every execution scans a fresh clone. Use the newwith_bind_initfor state that cannot be cloned. -
Breaking:
TypedTableFunctionBuilder::projection_pushdownis removed — the typed scan closure cannot learn the projection, so enabling it returned the wrong columns. Use the rawTableFunctionBuilderfor pushdown. -
Breaking:
FfiBindData::set/FfiInitData::setrequireT: Send + Sync,FfiLocalInitData::setT: Send;ReplacementScanBuilder::register_with_dataandConnection::register_replacement_scan_with_datarequireT: Send + Sync. -
Breaking: every scalar
Valuegetter returnsOption<T>(as_i8…as_u128,as_f32,as_f64,as_bool, the date/time/timestamp family,as_interval,as_uuid,as_decimal; the newas_enum_indexdoes too):Nonefor a null handle, SQLNULL, a non-scalar value or a failed cast. A failed cast used to return a sentinel (T::MIN,NaN) indistinguishable from a real value. Theas_*_or(default)forms keep their signatures and now cover all four cases. -
Breaking:
Value::as_blobaccepts only aBLOB(DuckDB's cast of anything else toBLOBcould throw) and errors on SQLNULL;Value::as_strerrors on SQLNULL. -
Breaking:
FileSystem<'ctx>borrows itsClientContext. Code that stores aFileSystemneeds a lifetime parameter, and theClientContextmust outlive it. -
Breaking:
DuckDbErrorTypegainsAutoload,SequenceandInvalidConfiguration(40–42), which used to map toInvalid. -
Breaking:
SelectionVector::newreturnsResult<Self, ExtensionError>. -
Breaking:
datetime::date_to_days,time_from_micros,time_tz_from_bits,timestamp_from_micros,timestamp_to_micros,time_tz_bitsanddecimal_to_f64returnOption. -
Breaking:
VectorWriter::set_null/set_null_range(and soDataChunk::propagate_nulls) on aSTRUCTorARRAYvector also null the row's fields / elements, recursively, as DuckDB'sFlatVector::SetNulldoes. To reuse such a row, callset_validon it first, which restores whatset_nullnulled below it, then write any field or element NULLs. -
Breaking:
SqlMacro::to_sqlemits double-quoted identifiers (CREATE OR REPLACE MACRO "add"("a", "b") AS (a + b)). Calling the macro is unchanged: DuckDB resolves quoted identifiers case-insensitively. -
ScalarFunctionBuilder::varargs,varargs_logicalandvolatileno longer requireduckdb-1-5: both C functions are in the stable v1.2.0 API. -
Appenderis re-exported from the prelude without a feature, matching the module, anduse quack_rs::prelude::*now bringsentry_point!/entry_point_v2!into scope, as the prelude's own documentation said. -
AggregateFunctionSetBuilder::overloadsis unchanged, but the builder its closure receives is now namedAggregateOverloadBuilder, for symmetry withScalarOverloadBuilder. The old name remains as a deprecated type alias (quack_rs::aggregate::builder::OverloadBuilder) and still compiles. -
AggregateOverloadBuilderis exported fromquack_rs::aggregateand from the prelude. The oldOverloadBuilderwas reachable only atquack_rs::aggregate::builder::, and had no public constructor, so a caller could not build one outside anoverloadsclosure. -
AggregateOverloadBuildermoved out ofset.rstosrc/aggregate/builder/overload.rs. -
Breaking:
QueryResult::next_chunkreturnsResult<Option<OwnedDataChunk>, ExtensionError>.duckdb_fetch_chunkreturns null both at the end of the rows and when the fetch fails, andnext_chunkreturnedNonefor both, so a streaming result that failed part way — a runtime error in the query, or another statement run on the same connection — read as a complete, shorter result. The error DuckDB recorded is now returned asErr, and keeps being returned on later calls; a clean end staysOk(None). -
Breaking:
Value::time_nsandValue::timestampreturnResult<Value, ExtensionError>and refuse a payload outside the range DuckDB's own SQL produces; so do the newValue::time,time_tz,timestamp_tz,timestamp_s,timestamp_msandtimestamp_ns(see Added). DuckDB stores any 64-bit payload unchecked, and rendering an out-of-range one crashed or aborted the process (Value::time_ns(i64::MAX),Value::timestamp(i64::MIN)) or printed garbage.Value::dateand the newValue::intervalare infallible: DuckDB renders every value of those types. -
Breaking:
CatalogEntry::lookupandCatalog::get_entryreturnResult<Option<_>, ExtensionError>:Ok(None)is "not found",Erris "refused". ATypeorCollationlookup of a name that makes DuckDB autoload an extension (inet,json, an ICU collation name) is refused without calling DuckDB while theautoload_known_extensionssetting is on: a failed autoload insideduckdb_catalog_get_entryaborted the process, and a successful one loaded an extension as a side effect of a lookup. The entry types that were already refused (Schema,Database,PreparedStatement,Invalid) are now anErrtoo, instead of aNonethat looked like "not found". -
Breaking:
FileSystem::openreturnsFileHandle<'_>, which borrows theFileSystem. AFileHandlekept after its database was closed read freed memory (valgrind: an invalid read induckdb::FileHandle::Read).FileHandle::from_rawreturns a handle whose lifetime the caller chooses. -
Breaking:
InitInfo::projected_column_indexreturnsOption<usize>,Nonepast the end of the projection, where DuckDB returns 0 — a real column index.CopyBindInfo::column_typereturnsOption<LogicalType>, bounds-checked likeBindInfo::result_column_type; it used to wrap the null handle DuckDB returns for an out-of-range index. -
Breaking:
TypedTableFunctionBuilder::buildreturns an error whenprojection_pushdown(true)was set on theTableFunctionBuilderbeforewith_state/with_bind_init. That sequence bypassed the typed builder's no-pushdown rule, and the scan returned the wrong columns (SELECT bgot columna's values). -
Breaking:
ConfigOptionBuilder::registerrefuses an option with no default value (DuckDB then reports it as an unrecognized configuration parameter), an option of a pseudo-type (ANY,SQLNULL,INTEGER_LITERAL,STRING_LITERAL) or of a type the typedValueconstructors cannot build (BIGNUM,GEOMETRY,VARIANT, …), and a name thatduckdb_settings()already lists, compared case-insensitively and including aliases: an option namedthreadsused to register and shadow the built-in setting. The default is now handed to DuckDB already typed (see Security). -
Breaking:
TableFunctionBuilder::registerrefuses a name that already belongs to a table function or table macro, andCopyFunctionBuilder::registera format name that already exists (csv,parquet, …). DuckDB dropped such a registration while reporting success, so the function was never called. The C API cannot overload table functions. -
Breaking:
ScalarFunctionBuilder::registerandScalarFunctionSetBuilder::register(and somap1,map2and the other typed constructors) refuse a parameter signature thatduckdb_functions()already lists under that name, built-ins included. DuckDB merges a scalar registration into the existing entry with override on, so the new overload silently replaced the old one for every query in the database — after registeringabs(BIGINT),abs(-5::BIGINT)returned 995 — or, with a different return type, made every call ambiguous. Signatures containingSTRUCT,UNION,ENUMor a literal pseudo-type are registered unchecked, as documented on both builders. -
Breaking:
SqlMacro::registerrefuses a body that holds more than one statement, counted with DuckDB's own parser, and executes nothing. The body1); DROP TABLE t; SELECT (1used to register and drop the table. A scalar body that ends in a--comment, which used to comment out the closing parenthesis, now works. -
Breaking:
Appenderno longer loses rows silently. DuckDB's appender cannot take back a value, so arow()closure that failed after appending part of a row left the row half-written, andclose()then returnedOkand wrote none of the buffered rows. Such arow()now poisons the appender: every laterappend_*,row,end_row,flushandclosereturns an error saying how many buffered rows were not written.close()with a row started but not ended is an error; every mutating method is an error after a successfulclose()(DuckDB accepted appends after close and wrote them at the next flush);append_chunkin the middle of a row is refused. Withduckdb-1-5,clear()resets DuckDB's state and clears the poison. A value rejected first in its row loses nothing and does not poison. -
Breaking: the
LogicalTypeSTRUCT and UNION constructors (struct_type,struct_type_from_logical,union_type,union_type_from_logicaland theirtry_forms) apply the rules DuckDB's binder applies to the same types in SQL: names unique ignoring ASCII case (empty names exempt) and at mostMAX_UNION_MEMBERSunion members. The C API checks nothing, so a scalar returning such a type registered and then failed every call with "duplicate name in struct". Thetry_forms return an error; the others panic. -
Breaking:
VectorWriter::write_varchar/write_blobandStructWriter::write_varchar/write_blobpanic for a value longer thanMAX_STRING_LENinstead of storing a truncated one (see Fixed). Insidescalar_callback!and the typed scalar constructors the panic becomes a SQL error;try_write_varchar/try_write_blobreturn it instead. -
Breaking:
MockVectorWriterbehaves like a real output vector, so a test that passed for a callback that is wrong in DuckDB now fails.set_nullfollowed by awrite_*leaves the row NULL (set_validundoes a NULL); a row never written is valid, not NULL (is_writtentells a test whether the loop wrote it); a write past capacity panics instead of growing the mock; a string overMAX_STRING_LENpanics, asVectorWriternow does. The docs no longer claim one function can be called with both the mock and the real writer: the types differ. -
Breaking:
generate_scaffoldvalidates the free text it writes into the generated files:descriptionandmaintainermust be non-empty, must not start or end with whitespace and must not contain control or bidirectional characters (maintainermust also be one line),github_repomust beowner/repo, andgit_refa commit hash or tag. Those fields went intodescription.ymlunquoted, soFast: analytics,Analytics #1or a newline produced a file that parsed to something else or not at all; they are now YAML double-quoted scalars. Each line of a multi-line description gets its own//!prefix; the second and later lines used to fall outside the doc comment and fail to compile. -
Breaking:
validate_spdx_licenserejects nesting deeper than 64 parentheses. It recursed once per(with no limit, so a license field of a million(overflowed the stack and aborted the process, reachable fromparse_description_ymlon untrusted input. -
TypeId::TimeNs,Any,Varint,SqlNull,IntegerLiteralandStringLiteralno longer requireduckdb-1-5. All six exist in DuckDB 1.4.4, this crate's floor, and 1.4.4 producesTIME_NSandBIGNUMcolumns, so with default featuresLogicalType::get_type_idpanicked on those columns. -
Breaking:
TypeId::Varint.sql_name()returns"BIGNUM", DuckDB's name for the type since 1.4, instead of"VARINT". Both names parse as SQL, but code that compares the returned string sees a different value. -
SecretEntry'sDebugoutput shows[REDACTED]for a non-empty scope: the scope (a bucket or URL prefix) is zeroized on drop as sensitive, butDebugprinted it. -
InMemoryDbchecks, before first use, that thebundled-test-prebuiltC++ shim was compiled against headers whoseduckdb_ext_api_v1has the size thelibduckdb-sysbindings expect, and panics naming both slot counts,DUCKDB_LIB_DIRandcargo tree -i libduckdb-sysif not. With a DuckDB 1.5.0 library under 1.10505 bindings, a slot the older headers lack was left as stack garbage and the tests ran on; larger headers would overrun the buffer. -
scripts/check-abi-table.pylists every upstreamvX.Y.Ztag from v1.2.0 on instead of a hard-coded list, which is how v1.4.5 went missing from the layout table (see Security). -
RELEASING.md: a release is done only once the tag is onorigin, crates.io's newest version is theCargo.tomlversion and docs.rs has built it, each with a command to check; version snippets are bumped in the release pull request itself. -
Internal layout, with no change to any public path:
src/value.rsis split intovalue/composite.rs,value/nested.rs,value/scalars.rs,value/temporal.rsandvalue/temporal_checks.rs; theLogicalTypeconstructors moved totypes/logical_type/construct.rs; andScalarOverloadBuildermoved toscalar/builder/overload.rs.src/query.rs,src/appender.rs,src/arrow.rsandsrc/testing/mock_vector.rsare split the same way into private submodules. -
Every
unsafeblock in library code now states, in a// SAFETY:comment, the invariant it relies on and why it holds; 149 did not.clippy::undocumented_unsafe_blocksis enabled so CI keeps it that way (test code is exempt). -
docs/architecture.mdmatches the crate again: its module table listed neitherabi,arrow,callback,chunk_writer,datetime,query,secrets,tlsnorwarning, and saidappender,table_description,ScalarFunctionBuilder::varargsandvolatileneedduckdb-1-5(they do not). An integration test now fails when the table andsrc/lib.rsdisagree.
Fixed
Fifth audit
- On a 32-bit target the Arrow layout walk's refusal of an oversized row
count named
vector::ops::MAX_CAPACITY(2^28 - 1) as the limit while refusing exactly that many rows: a list child's reserve rounds up to a power of two, so the limit is 2^27. The message now states the effective limit. Found by the first run of the unit tests on wasm32. - Rendering a value aborted the process for values ordinary SQL builds:
a
VARIANTholding an out-of-range timestamp, aDECIMAL(38, 0)holdingi128::MIN(fromsumover two in-range values), and aGEOMETRYbuilt from malformed WKB each madeDuckDB's cast to text throw through the C API; aDECIMAL(38, 38)holding 1.2 rendered with an unwritten first byte, and aborted too when that byte was not valid UTF-8. The render guard is now an allow-list, andDECIMALpayloads are checked against their width. ListBuilderaborted the process on a large list of a wide type.DuckDB's ceiling is 2^37 bytes per child buffer, not elements, checked after it rounds the reservation up to a power of two, so aBIGINTlist of 2^34 + 1 elements, or anINTEGER[1000]list of 2^25 + 1, reached a reserve that throws through the C API. The builder respects the rounded byte ceiling; a row past it is NULL.- Aggregate states
DuckDBmoved were never dropped (unreleased fourth-audit code; 0.16.0 had no tag). That pass'sFfiStatetag was derived from the slot's address, and radix repartitioning copies states to new rows, so every moved state was skipped and itsTleaked (8 of them after one ungrouped query on eight threads in the regression test). The tag follows the slot's contents. FfiState's type tag was salted with the address ofT's type-name string (unreleased fifth-audit code), which is not unique: with more than one codegen unit and no fat LTO (Cargo's default release profile) the init, access and destroy callbacks could see different addresses, sowith_statefound no state anddestroyskipped every one: in such a build everyFfiStateaggregate returned NULL or garbage (10 of the 279 end-to-end tests fail that way). The salt is now a hash ofTypeId::of::<T>().- Aggregate states
DuckDBnever destroys leaked a box each. A grouped aggregate's states that a stopped scan never reached (aLIMITabove it, an error, an interrupt) are never destroyed byDuckDB1.4.4 to 1.5.5; underLIMIT 10over 300,000 groups, 297,952 boxedTs leaked. A smallTis now stored inDuckDB's own state bytes, so it leaks nothing unless it owns heap memory itself. ListBuilder::with_element_limitcalled after the first row could raise the limit pastDuckDB's ceiling for the child type, which the builder applies once, at the first row; a later row past the ceiling then reachedduckdb_list_vector_reserveand aborted the process (aBIGINTrow of 2^34 + 1 intests/ffi_roundtrip/list_limits.rs). The ceiling is kept apart and always applies. On a 32-bit target a row of more than 2^31 elements under a larger limit made the reservation 0 (next_power_of_twooverflowing) while the closure still wrote the row, past the child; the reservation is now the limit there.TableDescription::column_name,column_typeandcolumn_has_defaultaborted the process for indexu64::MAXonDuckDB1.5.0 to 1.5.5: the C API converts the index to anoptional_idx, whose constructor throws for that value outside anytry(upstream item 30). They returnNonefor it without callingDuckDB, as for any other index past the last column.data_chunk_to_arrowexported different values without an error. AnINTERVALof more thani64::MAX / 1000microseconds wrapped whenDuckDBconverted it to nanoseconds; aUHUGEINTof 2^127 or more came out negative; aHUGEINTorUHUGEINTof 39 digits was exported as adecimal128(38, 0)it does not fit. Each is now refused, at any nesting depth; aHUGEINTis accepted whenarrow_lossless_conversionexports it as a 16-byte binary.data_chunk_to_arrowcould return an array that contradicts its schema. Before 1.5.5,BIGNUM(and from 1.5.0GEOMETRY) exported underarrow_output_version = '1.4'are written as binary views while the schema declares plain binary; a consumer reads the views as offsets, and appendingDuckDB's own re-import of the batch crashed on 1.4.4 to 1.5.4 (item 34). The export is now checked against the declared schema and refused on a mismatch.data_chunk_from_arrowimported valid Arrow arrays from the wrong rows, or read and wrote out of bounds.DuckDBmishandles offsets below the top level: a struct inside an offset struct or list, a union's members, a run-end-encoded array's value validity, and a dictionary's validity under a list. It also mishandles overlapping or gapped list views, dictionaries whose values are dictionary-encoded, sparse unions whose type codes are not0, 1, …, and a run-end-encoded array where it reads a plain one. A dictionary-encoded array of more than 2048 rows under a struct with NULL rows had its validity copied past a 2048-row heap mask; the regression test aborted with glibc'scorrupted size vs. prev_size. Each layout is refused, naming the node, and the neighbouring layoutsDuckDBdoes import correctly are still accepted (tests/ffi_roundtrip/arrow_layout.rs).data_chunk_from_arrowaccepted three more layoutsDuckDBimports wrongly, found by the fifth audit's review of the new layout walker: a fixed-size list with NULLs whose child is aSTRUCTwith a dictionary field of more than 2048 rows (the list's NULLs are broadcast into the struct and reach the 2048-row mask of item 25; valgrind reports 156 invalid accesses inSetInvalidpast the 256-byte mask on 1.5.5); a dictionary withnull_count = -1, whose NULL rows came back as values (item 31); and a sparse union with a nonzeronull_count, whose type ids were read as validity, so every row came back NULL (item 32). Each is refused, as is such a layout below a nodeDuckDBconverts as zero rows (the walk used to stop there, thoughDuckDBstill expands a run-end-encoded descendant; valgrind showed its values' validity read 11 bytes past a 1-byte bitmap on 1.5.5, item 24). So is ageoarrow.wkbcolumn read as more than 2048 rows:DuckDB1.5 copies its storage into a 2048-row vector before the cast toGEOMETRY(SIGSEGV on 4096 rows, item 33).- A null-typed field below the top level of an imported Arrow column read
as valid after its first row:
DuckDBimports it as a constant vector, which the readers index as flat. The column is now flattened whenever its type holdsNULLat any depth, not only at the top. - The
CombineFndocumentation advised acombinethat gave wrong window results. It said to move out of the source states. A window's segment tree combines one state into every frame that covers it, so acombinethat consumed its source gave 4985 of 5000 rows wrong overROWS BETWEEN 100 PRECEDING AND CURRENT ROW(pinned bytests/ffi_roundtrip/agg_window.rs). It now says to leave the source unchanged. - A typed table function answered with the wrong column under projection
pushdown switched on after
build().buildrefused pushdown switched on beforewith_state, but not on the raw builder it returns, andSELECT bthen returned columna's value. Registering such a builder (andMockRegistrar::register_table) now fails. - A typed table function's callbacks could be replaced after
build()with the raw builder's safebind,init,local_initandscansetters (orextra_info), after which the typed trampolines read one type's data as another's: an init callback setting au64as init data made the scan lock it as aMutex<S>. Registering such a builder (andMockRegistrar::register_table) now fails; bothextra_infoSafety sections now say the pointee must be what the installed callbacks read. - A
rowclosure that panicked after its first value left the appender unpoisoned, so finishing the row by hand committed a row half written by the closure that panicked. It is poisoned, as an error there poisons it. - Dropping a
FileHandlecould abort the process when the close threw:duckdb_destroy_file_handlecallsClose()with notry. Only an extension's file system can throw there;DuckDB's local close cannot. The drop now closes throughduckdb_file_handle_close, which catches, and destroys the handle only if that succeeded; after a failed close it leaks the handle rather than retry the close unguarded. FileHandle::seekclamped a position pasti64::MAXtoi64::MAX, which a file system that accepts that offset (tmpfs) took, so the call returnedOkat a position the caller never asked for. It is now anInvalidInputerror.append_metadatanamed the wrong default platform on OpenHarmony (*-linux-ohos):DuckDBappends_muslthere, as for any musl-based Linux, and the tool did not.MockRegistrarstill accepted builders whose types the real registration refuses: a composite or literalTypeIdin any parameter, varargs, return, named-parameter, cast or config-option slot, and anANYreturn type. Its module doc said these checks needDuckDB; they do not. Each builder now runs one sequence of type checks, withDuckDBwhen registering and without it in the mock, so the messages are the same.- Four writer contracts named only a
LIST/MAPvector's direct child as moved by areserveon thatLIST/MAP.VectorWriter::from_vector,StructWriter::new,StructVector::field_writerandValidityBitmap::ensure_writablenow say the same about every STRUCT field and ARRAY element vector below that child, down to the nextLISTorMAP:DuckDB1.5.5 reallocates all of their data and validity buffers (tests/ffi_roundtrip/nested_reserve.rsmeasures which move). - Arrow import: a fixed-size list format DuckDB accepts could bypass the
layout checks.
DuckDBreads the size in+w:Nwithstd::stoi, so+w:2x,+w: 2and+w:+2are fixed-size lists to it; quack-rs parsed the size strictly, took them for leaves and skipped every check below them. A fixed-size list → struct → dictionary layout the checks refuse under+w:2crashed the process (SIGSEGV) under+w:2x. The size is now parsed asstoiparses it. - Arrow import: offsets below the top level were not checked for being
negative, and their sums could overflow (a panic in a debug build).
Every node's length and offset must be non-negative, and a sum past
i64::MAXis refused. ArrowArray::releaseandArrowSchema::releasecalled a producer's callback again at drop when the callback did not null itself, as the Arrow specification requires it to; they now null it themselves.entry_point!andentry_point_v2!aborted the process when an argument expression panicked.$policyand$registerwere evaluated in the generatedextern "C"function before the panic guard ("panic in a function that cannot unwind"); they are now evaluated under it, and the load fails with the panic's message.- A typed table function used as a
COPY … FROMreader could invalidate the database. Its bind declares columns, and underCOPY … FROMDuckDBappends each to theINSERT's own expected types, so every chunk reaching the table is too wide: aDuckDBbuilt with assertions failschunk.ColumnCount() == types.size()and invalidates the database; a release build drops the column. The typed bind now fails with a message when a column is declared there (upstream item 37). VARCHARandBLOBvalues were decoded as little-endian.duckdb_string_tholds its length and pointer in the target's own byte order;DuckStringView,read_duck_stringandread_duck_blobread both as little-endian, so on a big-endian target an inline string's length read aslength << 24and its inlined bytes were followed as a pointer (reproduced under Miri with--target s390x-unknown-linux-gnu). Both are now read natively, and the pointer is read as a pointer at the target's width, which also keeps its provenance. NoDuckDBextension platform is big-endian, and nothing changes on a little-endian one; bytes passed toDuckStringView::inline_from_bytesare now read in native order too.- The Arrow export check read
HUGEINT/UHUGEINTrows with their halves swapped on a big-endian target.DuckDBstores them as{lower, upper}; the check read the 16 bytes as a nativei128, so on s390x it passed10^38(read as about1.27 * 10^37) into a lossy export and refused2^63. It now readsDuckDB's struct (reproduced under Miri on s390x). - On a 32-bit target (wasm32),
ListBuilderandOwnedVector::newcould askDuckDBfor a buffer whose size wraps.DuckDBcomputes a buffer's size as a 64-bitidx_tand passes it tomalloc, which narrows it to a 32-bitsize_tunchecked. The limits bounded the element count byusize::MAX, not the bytes, so aBIGINTorVARCHARlist child could reserve 2^32 elements andmallocreceive 0 bytes, andOwnedVector::new(HUGEINT, 2^28)succeeded with the same wrap. The list limit now also fits one allocation, andvector::ops::MAX_CAPACITYis 2^28 - 1 on a 32-bit target. 64-bit targets are unchanged. data_chunk_from_arrowpassed on lengths no vector can hold.DuckDBsizes the chunk from the array's length before its error handling starts, and sizes each list child it reserves from the list's element count; a run-end-encoded column declares any length with a few bytes of buffers. On a 32-bit target a length of 2^28 (VARCHAR) or 2^29 (BIGINT) had its byte size narrowed bymalloc, and the import then wrote past the buffer; on any target a length past 2^37 threw through the C API. The layout walk now refuses, at any node, a row count whose next power of two exceedsvector::ops::MAX_CAPACITY(the rounding a list child's reserve adds).- A run-end-encoded child of a zero-row list crashed the Arrow import
when the list's offset was not 0.
DuckDBtreats a list it converts as zero rows as empty whatever its offsets say, and reads an empty list's child as a plain array (upstream item 24): a run-end-encoded child there is read from buffers it does not have (SIGSEGV on every release from 1.4.4). The layout walk refused this only when the list's offset was 0, and a valid array (an inner list under an empty outer row, whose offsets need not start at 0) got through. It now decides asDuckDBdoes, by the row count. - A dictionary-encoded child of a fixed-size list crashed the Arrow import
when the fixed-size list was a
MAPvalue.DuckDBverifies a map by flattening its entries, and flattening anARRAYflattens its child over the child vector's capacity, which a map allocates at the chunk's row count rather than the entries it holds; the dictionary child's selection vector was built only for the entries converted, so the flatten read it out of bounds (SIGSEGVon 1.5.0-1.5.2, an AddressSanitizerheap-buffer-overflowon 1.5.5; upstream item 24). The layout walk refuses a dictionary-encoded fixed-size-list child under a map; the same shape at the top level or under a plain list, which is not over-flattened, still imports.
Fourth audit
- A C API aggregate in a running window returned the wrong answer. Without
a destructor
DuckDBstreams it and re-reads the first row (sum-like:1 2 3 4 5for1 3 6 10 15). Every aggregate now registers one (a no-op when none is given). - Arrow import with a nonzero parent offset imported the wrong rows
(
DuckDBignores the offset for values); it is refused. ListBuilderoverwrote rows already in the list vector when it started on a non-empty one; it appends after them.VectorWriter::set_validleft a nested row's children NULL; it restores them when the row was NULL.- Scalar collision check. It missed signatures containing a type alias,
could be shadowed by a user macro named like the catalog functions it
queries (it now qualifies them with
system.main.), and accepted overloads thatDuckDB's binder finds ambiguous with varargs. Breaking: such overlapping overloads are refused at registration.Connectionkeeps a snapshot of existing scalars, so 300 registrations throughRegistrartake 18.6–35.6 ms instead of 5.2–6.3 s (release build, three runs each). - Keyword names. Breaking:
validate_function_namerefuses the 53 keywords (DUCKDB_UNCALLABLE_KEYWORDS) thatDuckDBcannot call unquoted —coalesce(x)silently ran the built-in — andSqlMacroparameters use the newvalidate_parameter_name(79 keywords). - Catalog entries outlived their handle. Breaking:
CatalogEntrycopies the name and type at lookup and owns nothing afterwards. - Secret scopes were parsed from a string; they are read as a list,
NULL-safely, from
system.main.duckdb_secrets(). CopyGlobalInitInfo::get_file_pathtruncated at a NUL. Breaking: it returnsResult<String>;get_file_path_bytesreturns the raw bytes.interval_to_microsreported overflow for totals that fit (an intermediate sum overflowed); it computes the exact total ini128.Appender: a failed automatic flush (every 204,800 rows) poisoned the appender with a false "half-written row" message; the row counts as ended and the constraint error is reported as it is.- On DuckDB 1.4.x, registering a scalar under any existing name (a new
overload of
abs, say) failed with no reason, because the C API registers withCREATEbefore 1.5.0; quack-rs refuses it first and says why. AndLogicalType::try_new(TypeId::TimeNs)returned anINVALIDtype there (the 1.4.x C API does not knowTIME_NS); it is an error now, as is any type id the running engine hands back changed. LogicalType::registerblamed a taken name when the type containedANY; it names the cause.try_decimalvalidates width and scale itself (DuckDBdoes only from 1.5.4). Breaking:try_array(_, 0), an emptyunion_typeand an emptyValue::array_valueare refused, as in SQL.MockRegistraraccepted builders the real registration refuses. Breaking: it runs the same checks (missing callback or return type, empty function set, incomplete copy function, config option without type or default) and records nothing on failure.description.yml: a quoted, flow or block value on the line after its key kept its quotes; values thatPyYAML(YAML 1.1) reads as a boolean, number, date or null draw a warning; the scaffold quotesname,githubandref. The "text after a comment" error named the line the value started on rather than the line of the comment that ended it.- Scaffold: the generated
lib.rsfailedcargo fmt --checkfor names of 4 characters or fewer or 40 or more; the generated CI's Linux SQLLogicTest step was skipped by extension-ci-tools. append_metadatarefuses a platform group (linux) and awasm_*platform without--wasm, and recognises a footer with an empty ABI field.validate_semverrefuses numeric pre-release identifiers with leading zeros;validate_spdx_licensenames the canonical spelling for a case-only difference.- ABI refusal for a development engine no longer suggests a declaration
the build ignores;
build.rswarns about a malformedQUACK_RS_TARGET_DUCKDB_VERSION.
Earlier passes (0.17.0, second and third audits)
-
Pitfall L8 —
DEFAULT_NULL_HANDLINGdoes not propagate NULLs for scalar functions. quack-rs documented thatDuckDB"automatically returns NULL if any argument is NULL, without your function callback being called". For a scalar function registered through the C API that is false at run time:CAPIScalarFunctioncalls the callback for every row including NULL ones and never inspects the result's validity, and the only NULL check inExpressionExecutor::ExecuteisVerifyNullHandling, whose entire body is inside#ifdef DEBUG. A callback that ignores validity therefore returns a non-NULL answer for a NULL input, silently, in every release build.SELECT f(NULL)still returns NULL — a literal NULL is constant-folded before the function is reached — which is why the bug survives review. From a column it does not. NewDataChunk::propagate_nulls/any_nullrestore SQL semantics in one line; the new typed constructors (see Added) get it right by construction; the docs, the book chapter andLESSONS.mdnow state whatDuckDBdoes, with the source quoted. A regression test pins the behaviour. Aggregates are no different: theirupdatereceives NULL rows under either setting too (Pitfall L12; see Added and the aggregate entry below). -
Composite
TypeIds silently produced an invalid type.duckdb_create_logical_type"returns an invalid logical type" forDECIMAL,ENUM,LIST,STRUCT,MAP,ARRAYandUNION— a non-null handle wrappingLogicalTypeId::INVALID, so the existing null check never fired..param(TypeId::Struct)failed much later with a message that named neither the parameter nor the fix, andget_type_id()on one panicked. NewTypeId::is_composite/composite_constructor_hint;LogicalType::newasserts,try_newerrors, and every builder validates before allocating anyDuckDBhandle. -
extra_infoleaked when a builder was not registered.DuckDBonly takes ownership atduckdb_*_set_extra_info; a dropped builder dropped the pointer. This reached users through APIs that never mention a pointer —TableFunctionBuilder::with_stateboxes two closures. Found by Miri. -
Two stale-borrow bugs in
src/secrets.rs's own tests, which took a pointer into aString, called a&mutmethod, then read through the stale pointer. The library'szeroize_stringwas correct throughout. -
The reference example disabled every panic guard in the crate.
examples/hello-ext/Cargo.tomlshippedpanic = "abort"— the settingvalidate_release_profilerejects outright and the scaffold refuses to generate, because it makes everycatch_unwindin quack-rs inert. CI built that example, loaded it into a realDuckDB, and held it up as the way to do this. Two new tests hold the example and the scaffold's generated profile to quack-rs's own validator, so the generator and the validator cannot drift. -
The
ScaffoldConfigexample in the README and two book pages did not compile. The struct gained three fields and the exhaustive literals were never updated; rustdoc examples are compiled bycargo testbut Markdown code fences are not, so the copy a new user reaches for was the broken one. All now use..ScaffoldConfig::default(). -
Registration failures now name what to check.
duckdb_register_*_functionreports failure as a bareDuckDBErrorwith no message. There are exactly three causes, and a name collision with aDuckDBbuilt-in (list_sum,array_sum, …) looks identical to a type error. The message names all three and points atSELECT * FROM duckdb_functions() WHERE function_name = '<name>'. -
Wrong answers, no error:
- A NULL row of a
STRUCTresult kept its fields valid, so(f(x)).areturned the stale field value instead of NULL. - A typed table function failed the second time a plan ran
(
PREPARE … ; EXECUTE p; EXECUTE p;, or a recursive CTE):initmoved the state out of bind data DuckDB reuses for every execution. Valuegetters cast the value in place:Value::double(1.5).as_i32()turned the value intoDOUBLE 2.0. Getters now read a private copy.BindInfo::add_result_columnwith a type containingANY/INVALIDwas dropped by DuckDB, shifting every later column; it is now a bind error.datetime::time_tz_bitssilently corrupted an out-of-range offset.
- A NULL row of a
-
Duplicate overload signatures in a scalar or aggregate function set are rejected at
register, naming both overloads, instead of registering and then failing every call with "Could not choose a best candidate function". -
Expression::foldreturnsErrfor a non-foldable expression instead ofOkwith a null-handleValue. -
CastFunctionBuilder::registerleakedextra_infowhen DuckDB rejected anANY/INVALIDtype; those are now rejected before anything is handed over. -
ReplacementScanInfo::set_error("")was ignored by DuckDB, so the query fell through to "table does not exist". -
SqlMacrowith a SQL keyword as a name or parameter produced a parser error. -
Documentation that was false against the code or DuckDB:
set_max_threads(it does not needlocal_init);DbConfig::setaccepts unknown option names; Pitfall L4 (a skippedensure_validity_writablesilently drops the NULL rather than segfaulting); the book'spanic = "abort"advice (it must be"unwind", which the crate itself enforces); several README and book examples that did not compile; and the stable ABI prefix, which is ABI-identical since v1.2.0 but not byte-identical (two slots were renamedvarint→bignumin v1.4.0). -
ClientContext's constructors now state that the context must not outlive its connection: DuckDB's wrapper holds a reference, not an owner. -
Pitfall L10 (
LESSONS.md,book/src/reference/pitfalls.md) — scalar bind data is dropped whenDuckDBcopies a bound expression. The book's pitfall summary table was also missing L8 and L9; all three rows are now there. -
ScalarBindInfo::set_bind_data_copy— scalar bind data was silently lost wheneverDuckDBcopied a bound expression.CScalarFunctionBindData::Copy()populates the copy's bind data only if a copy callback is registered, and quack-rs never exposed the setter, soget_bind_datacould return null on a copied expression: a wrong answer, not a crash. -
An invalid
mutants.ymlthat silently disabled the mutation gate. A shell comment added earlier in this branch wrote out an empty workflow expression while explaining not to pass values that way. GitHub evaluates expressions anywhere in the file, including inside shell comments, so the empty one invalidated the whole workflow:Invalid workflow file: .github/workflows/mutants.yml (Line: 203, Col: 14): An expression was expectedThis fails silently by design: GitHub records a run with zero jobs and the workflow stops running.
mutants-incrementaltherefore stopped executing on pull requests while every other check stayed green — the same "a gate that is not actually running" failure this release is otherwise about, introduced by this branch rather than found in it.scripts/check-workflow-expressions.pynow runs in thedocjob and rejects this class. It fails on the exact commit that broke and passes on the fix. Neitheryaml.safe_loadnor a JSON-Schema check catches it, because both treat therun:block as an opaque string. -
22
assert!(x.is_empty())/assert!(!x.is_empty())assertions that beta clippy's newassert_is_empty/assert_is_not_emptylints reject, across 10 files. These are not style noise:clippy-betawas running stable clippy before this release, so it had never reported them, and when beta promotes to stable the blockingclippyjob inherits every one. Each now usesassert_eq!/assert_ne!against an empty value, which is what the lint asks for and prints the actual value on failure. -
Four CI quality gates were testing nothing, each verified against the files rather than inferred:
- The MSRV job (
ci.yml) and the release gate's MSRV entry ran a barecargo checkafter selecting 1.86.0.rust-toolchain.tomlpinschannel = "stable"and a toolchain file overrides the rustup default, so both ran stable. Nowcargo +1.86.0 check. clippy-betaran stable clippy for the same reason.- Miri ran with default features,
cfg-ing out everyduckdb-1-5*module — includingsrc/arrow.rs, the largest block of pure-Rustunsafehere. - Doctests were never compiled: every invocation used
--all-targets, which excludes them. All 183 passed when the gate was added.
- The MSRV job (
-
release.ymlstill usedfail-fast: true, the settingLESSONS.mdblames for a release that shipped with two platforms broken. -
MUTANTS_EXITcapturedtee's status rather than cargo-mutants'. -
SECURITY.mdrecommendedpanic = "abort"; that makescatch_unwindinert and disables the crate's entire panic-containment mechanism.Cargo.tomlhas always setunwind, andvalidate_release_profilerejectsabort. -
RELEASING.mdStep 1 listed 9 check names against 30 CI jobs, and.github/workflows/README.mdlisted 14. A maintainer following either could tag with Miri, LeakSanitizer,osv-scanorsemverred. The workflow README now carries a table generated fromci.yml, with the generator inline. -
An aggregate's
updatereceives NULL rows; the documentation said it did not.NullHandling, thenull_handlingsetter on both aggregate builders, the null-handling book page and the first-extension tutorial said that DuckDB skips NULL rows before an aggregate'supdateunlessSpecialNullHandlingis set. DuckDB'sCAPIAggregateUpdatepasses every row of the chunk toupdate, NULL rows included, underDefaultNullHandlingandSpecialNullHandlingalike; for an aggregate, DuckDB reads the setting only when decorrelating a correlated subquery. Anupdatethat reads a row without checking its validity reads a meaningless value for every NULL row. The docs now say so, the new Pitfall L12 gives the symptom and the fix (skip rows whoseis_validis false), and the end-to-end testaggregate_update_receives_null_rows_under_either_null_handlingpins the behaviour. L12 also records that, under either setting, a count-like aggregate in a correlated subquery returns NULL, not 0, for an outer row with no match; DuckDB rewrites that NULL to 0 only for its owncount. -
Wrong answers, no error, third pass:
- Once a row exceeded
ListBuilder's child-capacity ceiling,push_row/push_map_rowwrote no list entry for it or for any later row of the chunk. DuckDB reuses output vectors, so those rows returned the previous chunk's lists as valid values. A refused row is now NULL. VectorWriter::write_varchar/write_blobstored a value longer thanu32::MAXbytes as a truncated string, forVARCHARpossibly cut inside a UTF-8 sequence: a 4 GiB + 1 byte value came back one byte long.- A panic in a
cast_callback!body underTRY_CASTleft the output vector as it was, soTRY_CASTreturned zeros or a previous query's values rather than NULL: DuckDB ignores a cast's return value inTRYmode and nulls only the rows passed toset_row_error. The macro now marks every row of the chunk as an error. - Two overloads of a scalar set that differ only in their varargs type, such
as
f(BIGINT)andf(BIGINT, BIGINT...), were rejected as duplicates. The varargs type is now part of the signature, as it is in DuckDB.
- Once a row exceeded
-
A
cast_callback!body that returnedfalsewithout callingset_errorfailed a regularCASTwithConversion Error:and no text. The macro now setscallback::CAST_FAILED_WITHOUT_MESSAGEbefore running the body; the body's ownset_errorreplaces it. Hand-written cast callbacks are unchanged. -
set_error("")on a table function, cast or copy function reached the user as an error with no text after the prefix (Binder Error:,Conversion Error:); the newEMPTY_ERROR_PLACEHOLDERis reported instead. -
An error message containing a NUL byte lost everything after it on some paths and kept it on others. Every path now replaces the NUL with
?:set_erroron scalar, aggregate, table, cast, copy and replacement-scan functions,ExtensionError::to_c_string,ErrorData::new, and the entry point's report of a failed registration. -
Expression::folderrors carried DuckDB's exception serialized as JSON ({"exception_type":"Conversion","exception_message":…}) with the typeInvalidInput. They now carry the plain message and the matchingDuckDbErrorType(Conversion,OutOfRange, …). -
With
duckdb-1-5, a typed table function whose bind declares no column fails with an ordinary bind error; DuckDB raised anINTERNAL Errorwith a C++ stack trace. -
FileFlag::CreateNewdid not create a file: it set only DuckDB's exclusive flag, which is ignored without the create flag, so an existing file opened and a missing one failed.set_flag(FileFlag::CreateNew, true)now also setsCreate. On Windows an existing file is still opened rather than refused: DuckDB's Windows file system ignores the exclusive flag, which is now documented onFileFlag::CreateNew. -
WarningCollectordropped every warning, silently, once a panic had poisoned its lock. It now recovers the lock and keeps working. -
ScalarFunctionBuilder::varargs/ScalarOverloadBuilder::varargswith a compositeTypeId(TypeId::List, …) panicked inside the setter.registernow refuses it with an error naming the varargs slot. -
ScalarFunctionSetBuilderandAggregateFunctionSetBuildercheck every overload for a return type and its required callbacks before creating any DuckDB handle, and the error names the overload index; the scalar set used to report "overload missing function callback" with no index. -
AbiPolicy::Strict's refusal of an unknown engine recommendedAbiPolicy::AllowUnknownEngine; it now recommends a build that uses only the stable C API. The layout-mismatch message no longer advises rebuilding aduckdb-1-5extension against a pre-1.5 engine. -
append_metadatarejected a 32-byte footer value, which DuckDB reads in full without a terminating NUL, and it now also accepts--option=value. -
examples/parse_descriptions.rs, pointed at a community-extensions checkout (extensions/<name>/description.yml), found no files and reported "0 parsed, 0 rejected" with exit status 0. It now reads that layout, fails when it finds no files, and exits non-zero when any file is rejected. -
Documentation that was false against the code or DuckDB, third pass:
- Aggregates: the
state_sizecallback runs whenever an operator sizes a state buffer, not once at registration;finalizeruns once per result batch;destroyalso runs on the source states ofcombine;combinetargets are initialised by theinitcallback (StateInitFn;T::default()forFfiState<T>), not zeroed;updatereceives one state pointer per input row, not per group. - Scalars: the
null_handlingsetters repeated the claim Pitfall L8 disproved (that DuckDB skips the callback for NULL arguments). Identical calls share bind data: forSELECT f(i), f(i)the bind callback runs twice, but both columns use the data from its first run, so a bind that reads a counter, clock or RNG needsvolatile. Theregistermethods now state DuckDB's collision rules: an aggregate cannot take a name already used by a scalar function, aggregate or macro, while a scalar merges into an existing function of the same name (an identical signature is now refused, see Changed). The entry-point docs say that registration is not transactional: functions registered before the registration closure fails stay registered. - Table and copy functions:
set_cardinality'sis_exactworks the other way round fromduckdb.h;local_initdoes not enable parallelism, onlyset_max_threadsdoes; tableextra_infomust point toSend + Syncdata;CopyBindInfo::options' shape is now described. - Casts:
set_row_errorrequiresrow < count, because DuckDB checks that bound only in debug builds; the cast docs saidfalsebecomes NULL underTRY_CAST, which holds only for rows passed toset_row_error. - Queries and values: a multi-statement string passed to
query()/execute()runs every statement and returns the first row-producing statement's result; every prepared parameter has a name (a positional?is named by its position);DbConfig::get_flagreturns the extension's name, not a description, for an extension setting;check_valid_utf8agrees withstd::str::from_utf8rather than being stricter. - Dates and intervals:
date_from_daysofinfinitygives 5881580-07-11; DuckDB's 30-day month applies to interval comparison andepoch_us, not to interval arithmetic orepoch;DuckInterval'sEqcompares fields, so1 monthdiffers from30 dayshere although SQL calls them equal. InstanceCache::get_or_createreturns an error for a different config on an already-open database (the docs said the config was ignored), and never caches in-memory paths.FileSystem'sset_flag(flag, false)does not clear a flag.SqlMacrodocuments where the macro is created, that it persists in a database file and can replace a user's macro or shadow a built-in; its parameter errors say "parameter name".- Table macros read a table parameter through
query_table(tbl): theSqlMacroexamples wroteFROM tbl, which DuckDB binds at creation time to a table literally namedtbl, so the module example failed to register. - Loading: the getting-started pages and Pitfall P3 said to
LOADa bare.so, which every supported DuckDB refuses; they now show theappend_metadatastep andduckdb -unsigned, and no recipe setsallow_extensions_metadata_mismatchany more, since a correctly stampedC_STRUCTbuild loads without it. Pitfall P2's symptom was wrong: a bad-dvis stamped without complaint andLOADrefuses the file. - The book's Known Limitations page suggested approximating window semantics
with aggregate functions, the exact shape that crashes every C API
aggregate (Pitfall L11); that page, the README and the hello-ext README now
warn about it. The hello-ext README also stopped telling readers to verify
panic = "abort"and to addduckdbwithbundledas a dev-dependency (Pitfall P9). - The installation page's MSRV rationale,
SECURITY.md's claim thatTlsConfigProviderenforces TLS 1.2+ by default (min_tls_versionis a required method), the entry-point page's macro expansion, the crate's and FAQ's pitfall counts, and the README's validated-fields table (an unlisted SPDX id is a warning, not a rejection). - Book examples that did not compile or did not do what they said: among
them a
DuckStringViewexample using the deprecatedfrom_bytes(it returnsNonefor any string over 12 bytes), amy_bindthat leaked theduckdb_valuefromduckdb_bind_get_parameter, a READMEdescription.ymlexample that did not compile, and scaffold examples that, run as written, overwrote the current package'sCargo.tomlandsrc/lib.rs. Broken links in the book are fixed, and hand-kept test counts, several of them wrong, are removed from the repository trees.
- Aggregates: the
-
# Safetycontracts that allowed undefined behaviour (found while documenting everyunsafeblock; contract text only, no signature or behaviour change):FfiLocalInitData::get/get_mut,FfiInitData::get_mutandFfiBindData::get_from_init/get_from_functiondid not requireTto be the type passed toset;set::<u8>thenget_mut::<[u64; 64]>met every stated clause and wrote 512 bytes through a 1-byte allocation. The init-data getters also did not bound the returned lifetime by the scan call. Both requirements are now stated.MapVector::set_sizedid not require the size to equal the entries written, so a larger size made DuckDB read past the child vectors.read_duck_blobdid not require the vector to outlive the returned slice, which borrows the vector's own buffer for a blob of 12 bytes or fewer.
-
Documented: when an aggregate's
finalizereports an error, DuckDB 1.5.5 does not destroy every state the query created (ungrouped: 2 initialised, 1 destroyed; grouped: 4 and 2), so whatever those states own leaks, and for anFfiState<T>whoseTis boxed (see Changed) the box too. Nothing in an extension can detect it; Known Limitations andAggregateFunctionInfo::set_errornow say so, and an end-to-end test pins it. Two of this release's own tests leaked on these paths and made the LeakSanitizer job fail; both now use states that own nothing. -
cargo docfailed with default features on a link toPreparedStatement::execute_streaming, which needsduckdb-1-5. CI now also builds the docs with default features, the set a dependent crate documents. -
Cargo.toml'sduckdb-1-5description gave the requirement aslibduckdb-sys >= 1.5.0(the crate is versioned 1.10500.0) and claimed the feature has no effect against 1.4.x, which was never checked; the hello-ext README saidvarargsandvolatileneedduckdb-1-5.
Security
Fourth audit
- An Arrow import with a large dictionary wrote past a heap buffer.
data_chunk_from_arrowon a dictionary-encoded array with NULLs and more than 2048 entries — aLISTof 1025+ two-element dictionary-encoded lists is enough — madeDuckDBoverflow a validity mask (valgrind: invalid write inGetValidityMask; SIGSEGV or SIGABRT on 1.4.4, 1.5.0 and 1.5.5). Such arrays are refused beforeDuckDBis called. - An Arrow import read out of bounds. A dictionary-encoded or null-type
column came back as a dictionary or constant vector, which every quack-rs
reader reads as flat: wrong values, then reads past the buffer. Those
columns are copied into flat vectors before
data_chunk_from_arrowreturns. - Rendering a timestamp from SQL aborted the process.
make_timestamp(-9223372036854775808)passed to a table function, thenValue::as_str,display_stringor{:?}:DuckDB's rendering threw through the C API (exit 134, 1.4.4 to 1.5.5), also inside a LIST, STRUCT or MAP. Every temporal payload is checked first;as_strreturnsErr(UNRENDERABLE).as_timeand the other converting getters no longer hand such a payload toDuckDB's cast, which overflowed a signed multiply. - A scalar bind callback inspecting a subquery argument aborted the process
on DuckDB 1.5.0 to 1.5.4. Those releases copy the argument outside any
try;SELECT f((SELECT 1))threw a C++ exception through the callback. Breaking:ScalarBindInfo::argumentnow asks for nothing on those releases and fails the bind with an explanation;get_argument's Safety section states the requirement. - An out-of-range TIME or timestamp crashed
DuckDBlater.PreparedStatement::bind_time/bind_timestamp/bind_timestamp_tzandAppender::append_time/append_timestampaccepted any payload;i64::MINas a TIME segfaulted when rendered, and at the append itself into a VARCHAR column. They are refused (Breaking).bind_str,bind_blobandappend_bytesrefuse more than 4 GiB, whichDuckDBstored modulo 2^32 (a 4 GiB + 3 byte blob became 3 bytes). - A panic in a scalar function's bind or init callback aborted the
process. New
scalar_bind_callback!/scalar_init_callback!macros (duckdb-1-5) catch it and fail the query with its message; a panic with an empty message reportsEMPTY_PANIC_PLACEHOLDER. - A null
get_apioraccessfromDuckDBpanicked or crashed the entry point.init_extensionchecks both beforelibduckdb-sysunwraps them. - A literal type in a registration invalidated the database.
TypeId::StringLiteral/IntegerLiteralas a parameter, return or registered type: the first query that used it raised an internal error and every later query failed.LogicalType::try_newrefuses both. FfiStatedestroyed statesinitnever ran on. After astate_initerror,DuckDBpasses never-initialised states todestroy; each state now carries a tag derived fromT'sTypeId(and, for a boxedT, the box's address) thatdestroychecks (Breaking: see theFfiState<T>entry under Changed).SecretEntryleft secret bytes in spare capacity after truncation; zeroisation now covers the whole allocation.
Earlier passes (0.17.0, second and third audits)
-
A panicking
Dropin extension state aborted the process. Every FFI destructor quack-rs generates —FfiState<T>::destroy_callback,FfiBindData/FfiInitData/FfiLocalInitData::destroy,replacement_scan::drop_box,TypedCallbacks::destroy_extra— dropped aBox<T>of arbitrary user data directly inside anextern "C" fn. Since Rust 1.81 an unwind across that boundary is a guaranteed process abort. Reproduced againstDuckDB1.5.4: an aggregate whose state type has a panickingDropkilled the process withSIGABRTfrom insideduckdb::RowOperations::DestroyStates, on a task-scheduler thread. All of them now run under the newcallback::catch_ffi_panic, which is public so extensions writing their ownextern "C"destructors get the same containment.Where
DuckDBoffers an error channel the panic is now reported instead of swallowed:CAPIAggregateStateInitchecks the error flag and throws, so a panickingDefault::default()becomes an ordinary SQL error rather than a silent NULL. The state destructor has none (CAPIAggregateDestructortakes no info and returns nothing), so there the message is discarded.FfiState::init_callbackalso no longer forms a&mut Selfover the possibly-uninitialised allocationDuckDBhands it. -
Soundness: safe code could corrupt memory, race, or read freed memory.
FileSystemheld a rawClientContextpointer with no lifetime, so it could be used after its connection closed and read freed memory (confirmed under valgrind). Breaking:FileSystem<'ctx>.SelectionVector::newexposed uninitialised memory through the safeas_slice()(stale0xDEADBEEFobserved), and a large length made DuckDB compute a wrapped allocation size, so safe indexing segfaulted. Breaking: it returnsResult, rejects lengths aboveMAX_LENbefore DuckDB is called, and zeroes the buffer.datetime::decimal_to_f64read past DuckDB's powers-of-ten tables forscale > 38.FfiBindData,FfiInitDataand replacement-scan data are shared across threads by DuckDB. Breaking:FfiBindData::set/FfiInitData::set/register_with_datarequireT: Send + Sync,FfiLocalInitData::setrequiresT: Send.
-
Process aborts from ordinary input. Each of these let a DuckDB C++ exception unwind into Rust ("Rust cannot catch foreign exceptions"), killing the host process; each is now validated in Rust first and reported as an error or
None:- every
Value::as_*getter (and the_orforms) on a SQLNULL— e.g. a table function called withf(n := NULL); a null handle was dereferenced; datetime::date_to_dayson an invalid date,timestamp_from_micros/timestamp_to_microson infinities and the far-negative range;datetime::time_from_micros/time_tz_from_bitson a time outside00:00:00–24:00:00, in a DuckDB built with assertions (a debug build, as thebundled-testfeature compiles), which failsTime::Convert'sD_ASSERT; a release build returned out-of-range fields;SelectionVector::newabove DuckDB's allocation limit;- a config option whose default does not cast to its type;
- catalog lookups for
Schema,Database,PreparedStatementandInvalidentry types; - a panic whose payload's own
Droppanics (panic_any(value)), in every callback macro,catch_ffi_panic, the typed scalar and typed table trampolines and the entry point's registration guard; - the entry points dereferenced a NULL
duckdb_database*when DuckDB'sget_databasefailed.
- every
-
Documented, not fixable here: C API aggregates crash under
agg(x) OVER ()andagg(x ORDER BY y). DuckDB'sCAPIAggregateUpdatedoes not flatten the state vector, and the window-constant and sorted-aggregate executors pass a one-element state array withcount > 1, so every aggregate registered through the C API — not only quack-rs's — reads out of bounds. Reproduced in plain C against DuckDB 1.4.4, 1.5.0 and 1.5.5; reported upstream as duckdb/duckdb#26109. Documented onAggregateFunctionBuilder,AggregateFunctionSetBuilder,FfiState, the aggregate book pages and as Pitfall L11. -
The one active advisory suppression is gone, because the crate behind it is. RUSTSEC-2026-0235 (
rkyv0.7.46) was suppressed inosv-scanner.toml, reachable only as quack-rs →duckdb→rust_decimal→rkyv. Induckdb1.10505.0rust_decimalbecame an optional dependency (it was required in 1.10504.0), and quack-rs does not enable it — so neither crate is inCargo.lockany more. Both the suppression and the CI step that re-proved it have been removed. -
cargo denywas scanning the wrong dependency graph.deny.tomlhadgraph.all-features = false; sinceduckdbis optional and the crate declares nodefaultfeature, neither the advisory scan nor the license scan ever evaluatedduckdb,arrow,chronoorrust_decimal. Nowall-features = true. -
ci.ymlandmutants.yml— the two workflows that build and execute pull-request code — had nopermissions:block, so the token inherited the repository default. Both now takecontents: read. -
mutants.ymlinterpolated a PR-derived file list straight into arun:block, where$(...)expands before bash parses the script. Moved toenv:. -
persist-credentials: falseon all 48actions/checkoutsteps. -
Soundness, third pass: more ways safe code, or a malformed input, could corrupt or read freed memory.
- A
FileHandlecould outlive its database and read freed memory. Breaking:FileHandle<'fs>(see Changed). - DuckDB v1.4.5 was missing from the ABI layout table, so a
duckdb-1-5build loaded into it was treated as an unknown engine instead of a layout mismatch, and underAbiPolicy::AllowUnknownEngineit loaded and segfaulted. v1.4.5 is now in the table, so such a build is reported as a layout mismatch there, as it is on v1.4.4.
- A
-
Process aborts, third pass. Each of these killed the host process:
ClientContext::config_optionon an option whose value is NULL:enable_profilingbefore it is set, or any option afterSET <option> = NULL. It now returnsNone.- A catalog
TypeorCollationlookup of a name that makes DuckDB autoload an extension, when the autoload fails. Breaking: now refused (see Changed). - A config option default that SQL's
TRY_CASTaccepts but DuckDB's built-in cast does not, such as aTIMESTAMPTZdefault with a time-zone name while ICU is loaded.ConfigOptionBuilder::registernow converts the default through the connection's ownTRY_CASTand hands DuckDB aValueof the option's type, so no cast runs inside the C API. Valuetemporal getters whose cast DuckDB implements by throwing: for exampleas_time()of'infinity'::TIMESTAMP(reachable from a table function's named parameter) oras_timestamp_ns()of anyTIMESTAMPafter 2262. They now returnNonewhere DuckDB would throw, and also for a result outside the target type's range, which DuckDB could produce but not render.- Rendering a temporal
Valuebuilt from an out-of-range payload. Breaking: the constructors now validate (see Changed). - The
AbiPolicy::Warndiagnostic usedeprintln!, which panics when writing to stderr fails, outside the entry point's panic guard. A failed write now loses the warning instead. validate_spdx_licenseon deeply nested parentheses (stack overflow). Breaking: nesting is capped at 64 (see Changed).
-
SqlMacro::registerran every statement in a body. A body of1); DROP TABLE t; SELECT (1registered and dropped the table. Breaking: a body with more than one statement is now refused (see Changed). -
SecretEntry::with_fieldon an existing key,with_providerandwith_scopefreed the value they replaced without zeroizing it (andwith_fieldalso the duplicate key), althoughDropzeroizes those same values. Each now zeroizes before replacing. A test that inspects every buffer as it is freed covers the replaced value, provider and scope; the duplicate-key case is covered only by reading the code.
Dependencies
libduckdb-sys/duckdb1.10504.0 → 1.10505.0 (DuckDB 1.5.4 → 1.5.5),cc1.2.64 → 1.4.7,arrow58.1.0 → 58.4.0. The relock removed 100 packages net:libduckdb-sys1.10505.0 swapped itsreqwestbuild-dependency forureq, taking the hyper/tokio/quinn/rustls trees with it. MSRV is unchanged at 1.86.0.- No
duckdb-1-5-5feature was added, deliberately. DuckDB 1.5.5 adds no C Extension API surface:extension_api.hppis byte-identical between v1.5.4 and v1.5.5 (sha2560232a22a…3017031, 89456 bytes, 546 function pointers in each). There would be nothing to gate. See the note inCargo.toml. - GitHub Actions, each SHA resolved against the upstream tag:
actions/checkoutv7.0.0 → v7.0.1,Swatinem/rust-cachev2.9.1 → v2.9.2,codecov/codecov-actionv7.0.0 → v7.1.1,actions/attest-build-provenancev4.1.1 → v4.2.2,actions/deploy-pagesv5.0.0 → v5.0.1. Theactions/configure-pagespin was already v6.0.0; only its comment said v5.0.0. - Pinned the four CI tools installed unpinned (
cargo-mutants27.1.0,cargo-semver-checks0.50.0,cargo-llvm-cov0.9.1,cargo-fuzz0.13.2).mutants.ymlreasons about behaviour introduced in cargo-mutants 27.0.0, which held only by luck of whatevercargo installfetched. Everycargo installstep now also clears the workflow-wideRUSTFLAGS: "-D warnings", which was compiling third-party trees with warnings-as-errors. dtolnay/rust-toolchainis pinned to a commit on the action'smasterbranch in every workflow and in the workflowgenerate_scaffoldwrites. The old pin was a commit of its regeneratedstablebranch that no ref reaches any more, so GitHub may garbage-collect it. Every use now passestoolchain:explicitly, asmaster'saction.ymlrequires; the pin does not pin the Rust version.
0.16.0 — 2026-08-19
Security
-
New
abimodule:duckdb_ext_api_v1layout verification.DuckDBhands a loadable extension a struct of function pointers. Its first 357 slots — the "stable prefix" — have been byte-for-byte identical in every release from v1.2.0 through v1.5.5, but everything past that is the unstable region, andDuckDBinserts new entries in the middle of it between releases (duckdb_appender_clearat slot 410 in v1.5.0,duckdb_geometry_type_get_crsat slot 493 in v1.5.2). Every quack-rs wrapper behind theduckdb-1-5/duckdb-1-5-3features — 105 C API functions covering scalar bind/init, copy functions, catalog access,ErrorData,FileSystem,Expression,SelectionVector, config options, table descriptions and the client context — lives in that region.DuckDBdoes not catch this: an extension stampedC_STRUCT+v1.2.0(the default) is accepted by anyDuckDBwhose C API version is at least v1.2.0 and then handed the whole struct, unstable region included. Loading such a build into aDuckDBwith a different layout silently dispatches to the wrong function pointers. Verified end-to-end: an extension built againstDuckDB1.5.0's headers, stampedC_STRUCT/v1.2.0, loaded intoDuckDB1.5.5 aborts the process withdouble free or corruption.abi::checkcompares the slot count of the compiled-in layout against the layout the running engine uses (resolved fromduckdb_library_version(), which sits at stable slot 7 and is therefore always dispatched correctly).init_extension/init_extension_v2and theentry_point!/entry_point_v2!macros now run that check under the newAbiPolicy::Strictdefault wheneverduckdb-1-5is enabled, turning the memory corruption above into aLOADerror that names the mismatch and the remedy.AbiPolicy::WarnandAbiPolicy::Trustopt out;Trustis the right choice for binaries stampedC_STRUCT_UNSTABLE, whereDuckDBalready pins the release. Extensions that stay on the stable prefix are unaffected and keep their forward compatibility.scripts/check-abi-table.pyre-derives the layout table from every upstream release header and runs in CI, so the table cannot drift asDuckDBreleases. -
DuckStringView::from_byteswas unsound. It was safe to call yet dereferenced the heap pointer embedded in bytes 8–15 of a pointer-formatduckdb_string_t, so safe code holding attacker-influenced bytes could read arbitrary memory. Replaced by two honest constructors:from_raw(unsafe, honours pointer format — what callbacks want) andinline_from_bytes(safe, returnsNonefor pointer-format values).from_bytesis deprecated and no longer dereferences. -
CopyGlobalInitInfo::get_file_pathcorrupted the heap. It calledduckdb_freeon the pointer fromduckdb_copy_function_global_init_get_file_path, which returnsinfo_ref.file_path.c_str()— the interior pointer of a C++std::stringDuckDBstill owns and destroys itself. EveryCOPY ... TOthrough a quack-rs copy function handed the allocator a pointer it never issued; the first live test of the path aborted withcorrupted size vs. prev_size in fastbins.Every other
duckdb_freecall site in the crate was then audited againstDuckDB's implementation, and all twelve are correct. The signature is not sufficient to decide:char *returns are owned andconst char *returns are usually borrowed, butduckdb_parameter_nameis declaredconst char *and returnsstrdup(...), so it is owned. Recorded asLESSONS.mdP11 with the full table.
Portability and feature-combination breakage
-
MAX_LIST_CHILD_CAPACITYwas typedusize, making the crate fail to compile forwasm32.DuckDB'sDConstants::MAX_VECTOR_SIZEis1ULL << 37ULL— anidx_t, not a pointer-sized value. As ausizeconst,1 << 37is a const-eval overflow wherever pointers are 32 bits, which is everywasm32target — andDuckDB's own extension CI builds three of them. Now typedu64, with a separateusize-clamped constant for the capacity arithmetic; on a 32-bit target the ceiling is larger than any allocationusizecan describe, sousize::MAXis the real limit. -
Three unit tests called
duckdb-1-5-gated methods without acfggate, breaking--features bundled-teston its own — a combination that builds the live-DuckDB tests but not the 1.5 wrappers.Value::display_stringandTableDescription::column_count/column_typeare the gated methods; the assertions around them are now gated too.Both defects compiled cleanly under every other feature combination.
scripts/check-matrix.shnow runs the combinations CI runs — includingbundled-testalone and thewasm32legs — in one command.
Security scanning
-
Security (OSV / GHSA)failed onRUSTSEC-2026-0235(rkyv0.7.46), an advisory that no build of this crate can reach.osv-scannerreadsCargo.lock, and Cargo pins optional dependencies there whether or not their feature is enabled, so the job flagged a crate that is never compiled. The chain is quack-rs →duckdb(optional dependency, enabled bybundled-test) →rust_decimal1.40.0 →rkyv(optional feature ofrust_decimal, not enabled).rust_decimalitself is built;rkyvis not. It is also not fixable here —rust_decimal1.40 constrainsrkyvto^0.7, and the fixed version is 0.8.17. The advisory is present onmainas well.Suppressed via a new
osv-scanner.tomlcarrying the full reachability argument. The suppression does not rest on that comment staying true: theosv-scanjob now re-derives it on every run, failing the build if anrkyvnode ever appears incargo tree --all-features --target all. The check tests the forward tree, becausecargo tree -iexits 0 with "nothing to print" for a lockfile-only package and so cannot tell "not built" from "built" by exit code; it also anchors onrust_decimalbeing present, so a truncated or failed tree reports as unverified rather than as clean.
CI guards
-
scripts/check-abi-table.pytreated an unreachable release header as proof the release did not exist. Itsfetchswallowed every exception — 404, timeout, DNS, 5xx alike — and returnedNone, which the caller printed as "not published (skipped)" and dropped from the derivation. One transient failure onv1.4.4therefore narrowed the derived range fromv1.4.0–v1.4.4tov1.4.0–v1.4.3and failed CI reportingsrc/abi.rsas stale. Following that advice would have shrunk the layout table and made the runtime guard refuseDuckDBversions it should accept — the exact failure the table exists to prevent.A definite 404 is now distinguished from every other failure, transient errors are retried, and a tag that could not be downloaded suspends the staleness comparison (exit 2, "could not check") instead of failing it. The other three guards each fetch a single file, so an empty fetch already meant no data rather than partial data.
-
All four guard jobs treated exit 2 as a failure. The scripts document it as "upstream unreachable, could not check", but the workflow ran them bare, so any non-zero failed the job — making every guard a network-flake away from a red build. They now surface exit 2 as a warning and fail only on exit 1.
Generated CI
- The generated CI workflow left one action unpinned. Three of its four
actions were SHA-pinned;
dtolnay/rust-toolchain@stablewas not, justified by a comment claiming its SHA "changes with each Rust release". That is not how the action works — it reads the toolchain fromrust-toolchain.tomlor itstoolchain:input at run time, so pinning the action's SHA does not pin the Rust version. quack-rs's own CI SHA-pins the same action and gets current stable. A branch is a moving target its owner can repoint, and a workflow step runs arbitrary code in the user's CI. All four are now pinned to the same SHAs quack-rs itself uses, and a test asserts everyuses:in the generated workflow carries a 40-character hex ref.
Fixed
The release-profile validator required the setting that breaks panic safety
-
validate_release_profilerequiredpanic = "abort", which makes every one of quack-rs's panic guards inert. quack-rs wraps everyextern "C"entry point — the extension entry point and every scalar/table/aggregate/cast/copy callback macro — incatch_unwind, so a panic in an extension's code becomes aDuckDBerror instead of a crash.catch_unwindcatches nothing underpanic = "abort": the runtime aborts before unwinding starts. Demonstrated directly rather than assumed —rustc -O panic_probe.rs → caught, process survived, exit 0 rustc -O -C panic=abort … → Aborted, exit 134— so the validator was telling extension authors to configure the one setting that turns a recoverable SQL error into a
SIGABRTthat kills the user's wholeDuckDBsession.The crate already disagreed with itself: the scaffold has generated
panic = "unwind"since the panic-safety work in this release, with a comment explaining why.validate_release_profilenow requires"unwind"and rejects"abort"with that explanation;ReleaseProfileCheck::panic_abortis renamedpanic_unwind. A new test asserts the scaffold and the validator agree, so they cannot drift apart again.The original justification — "panics across FFI boundaries are undefined behavior" — is also out of date: Rust defines an unwind escaping
extern "C"as an abort, and quack-rs catches panics before the boundary regardless.quack-rs's own
[profile.release]also saidpanic = "abort". Cargo ignores a dependency's profile so it changed nothing downstream, but it contradicted the crate's own advice; it now says"unwind".
A validator made legal function names unregisterable
-
validate_function_namerejected mixed-case names, and it gatestry_new— soScalarFunctionBuilder::try_new("myFunc")returnedErrand the function could not be registered through quack-rs at all.DuckDBitself shipsformatReadableSizeandformatReadableDecimalSize, and registering a camelCase name through the C API succeeds: verified againstDuckDB1.5.5, where the function is then callable asformatReadableThing,formatreadablethingandFORMATREADABLETHING, becauseDuckDBidentifiers are case-insensitive.The rule was justified as avoiding "catalog issues"; that test disproves it. Letters of either case are now accepted. Everything that would genuinely break is still rejected — a name needing quotes in SQL (
my-func,my func,my.func), one starting with a digit, one over 256 characters, one with an interior NUL.snake_caseremains the right convention and is documented as one, rather than enforced as a rule that blocks a legal name.The same relaxation applies to
AggregateFunctionBuilder,TableFunctionBuilderandSqlMacroparameter names, which share the validator.A regression test now runs
validate_function_nameover every function induckdb_functions()(746 of them) andvalidate_extension_nameover every entry induckdb_extensions(), asserting that everything identifier-shaped is accepted and every operator is not. That is how the defect was found.
A documented convention that was not being followed
-
"Every
unsafeblock inside this crate has a// SAFETY:comment" was not true.clippy::undocumented_unsafe_blocksreports 180 blocks in the library. Most are inside anunsafe fnand merely forward that function's own documented contract —unsafe_op_in_unsafe_fnis denied crate-wide, so those blocks are required syntax rather than new assertions — but around forty were in safe functions, where the crate rather than the caller is asserting the invariant, and those had nothing.The claim is replaced with the convention actually worth following, and that convention is now met: every
unsafeblock in a safe function carries a// SAFETY:comment. Auditing them also turned up three comments that described the wrong thing — twoduckdb_freecalls and aduckdb_destroy_valueannotated as if they were uses of the enclosing handle; those now say which allocation they own and why, cross-referencingLESSONS.mdP11.
The scaffold generated a description.yml that would be rejected
-
repo.refwas generated asmain.DuckDB's community-extension documentation is explicit: "Provide the hash of the latest commit on the branch targeting stable asref". The repository builds exactly that revision and signs the result, so a branch makes the build unreproducible. Of the 43 published extensions sampled, 41 pin a full 40-character hash and two pin a tag; none uses a branch.ScaffoldConfiggainsgit_ref, defaulting toREF_PLACEHOLDER("REPLACE_WITH_COMMIT_HASH") — deliberately not a valid revision, so it cannot be submitted by accident the waymainsilently could. The generated file carries a comment saying why, and a commented-outref_next. -
DescriptionYmlsilently droppedrepo.ref_next. It is a documented field: while a newDuckDBrelease is being prepared, the community repository tests an extension against both the latest stable release andmain, andref_nextnames the revision compatible withmain. Now parsed intogit_ref_next, empty when absent. -
The generated
description.ymlhad nodocs:section. All 43 published extensions have one — it is what renders on the community-extensions documentation site. The scaffold now emitshello_worldandextended_descriptionstubs.
Two more documented behaviours that were not the real ones
-
ClientContext::catalogdocumented an empty name as "the default catalog";DuckDBrejects it outright.duckdb_client_context_get_catalogstarts withif (!context || !name || strlen(name) == 0) return nullptr;— an empty string is the one value guaranteed to fail. The catalog of an in-memory database is namedmemory; a file database's is the file's stem. The doc now says so, along with the otherNonecase the C API imposes and quack-rs never mentioned:DuckDBcheckstransaction.HasActiveTransaction(), so this works inside a callback but not on an idle auto-commit connection. Both verified against 1.5.5 by a live test. -
ClientContext::config_optionaborts the process when asked for a setting that does not exist — on aDuckDBbuilt with debug assertions.duckdb_client_context_get_config_optioncallsTryGetCurrentSetting(...).GetScope()without first checking the lookup succeeded, andGetScope()assertsscope != SettingScope::INVALID. A releaseDuckDBcompiles the assertion out and the function's owndefault:arm returnsNULLas documented, so this never reproduces for end users and always reproduces in a test suite linking a debugDuckDB.This is a
DuckDBdefect, not a quack-rs one, but it makes the obvious "does the user have this setting?" probe unsafe. Documented on the method with the source lines, recorded asLESSONS.mdP12, and the abort-free alternative given:SELECT count(*) FROM duckdb_settings() WHERE name = ?.
Documentation claimed a bridge that cannot exist
-
The
secretsmodule described itself as bridging intoDuckDB's secrets system. There is no such bridge, and there cannot be. The extension C API has zero secret functions — not oneduckdb_secret_*among the 546 slots ofduckdb_ext_api_v1inDuckDB1.5.5. An extension cannot askDuckDBfor a credential through the C API at all.The only route is the
duckdb_secrets()table function, andDuckDBredacts sensitive fields there. Verified against 1.5.5:CREATE SECRET s (TYPE s3, KEY_ID 'AKIAEXAMPLE', SECRET 'super-secret-value'); SELECT secret_string FROM duckdb_secrets(); -- ...;key_id=AKIAEXAMPLE;secret=redactedThe module docs now say this plainly, and say what
SecretsManageractually is: a trait over the extension's own credential source, carrying the redactingDebug, zeroize-on-drop and absentPartialEqthat credential handling needs, rather than a route toDuckDB's store.The zeroize claim is also narrowed to what is true: it covers the buffers a
SecretEntryowns, not aStringthe caller still holds or one aStringabandoned when it grew.
The description.yml validator rejected 84% of real extensions
-
parse_description_ymlrejected 36 of the 43 published community extensions it was tested against. Its entire purpose is to tell an author their submission is valid before they open a PR, and it told almost everyone they were invalid. Four independent causes:-
requires_toolchainswas treated as required. It is not — only 14 of the 43 set it, and the community-extensions documentation does not list it as required. This alone rejected half the corpus. It is now optional;validate_rust_extensionstill requiresrustin it when present. -
YAML quotes were not stripped.
parse_kvdeliberately returned quoted values with their quotes and left stripping to each caller, and onlyexcluded_platformsdid. 12 of 43 files writeversion: '2025120401', so the parser saw'2025120401'— quotes included — and every version check failed on it.parse_kvnow unquotes, with a real balanced-quote check rather thantrim_matches, which would also eat""doubled""and a trailinga". -
validate_extension_versionimposed a formatDuckDBdoes not. It accepted only semver or a git hash; 11 of 43 published extensions use a date-based build id (2025120401).DuckDB's community-extension documentation specifies no version format at all — it says the descriptor carries "the version of the extension" and points at existing extensions as examples. The check is now what would actually break something: empty, over 64 characters, or containing anything outside[A-Za-z0-9._+-](whitespace, path separators, control characters).classify_extension_versionis unchanged —DuckDB's three-tier stability scheme is documented and is strict, and that function is where it belongs. -
windows_amd64_rtoolswas rejected. It is the R-tools Windows build (DuckDBPlatform()emits it underDUCKDB_PLATFORM_RTOOLS), it is not in the distribution matrix, and 14 of 43 published extensions exclude it.DUCKDB_PLATFORMSnow also accepts it and the four group names (linux,osx,wasm,windows— the top-level keys ofdistribution_matrix.json), while the newDUCKDB_CI_PLATFORMSkeeps the matrix-derived list the guard script checks. Empty segments from a trailing;— which five real files have — are skipped rather than reported as a platform named"".
All 43 now parse, with every name matching its directory.
-
-
Prose in the
docs:section was parsed as metadata. The scan was flat, so aversion:orlicense:line insidedocs.extended_description— free-form prose in 42 of the 43 files — silently overwrote the extension's real values. Demonstrated: alicense: FAKE-LICENSEline inside a documentation block made a valid file fail validation, and the same mechanism could have made an invalid one pass. The parser is now section-aware (onlyextension:andrepo:are read) and understands block scalars:key: |andkey: >bodies are captured as the field's value — literal blocks keeping line breaks, folded blocks joined — instead of being scanned for mappings. -
Three doc examples showed indented YAML that was not indented. A
\line-continuation in a Rust string literal eats the following line's leading whitespace, sodescription.ymlexamples inparse_description_yml,validate_description_yml_strandvalidate_rust_extensionwere parsing fully-unindented text. They only passed because the parser ignored indentation; making it section-aware exposed them. Rewritten as real multi-line literals.
Validators were giving wrong answers
-
The DuckDB platform list was stale in both directions.
validate::platformrejectedlinux_amd64_muslandlinux_arm64_musl— real, currently-built targets — so an extension that legitimately cannot support musl could not declare it. And it acceptedlinux_amd64_gcc4, whichDuckDBretired:DuckDBPlatform()induckdb/common/platform.hppnow raises a compile error for the legacy CXX ABI rather than emitting a_gcc4suffix, and it is absent from the distribution matrix. Excluding it was a silent no-op.The list is now derived from
config/distribution_matrix.jsoninduckdb/extension-ci-tools— the file the community-extensions build actually reads — andscripts/check-platform-table.pyplus a CI job fail when the two diverge. AddsDUCKDB_OPT_IN_PLATFORMSandis_opt_in_platform, because three of the twelve (linux_amd64_musl,linux_arm64_musl,windows_arm64) are only built on request, so excluding one of those is also a no-op.linux_amd64_gcc4gets a targeted error saying what happened to it, rather than "not a recognized DuckDB build target". -
validate_spdx_licenseclaimed valid licenses did not exist.COMMON_SPDX_LICENSESis a 42-entry shortlist of a 733-entry registry, but the rejection message read "is not a recognized SPDX identifier" — false forCC0-1.0,Python-2.0,BSD-4-Clauseand roughly 690 others. It now says the identifier is not on quack-rs's shortlist and points at the registry.Every entry was checked against
spdx/license-list-data: all 42 are real and none are deprecated.scripts/check-spdx-list.pyand a CI job keep it that way, and flag any newly-added identifier that is not OSI-approved (SSPL-1.0is listed and deliberately is not). The list is now sorted, with a test keeping it so. Also fixes the module doc, which called the fieldextension.licence; realdescription.ymlfiles — and quack-rs's own parser — uselicense.
Silent data corruption
-
The
UUIDaccessors disagreed about which 128 bits they meant, and the documentation said they agreed. AUUIDcolumn is physically aHUGEINT, butDuckDBstores it with the top bit flipped so that signed integer ordering matches UUID string ordering (BaseUUID::FromUHugeintinsrc/common/types/uuid.cppsubtracts 2^63 from the upper half). So:Accessor Returned For '11111111-…'::UUIDVectorReader::read_uuid(old)raw storage 0x9111…Value::as_uuidtextual bits 0x1111…Both were documented as "matching" the other. Handing one to the other — the obvious thing to do when a table function reads a
UUIDand builds aValuefrom it — silently changed the UUID's first hex digit.read_uuid/write_uuid(onVectorReader,VectorWriter,StructReader,StructWriterand both mocks) now apply the flip and take/returnu128textual bits, the same convention asValue::uuid/Value::as_uuidand every RustUuidtype.Value::uuid/as_uuidmove fromi128tou128for the same reason. The type change is deliberate: it turns a silent behaviour change into a compile error at every affected call site.read_i128/write_i128still read and write the raw storage, and the newvector::uuid_from_storage/vector::uuid_to_storageconvert between the two. Pinned by a live test that asserts the raw storage and the textual bits really do differ, so the conversion cannot quietly become a no-op.
Wrong results and unloadable builds
-
ChunkWriterno longer hardcodes a 2048-row capacity.DuckDBcan be built with a differentSTANDARD_VECTOR_SIZE, which is exactly why the C API exposesduckdb_vector_size(); assuming 2048 against a smaller build overruns the output vectors.ChunkWriter::newnow reads the running engine's value.ChunkWriter::newandDataChunk::into_chunk_writerare consequently no longerconst fn. -
The scaffold produced an extension
DuckDBrefuses to load. The generatedMakefilesetDUCKDB_PLATFORM_VERSION, whichextension-ci-toolsdoes not read, alongsideUSE_UNSTABLE_C_API=1.TARGET_DUCKDB_VERSIONtherefore fell back to itsv0.0.1default and the binary was stampedC_STRUCT_UNSTABLE/v0.0.1, whichDuckDBrejects with "The file was built specifically for DuckDB version 'v0.0.1'". The generatedMakefilenow setsEXTENSION_NAME(notEXT_NAME, whichbase.Makefileignores),TARGET_DUCKDB_VERSIONandUSE_UNSTABLE_C_APIfrom the newScaffoldConfigfields, and defines theall/configure/debug/release/test/cleantargets its own README and CI invoke. -
The scaffold generated
panic = "abort", which makes thecatch_unwindinscalar_callback!,table_scan_callback!and the extension entry point inert — so any panic in extension code killed the wholeDuckDBprocess instead of surfacing as a SQL error. Now generatespanic = "unwind". -
The scaffold pinned
quack-rs = "0.13"regardless of the generating crate's version. It now tracks the current major.minor. -
A freshly scaffolded project failed its own generated CI.
cargo clippy --all-targets -- -D warnings(which the generated workflow runs) rejected the generatedsrc/lib.rsforclippy::redundant_closureandsrc/wasm_lib.rsforspecial_module_name. Both are fixed; a newscaffold-e2eCI job builds the generated project, stamps its metadata footer, loads it into a realDuckDB, asserts the query result, and runs the generated lint gate. -
The generated CI referenced a nonexistent action (
duckdb/duckdb-build@v1) and ranmake testwithoutmake configure/make release, so it could not have passed. Replaced with a workflow that configures, builds and tests throughextension-ci-tools. -
The extension entry point ran user registration code without
catch_unwind. A panic in a registration closure unwound to theextern "C"entry point, aborting the process; it now becomes aLOADerror. Anapi_versioncontaining an interior NUL is also rejected up front instead of panicking insidelibduckdb-sys.
Behaviour documented after verification
Value::display_stringrenders a SQL literal, not display text:Value::varchar("hello")gives'hello'andValue::date(0)gives'1970-01-01'::DATE. Now documented with a table, since silently getting quotes and a cast suffix in a diagnostic is surprising.Value::as_strtruncates at an interior NUL, becauseduckdb_get_varcharreturns a NUL-terminatedchar *.DuckDBstores the full bytes; only this read path is limited. Documented on bothas_strandValue::varchar, and pinned by a test.
Documentation
-
The crate documented an "architectural limitation" that does not exist.
Cargo.toml,testing::in_memory_dband the book all stated thatVectorReader,VectorWriterandConnection::register_*"cannot be called incargo test" because they route through the dispatch table. Opening anInMemoryDbpopulates that table for the whole process, after which the entire C API — registration included — works. The newtests/ffi_roundtrip.rsregisters real scalar functions and round-trips every vector type through SQL: every integer width at its extremes,HUGEINT/UHUGEINTat theirs, floats and NaN, strings across the 12-byte inline/pointer boundary and multi-byte UTF-8, blobs containing NUL and non-UTF-8 bytes, all temporal types cross-checked againstDuckDB's own rendering,UUID,INTERVAL's three fields,DECIMALat all four physical widths, NULL in and out, multi-chunk scans, and a panicking callback surfacing as a SQL error. -
Documentation examples pinned
quack-rs = "0.13".
Added
Live tests for every previously untested C API path
-
Copy functions and replacement scans had no live tests at all. Between them they had 19 unit tests, none of which registered anything against a running
DuckDB— which is how a heap-corrupting free survived in a shipped API. Both now have end-to-end coverage:- A
COPY ... TO 'f' (FORMAT my_format)over 5000 rows, threading bind data and global state through all four lifecycle phases, asserting the sink saw every row and that both destructors ran exactly once (a leak or a double free is invisible without counting). - A replacement scan rewriting
SELECT * FROM '10.myfmt'into a table function call, plus the decline path — an identifier the callback ignores must still reachDuckDB's own error handling — and a panicking scan surfacing as a SQL error.
- A
-
Six more modules had unit tests but no live registration: scalar bind/init/local state,
Expression::fold, catalog lookup, config options, selection vectors and the instance cache. All now run against a realDuckDB, which turned up two more documentation defects (below) and confirmed the rest. -
copy_bind_callback!,copy_global_init_callback!,copy_sink_callback!andcopy_finalize_callback!. Every other callback kind had a panic-safe macro; the four copy-function phases did not, so a panic in one of them had nothing to catch it. Each routes the message through that phase's ownduckdb_copy_function_*_set_error. -
TypeId::try_from_duckdb_type— returnsOption<TypeId>instead of panicking on a type value this build does not know. Extensions routinely meet these: a column of a type added in a newerDuckDB, or a 1.5.x type reaching a build withoutduckdb-1-5.from_duckdb_typestill panics and now documents that callbacks should not use it. -
Fallible
LogicalTypeconstructors that previously panicked on an interior NUL in a caller-supplied name:try_struct_type_from_logical,try_union_type,try_union_type_from_logical,try_enum_type,try_set_alias. -
entry_point!/entry_point_v2!accept an optionalAbiPolicyas their second argument;init_extension_with_policy/init_extension_v2_with_policyare the function-level equivalents. -
examples/scaffold_to_dir.rs— writes a scaffolded project to disk, used by the newscaffold-e2eCI job.
Panic safety
-
A panic-safe wrapper macro for every callback kind. Only
scalar_callback!andtable_scan_callback!existed, so the other six kinds — table bind, table init, aggregate update/combine/finalize/destroy, cast, and replacement scan — were unguarded, and a panic in any of them aborted theDuckDBprocess. The aggregate ones are the worst case: they run on worker threads, so the abort comes from a thread the user never sees. New macros:table_bind_callback!,table_init_callback!,aggregate_update_callback!,aggregate_combine_callback!,aggregate_finalize_callback!,aggregate_destroy_callback!,cast_callback!,replacement_scan_callback!. Each routes the panic message to that callback kind's ownset_error;cast_callback!also returnsfalsesoTRY_CASTyields NULL. The aggregate destructor has no error channel in the C API, so its panic is caught and dropped — leaking beats aborting during query teardown. Verified end-to-end: a panicking aggregateupdateand a panicking cast both surface as SQL errors and leave the connection usable. -
The two existing macros now share
callback::panic_messageandcallback::message_to_c_stringwith the new ones. The latter replaces an interior NUL rather than dropping the diagnostic, which the oldif let Ok(c_msg) = CString::new(msg)silently did. -
TypedTableFunctionBuilderreported every panic as the same fixed string. It now includes the payload, so the user learns which assertion failed. -
Deprecated
FfiBindData::get_from_bind, which always returnedNoneand always will:DuckDBexposes noduckdb_bind_get_bind_data. Being safe and returningOption, it silently sentif let Some(..)down the wrong branch.
Capabilities
-
ListBuilderforLISTandMAPoutput vectors.duckdb_list_vector_reservetakes a total capacity and reallocates the child vector when it grows, so aVectorWriterobtained beforehand is left dangling. That makes the natural "reserve as you go, keep one writer" loop a use-after-free.ListBuilderre-fetches the child writer after every reserve, tracks the running offset, writes each parent{offset, length}entry, and grows geometrically so building a list is not quadratic.push_map_rowdoes the same forMAP. It also refuses capacities aboveMAX_LIST_CHILD_CAPACITY(duckdb::DConstants::MAX_VECTOR_SIZE), above whichDuckDBthrows a C++ exception that its own C API does not catch — an exception unwinding into Rust would be undefined behaviour. Covered by tests building 2000 lists and 1500 maps of varying length through real SQL. -
Valuegained the extractors and constructors it was missing. A table function declared with aTIMESTAMPorLISTparameter handed the bind callback aduckdb_valuethat could only be read viaas_str()and reparsed. Addsas_date,as_time,as_time_tz,as_timestamp,as_timestamp_tz,as_timestamp_s/ms/ns,as_interval,as_uuid,as_decimal,as_u128,list_len/list_child/list_items,struct_child,map_len/map_key/map_value, and the constructorsboolean,bigint,double,date,timestamp,varchar,uuid,null_value. -
querymodule — running SQL from inside an extension. The C API has everything needed (duckdb_query,duckdb_prepare,duckdb_bind_*,duckdb_fetch_chunk) and it is all in the stable prefix, but each handle has adestroythat must run exactly once, including on error paths.QueryResult,OwnedDataChunk,PreparedStatementandOwnedConnectionare RAII wrappers for those;Connectiongainsquery,execute,prepareandopen_connection.OwnedConnectioncovers the case the borrowed registration connection cannot: aduckdb_connectionholds its own reference to the database instance, so one opened during load stays valid afterwards — for a callback or a background thread. Verified by a test that closes theduckdb_databasehandle and keeps querying. -
datetimemodule — calendar conversions.DATE,TIMEandTIMESTAMPmove through vectors as raw integers; turning those into year/month/day meant reimplementing the proleptic Gregorian calendar andDuckDB's infinity sentinels.DuckDBalready exposes the conversions in the stable API, so this wraps them:date_from_days/date_to_days,time_from_micros/time_to_micros,timestamp_from_micros/timestamp_to_micros,time_tz_bits/time_tz_from_bits, the fouris_finite_*predicates, andHUGEINT/UHUGEINT/DECIMAL↔f64.Also exports the exact sentinel values as constants.
-infinityis-i32::MAX/-i64::MAX, noti32::MIN/i64::MIN—i32::MINis an ordinary finite date, and treating it as infinity would silently drop real rows. -
VectorWritercaches its validity bitmap.set_nullcalledduckdb_vector_ensure_validity_writable+duckdb_vector_get_validityon every row; both are now resolved once per vector (2 FFI calls instead of 4096 for an all-NULL 2048-row vector). Addsset_null_rangefor the batched case. -
Vector accessors for the remaining physical layouts:
write_u128/read_u128(UHUGEINT),write_decimal/read_decimal(which selecti16/i32/i64/i128from the declared width the wayDuckDBdoes),write_time_tz/read_time_tz, andTIMESTAMPTZ/TIMESTAMP_S/TIMESTAMP_MS/TIMESTAMP_NSaccessors.VectorReader::containsbounds-checks an index against the row count. -
Callback signature aliases are re-exported at their module roots:
scalar::ScalarFn(plusScalarBindFn/ScalarInitFnunderduckdb-1-5) andaggregate::{StateSizeFn, StateInitFn, UpdateFn, CombineFn, FinalizeFn, DestroyFn}, matching whattablealready did. -
The prelude re-exports
AbiPolicy, thedatetimetypes and thequerytypes. -
Registrar::register_config_option— the trait already covered scalar, scalar set, aggregate, aggregate set, table, SQL macro, cast and copy functions, but not config options, so an extension registering one could not have its whole registration closure exercised throughMockRegistrar. Added, withconfig_option_names/has_config_optionon the mock. -
secrets::list_duckdb_secrets— reads the secret metadataDuckDBdoes expose, viaduckdb_secrets(): name, type, provider, persistence, storage, scope prefixes and the redactedsecret_string. Enough to pick a scope, warn that a required secret is missing, or choose a provider. It returns aDuckDbSecretInfo, deliberately not aSecretEntry, so nothing suggests it carries credentials. A live test asserts both halves: the metadata comes through, and the credential provably does not. -
The appender is no longer behind
duckdb-1-5, and gained the row-at-a-time API it never had.duckdb_appender_*occupies slots 281–291 and 330–356 — the frozen stable prefix, unchanged since v1.2.0 — yet the whole module was gated onduckdb-1-5, whose wrappers live in the unstable region. Using the appender therefore forced an extension onto the version-pinned unstable ABI, for functionality that has been portable for four minor releases. Only three methods actually need 1.5 and stay gated:error_data,clearandappend_default_to_chunk.The 24 row-at-a-time functions were wrapped for the first time:
append_bool/_i8/_i16/_i32/_i64/_i128/_u8/_u16/_u32/_u64/_u128/_f32/_f64/_str/_bytes/_date/_time/_timestamp/_interval/_value/_null/_default,end_row,column_count,column_type,add_column,clear_columns, and arow(|row| …)helper that callsend_rowfor you. Previously the only way to insert a row was to build a wholeDataChunk.Three details that are easy to get wrong and are handled here:
append_strusesduckdb_append_varchar_length, so interior NUL bytes survive; that function narrows its length touint32_twith an unchecked cast inDuckDB's release builds, so longer strings are refused rather than truncated; andduckdb_append_valuedereferences its argument with no null check, so a nullValuehandle is refused. Covered by live tests that append every scalar type at its extremes, 5000 rows across several vectors, a short row, a constraint violation surfacing atclose, and aDEFAULT-filled column subset.New
appender::AppendErrorisErrorDatawithduckdb-1-5andExtensionErrorwithout, so enabling the feature upgrades the error type in place without changing any method's shape — existingduckdb-1-5code is unaffected. -
table_descriptionis no longer behindduckdb-1-5either. Slots 292–297 are stable; onlycolumn_countandcolumn_typeare 1.5 additions and stay gated. AddsTableDescription::with_catalog(duckdb_table_description_create_ext, for tables in another catalog) andcolumn_has_default(duckdb_column_has_default) — the latter being the only way to know whetherAppender::append_defaultwill succeed. -
FileHandlegained the looping I/O helpers, andsize/tellbecame fallible.duckdb_file_handle_readandduckdb_file_handle_writereturn "the number of bytes actually read/written" — a single call can come up short, which overhttpfsis routine rather than theoretical. Addsread_exact,read_to_endandwrite_all, which loop.size()andtell()changed fromi64toResult<u64, ErrorData>: the C API signals failure with a negative return, and the previous signature madehandle.size().max(0) as usize— silently treating an error as an empty file — the obvious thing to write. It was in this crate's own documentation. -
Value::type_id().Valuehad fortyas_*accessors and no way to ask what the value actually is, so reading aVARCHARwithas_i64()returned garbage rather than an error. Wrapsduckdb_get_value_type(stable prefix, slot 137, unchanged since v1.2.0), returningNonefor a null handle or a type id newer than this build knows. -
Every public type implements
Debug. 58 of them did not, which is Rust API guideline C-DEBUG and not cosmetic:Result::unwrap,Result::expect_err,assert_eq!, and#[derive(Debug)]on any downstream struct storing a quack-rs type all fail to compile without it.LogicalTypeandValueprint decoded state (type id, alias,DECIMALwidth/scale,DuckDB's own rendering) rather than a pointer; builders printset/unsetper callback, which is the question you have whenregisterreports a missing function;WarningCollectorusestry_lockso printing can neither block nor deadlock.missing_debug_implementationsis now enabled crate-wide, and CI's-D warningsmakes it an error.testing::InMemoryDbwas a 59th, only visible once the lint ran withbundled-teston.
Changed
MSRV
-
MSRV lowered 1.87.0 → 1.86.0. DuckDB's reusable
_extension_distribution.yml— the workflow the community-extensions repository builds every extension with — pinsdtolnay/rust-toolchain@… # 1.86.0for the WebAssembly job. quack-rs required 1.87.0, so Cargo refused, and no quack-rs extension could be built forwasm_mvp/wasm_eh/wasm_threadsby the official pipeline — despite the crate advertisingwasm32-unknown-emscriptensupport since 0.14.0.The entire 1.87 requirement was five
const fnaccessors callingVec::len(stabilised as const in 1.87). None can be reached in a const context —MockVectorWriter,StructReaderandStructWriterare all built at runtime — so droppingconstcosts nothing. 1.86.0 is now the floor for the library, its dev-dependencies (criterionneeds 1.86) and thehello-extexample, all verified.New
scripts/check-msrv-vs-duckdb-ci.pyand a CI job re-derive DuckDB's pinned toolchains from that workflow and fail if the MSRV creeps back above them. -
Breaking:
ScaffoldConfiggainstarget_duckdb_versionanduse_unstable_c_api.ScaffoldConfignow implementsDefault, so existing struct literals can add..ScaffoldConfig::default().generate_scaffoldrejects combinations that produce an unloadable binary — aC_STRUCTbuild claiming aDuckDBrelease as its-dv, or aC_STRUCT_UNSTABLEbuild claiming the C API version.
CI / tooling
- New
abi-tablejob:scripts/check-abi-table.pyverifiessrc/abi.rs's layout table against every upstreamDuckDBrelease header. - New
abi-guardjob: builds an extension againstDuckDB1.5.0's header layout, stamps itC_STRUCT, and asserts the load is refused with a layout diagnostic — a regression test for the corruption described above. - New
scaffold-e2ejob (see above). extension-loadnow stamps a real metadata footer and asserts query results rather than grepping the log for the word "error"; loading a bare.sobypassedDuckDB's metadata validation entirely.
0.15.0 — 2026-07-16
Added
Value::as_blob()for copying arbitrary binary data from aduckdb_value. (Thanks @adonm.)
Fixed
VectorReader::read_blob()now preserves non-UTF-8 bytes instead of returning an empty slice. (Thanks @adonm.)
Changed
- Dev/CI DuckDB bumped to 1.5.4 —
libduckdb-sys/duckdb1.10503.1 → 1.10504.0 in the root lockfile, thehello-extexample lockfile, and thebundled-test-prebuiltCI download (v1.5.3→v1.5.4). 1.5.4 is a bugfix release in the 1.5.x line; its C extension API version is unchanged (v1.2.0, verified fromduckdb_extension.h), soDUCKDB_API_VERSIONis unchanged and the publiclibduckdb-sysdependency range (>=1.4.4, <2) is untouched — downstream consumers are unaffected.
Security
crossbeam-epoch0.9.18 → 0.9.20 (root lockfile), resolving RUSTSEC-2026-0204 (invalid pointer dereference in thefmt::Pointerimpl). Reaches the tree only as a dev-dependency viacriterion → rayon → crossbeam-deque.quinn-proto0.11.14 → 0.11.15 (root and example lockfiles), resolving RUSTSEC-2026-0185 (CVSS 7.5). Reaches the lockfiles vialibduckdb-sys → reqwest → quinn(feature-union only; the loadable-extension build never links it).
CI / tooling
- Refreshed SHA-pinned GitHub Actions via Dependabot:
actions/checkoutv6.0.2 → v7.0.0,codecov/codecov-actionv6.0.1 → v7.0.0,actions/cachev5.0.5 → v6.1.0, andactions/attest-build-provenancev4.1.0 → v4.1.1. Also bumped theccbuild-dependency 1.2.63 → 1.2.64.
0.14.0 — 2026-06-07
Added
wasm32-unknown-emscriptensupport (the DuckDB-WASM target). The crate no longer hard-rejects non-64-bit targets with a top-levelcompile_error!, and theduckdb_string_tpointer slot is read as au64then narrowed tousize— lossless on 64-bit, and on wasm32 it yields the low 4 bytes of the 8-byte slot (the upper 4 are zero padding in DuckDB's 16-byte layout). The full public API, including theduckdb-1-5-3surface,cargo checks forwasm32-unknown-emscripten; CI now guards this. (Thanks @killzoner.)bundled-test-prebuiltfeature — links a pre-built libduckdb instead of compiling DuckDB from C++ source, for a much faster test build. Supply the library viaDUCKDB_DOWNLOAD_LIB=1(libduckdb-sysdownloads the upstream release zip) orDUCKDB_LIB_DIR=...(a libduckdb tree you already have).bundled-testcontinues to compile DuckDB from source. (Thanks @killzoner.)InMemoryDb::open_unsigned()opens an in-memory database withallow_unsigned_extensions=true, allowing downstream extension crates toLOADtheir own locally-built (unsigned).duckdb_extensionartifact for integration testing. (Thanks @killzoner.)
Changed
duckdbis now a purely optional dependency, activated only bybundled-test/bundled-test-prebuilt. It is no longer a dev-dependency, and there is no defaultbundledfeature. As a result, a plaincargo test— and every downstream consumer'sCargo.lock— no longer pulls the DuckDB + arrow tree, and the default test build no longer compiles DuckDB.
Security
tar0.4.45 → 0.4.46 in both the root and example lockfiles, resolving GHSA-3pv8-6f4r-ffg2 ("PAX header desynchronization", Moderate).taris alibduckdb-sysbuild-dependency, so it appears in bothCargo.lockfiles and raised one Dependabot alert each — the two moderate alerts reported onmain. This advisory is published in the GitHub Advisory Database (GHSA) but not the RustSec database, socargo denydid not flag it; the new OSV scan below closes that gap.- Bumped
cc1.2.62 → 1.2.63 (which movesshlex1.3.0 → 2.0.1) and refreshed thecodecov/codecov-actionpin to v6.0.1.
CI / tooling
- Added an OSV / GHSA advisory scan to CI (
osv-scanner, pinned to v2.3.8 via a checksum-verified binary) covering bothCargo.lockfiles.cargo denyconsults only the RustSec database; OSV.dev aggregates GHSA and RustSec, so GHSA-only advisories (such as thetarone above) now fail CI alongside the existing cargo-deny gate.
0.13.0 — 2026-05-24
Added
New safe wrappers for the DuckDB 1.5.0+ C extension API, all gated behind the
duckdb-1-5 feature, plus a new duckdb-1-5-3 feature that surfaces the two
DuckDB 1.5.3 type-enum values. DuckDB 1.5.3's C extension function-pointer API
(version v1.2.0) is unchanged from 1.5.2; the one new C addition — the
DUCKDB_TYPE_VARIANT (41) type-enum value — is now exposed as TypeId::Variant
behind the duckdb-1-5-3 feature (see below). So the additions below mostly
expose 1.5.x capabilities the SDK had not previously wrapped rather than anything
new to 1.5.3 specifically.
error_datamodule —ErrorData, an RAII wrapper overduckdb_error_data(the structured error type returned by several 1.5 APIs). Carries aDuckDbErrorTypecategory and a message, and converts intoExtensionError. Adds the free functioncheck_valid_utf8, exposingDuckDB's own UTF-8 validator.expressionmodule —Expression, an RAII wrapper overduckdb_expression, withreturn_type,is_foldable, andfold. This closes a real gap:ScalarBindInfoalready returned a raw, unusableduckdb_expressionfromget_argument; the newScalarBindInfo::argumentreturns a safeExpression, so bind callbacks can inspect argument types and pre-fold constant arguments once at bind time.file_systemmodule —FileSystem,FileHandle,FileOpenOptions, andFileFlag: read and write files throughDuckDB's virtual file system (honouringhttpfs, in-memory files, and other registered file systems) instead of reaching forstd::fs.appendermodule —Appender: bulk row insertion (create, append aDataChunk, flush, close) plus the 1.5 additionsclear(revert buffered rows),error_data(structured errors), andappend_default_to_chunk.selection_vectormodule —SelectionVector: allocate and fill zero-copy row-index selection vectors.instance_cachemodule —InstanceCache: share one underlying database instance across repeated opens of the same path.Valuegainsdisplay_string(canonical string rendering of any value, viaduckdb_value_to_string) andTIME_NSaccessorsValue::time_ns/Value::as_time_ns(pairing with the existingTypeId::TimeNs).Cataloggainstype_name(the catalog's storage type, e.g."duckdb"or a storage extension's name).- All new public types are re-exported from the
preludebehind theduckdb-1-5feature. duckdb-1-5-3feature +TypeId::Variant/TypeId::Geometry— a new feature flag (duckdb-1-5-3, which impliesduckdb-1-5) exposes theDUCKDB_TYPE_VARIANT(41, added in DuckDB 1.5.3) andDUCKDB_TYPE_GEOMETRY(40) type-enum values asTypeId::VariantandTypeId::Geometry, with the matchingto_duckdb_type/from_duckdb_type/sql_name/Displaycoverage. It is a separate gate because these constants postdate theduckdb-1-5feature's 1.5.0 floor and requirelibduckdb-sys >= 1.10503.1; keeping them out ofduckdb-1-5preserves compatibility for consumers pinned to libduckdb-sys 1.5.0–1.5.2.ErrorDatais now a first-class error type — implementsstd::fmt::Displayandstd::error::Error, gains a structuredDebugimpl, and converts intoExtensionErrorviaFrom(alongside the existinginto_extension_error) so it propagates through?.DuckDbErrorTypenow implementsDisplay(backed by a newpub const fn as_str).TableDescription::as_raw()— exposes the raw handle, matching the accessor convention of the other 1.5 wrappers.
Changed
duckdb/libduckdb-sys1.10502.0 → 1.10503.1 (DuckDB 1.5.2 → 1.5.3) in both the workspace andexamples/hello-extCargo.lock. DuckDB 1.5.3 is a bugfix release (announcement); since the>=1.4.4, <2constraint already permitted it, the bundled fixes are picked up purely by the lock-file update with no source changes required for the bump itself.cc→ 1.2.62 in bothCargo.lockfiles — workspace (1.2.61 → 1.2.62, folding in Dependabot PR #89, thepatch-updatesgroup) andexamples/hello-ext(1.2.57 → 1.2.62, re-syncing the example lock's oldercc). Build-dependency; no API impact.- MSRV corrected to 1.87.0. The crate declared
rust-version = "1.84.1", butlibduckdb-sys(1.5.x line, a non-optional dependency) isedition = "2024"/rust-version = "1.85.1"— so quack-rs has in fact required Rust ≥ 1.85.1 since before this release (cargo +1.84.1 checkcannot even parse the manifest). The declared MSRV, the CIMSRVjob (now explicitly pinned withtoolchain: "1.87.0"so it genuinely gates instead of silently falling back to therust-toolchain.tomlstable channel), the release matrix, and all docs/badges are updated to 1.87.0 — a small headroom margin above the 1.85.1 floor.
Fixed
TypeId::from_duckdb_typeno longer panics on theduckdb-1-5type-enum values. It previously recognised only the base (1.4) values andpanic!ed on everything else — including theduckdb-1-5values (TIME_NS,ANY,BIGNUM/VARINT,SQLNULL,INTEGER_LITERAL,STRING_LITERAL). Because the publicLogicalType::get_type_id()calls it, inspecting such a type inside a bind callback could panic across the FFI boundary (Pitfall L3). It now maps every variant available in the active feature set (plus theduckdb-1-5-3GEOMETRY/VARIANTvalues when that feature is enabled).TableDescription'sDropnow null-checks the handle before destroying it, matching every other RAII wrapper in the crate.
Documentation
- New book section "DuckDB 1.5+ APIs" — dedicated guide pages for the
error_data,expression,appender,file_system,selection_vector, andinstance_cachemodules, wired intoSUMMARY.md. - Refreshed the reference docs (
docs/architecture.md,docs/ffi-reference.md, theTypeIdreference,CONTRIBUTING.md/book source trees) to cover the new modules, and updated the VARIANT/GEOMETRY entries inKnown Limitations,concepts/types.md, and theTypeIdreference to document the newduckdb-1-5-3gate (previously tracked as a follow-up). - Added
// SAFETY:comments to previously-undocumentedunsafeblocks in theget_client_contextaccessors (scalar,copy_function) andTableDescription::create, and SPDX headers tobenches/interval_bench.rsand the test submodule files — closing the last gaps against the crate's own "every file / everyunsafeblock" conventions. - Corrected the README install note (it claimed v0.11.0 was the latest published
crate; v0.12.1 was in fact already on crates.io) and bumped install-example
version references throughout the README, book, and scaffold template to
0.13.
CI
- docs.rs now builds with
duckdb-1-5-3([package.metadata.docs.rs]), so the feature-gated modules (appender,error_data,file_system, …) and the newTypeIdvariants render on docs.rs and the README's docs.rs links resolve. Previously docs.rs built the empty default feature set and omitted them. - CI exercises the
duckdb-1-5-3feature — the feature job now runscheck/test/clippyforduckdb-1-5-3alongsideduckdb-1-5, and theClippy (beta)anddocjobs useduckdb-1-5-3. - Fixed the
NightlyCI job silently running stable — the SHA-pinneddtolnay/rust-toolchainstep lackedwith: toolchain: nightly, so it fell back to therust-toolchain.tomlstable channel (the same class of bug previously fixed for the MSRV job). - Mutation testing scoped to testable code — DuckDB FFI-wrapper modules
whose methods require a live runtime (and whose tests are
bundled-test-gated or absent) are excluded fromcargo mutants, since their mutants can't be killed by unit tests. This extends the existing exclusion pattern to the 1.5.x wrappers —expression,file_system,appender,selection_vector,instance_cache,table_description, and the scalar/copy*Infoaccessors. Pure-logic code (e.g.DuckDbErrorType, theTypeIdconversions) stays in scope. The mutants feature set is bumped toduckdb-1-5-3.
0.12.1 — 2026-05-01
Security
Closes nine GitHub Dependabot alerts (two High, seven Low) split across
the workspace Cargo.lock and examples/hello-ext/Cargo.lock.
-
rustls-webpki0.103.10 → 0.103.13 — picks up the fix for three RustSec advisories reachable via thebundledDuckDB build's transitivereqwest→rustlschain:- RUSTSEC-2026-0098 (GHSA-965h-392x-2mh5) —
nameConstraintswith URI name restrictions were silently ignored instead of enforced. Patched in 0.103.12+; the URI-name path is not on the public Web PKI, so impact is limited to private-PKI consumers. - RUSTSEC-2026-0103 (GHSA-xgp8-3hg3-c2mh) — name-constraint enforcement accepted certificates asserting a wildcard subject name. Reachable only after signature verification and requires misissuance. Patched in 0.103.12+.
- RUSTSEC-2026-0104 — reachable panic when parsing certificate
revocation lists with a syntactically valid empty
BIT STRINGin theonlySomeReasonselement of anIssuingDistributionPointCRL extension. Affects only applications that use CRLs. Patched in 0.103.13+.
Neither path is exercised by
quack-rsitself, but the advisories tripcargo denyfor any downstream consumer that has not yet bumped, so shipping a release that resolves them is the path of least friction. - RUSTSEC-2026-0098 (GHSA-965h-392x-2mh5) —
-
rand0.9.2 → 0.9.4 / 0.8.5 → 0.8.6 — picks up the fix for RUSTSEC-2026-0097 (GHSA-cq8v-f236-94qc) —ThreadRngcould produce an aliased&mut BlockRng<ReseedingCore>(Stacked-Borrows UB) when a custom logger reenteredrand::rng()from inside a reseed at trace-level logging. Triggering the unsoundness requires a custom global logger that pulls fromrand::rng()while reseeding, which is not a patternquack-rsuses, but the advisory matches by version range so resolving it removes the alert noise. Patched on every affected line: 0.8.6+, 0.9.3+, 0.10.1+.
Changed
- Workspace
Cargo.lockbumps —cc1.2.59 → 1.2.61 (build-dep; no API impact)duckdb/libduckdb-sys1.10501.0 → 1.10502.0 (latest patch release; no API impact forquack-rs)rand0.8.5 → 0.8.6 (transitive viarust_decimal; security)rand0.9.2 → 0.9.4 (transitive viaproptestdev-dep; security)
examples/hello-extCargo.lockbumps —libduckdb-sys1.10501.0 → 1.10502.0rand0.9.2 → 0.9.4 (security)rustls-webpki0.103.10 → 0.103.13 (security; matches workspace)
CI
-
GitHub Actions pin updates —
actions/cachev5.0.4→v5.0.5actions/upload-artifactv7.0.0→v7.0.1actions/upload-pages-artifactv4.0.0→v5.0.0
All updates retain SHA-pinned references for supply-chain integrity.
-
New informational
Clippy (beta)job — runs the samecargo clippy --all-targets --features duckdb-1-5 -- -D warningsinvocation on thebetaRust toolchain. Markedcontinue-on-errorso a beta-only lint regression does not block the merge queue, but surfaces six weeks before the lint reachesstable. Originally added in response toclippy::map_unwrap_orgraduating tostablein Rust 1.95.0 and bitingsrc/warning.rsafter the toolchain rolled forward.
Fixed
clippy::map_unwrap_oronWarningCollector::len—self.warnings.lock().map(|w| w.len()).unwrap_or(0)rewritten asself.warnings.lock().map_or(0, |w| w.len()). Behaviour-preserving; fixesClippyandTest duckdb-1-5 featurejobs under Rust 1.95.0.clippy::map_unwrap_or_defaultonWarningCollector::snapshot(defensive) — same rewrite for the siblingmap(|w| w.clone()).unwrap_or_default()call. Caught proactively alongside the above; otherwise would have surfaced the next time the lint promotion round-trips throughpedanticornursery.
0.12.0 — 2026-04-09
Added
-
TypedTableFunctionBuilder<S>with closure-basedbind/scan— new high-level layer on top ofTableFunctionBuilderthat lets extensions register table functions via two safe Rust closures instead of hand-rolledunsafe extern "C" fntrampolines. Entry point isTableFunctionBuilder::with_state::<S, _>(|bind| Ok(S { ... })), followed by.scan(|state, chunk| { ... Ok(()) })and.build()?to recover a fully configuredTableFunctionBuilderusable with anyRegistrar. Highlights:- The
bindclosure receives&BindInfo, declares the output schema, reads parameters, and returns the typed scan stateS: Send + 'static. - The
scanclosure receives&mut Sand aDataChunkfor the output chunk. Returning with chunk size zero signals end-of-stream. - Panics in user closures are caught via
std::panic::catch_unwindand surfaced throughduckdb_bind/init/function_set_error; the scan forces chunk size to zero on panic so the query terminates safely. - Scan state is carried from
bindthroughinitintoinit_dataso the scan callback can hold&mut Swithout extra ceremony. - Because
Sis only required to beSend, scans are serialised by callingset_max_threads(1). Extensions that need true multi-worker parallelism should continue to use the rawTableFunctionBuilderwithlocal_init. - Re-exported from the prelude as
TypedTableFunctionBuilder.
This is proposal A from the duck_net "quack-rs enhancements" list and eliminates the raw bind/init/scan trampolines that every FFI-heavy extension would otherwise write by hand.
- The
-
ExtensionError: additionalFromimpls —From<std::io::Error>,From<std::ffi::NulError>, andFrom<std::fmt::Error>allow the?operator to propagate common error types directly inregister_all()without.map_err(). This eliminates the need forpanic!()when operations like tokio runtime allocation fail during extension initialization. -
tlsmodule —TlsConfigProvidertrait for type-erased TLS client configuration injection. HTTP-capable extensions (e.g.,duck_net) implement this trait to supply custom CA bundles, client certificates for mTLS, or restricted cipher suites through a uniform interface. Usesstd::any::Anysoquack-rshas no dependency on any specific TLS library. Security hardened:client_config()returnsResultfor fallible config creation,accepts_invalid_certs()andmin_tls_version()enable security auditing,config_type_name()allows safe pre-downcast verification, andaudit_tls_provider()integrates with thewarningmodule to automatically flag CWE-295 (cert validation bypass) and CWE-327 (deprecated TLS versions). IncludesTlsVersionenum withis_deprecated()andOrdordering. -
warningmodule — structured security warning API withExtensionWarning,WarningSeverity(Info/Low/Medium/High/Critical), andWarningCollector. Extensions that touch external resources emit warnings with machine-readable codes and optional CWE identifiers.WarningCollectoris thread-safe (Mutex-backed) and supportsemit(),snapshot(),drain(), andclear(). -
secretsmodule —SecretsManagertrait andSecretEntrytype for bridging into DuckDB's nativeCREATE SECRETstorage. Extensions implementSecretsManagerto provideget_secret(),list_secrets(), andremove_secret()through a safe Rust interface.SecretEntryuses a builder pattern withwith_provider(),with_scope(), andwith_field(). Security hardened:Debugredacts field values,Dropzeroizes sensitive data viawrite_volatile,PartialEqintentionally omitted to prevent timing side-channels, and fields are private with accessor methods. -
StructWriter::child_list_vector(field_idx)— semantic alias forchild_vector()that makes the intent clear when a struct field has LIST type. Returns the rawduckdb_vectorhandle for use withListVectormethods (reserve,set_entry,set_size,child_writer, etc.). -
Prelude additions —
TlsConfigProvider,ExtensionWarning,WarningSeverity,WarningCollector,SecretEntry,SecretsManagerre-exported fromquack_rs::prelude.
0.11.0 — 2026-03-30
Added
-
StructWriter::child_vector(field_idx)— returns the rawduckdb_vectorhandle for a struct field, enablingListVector/MapVector/ArrayVectoroperations on nested complex types without raw FFI calls. -
StructReader::child_vector(field_idx)— read-side counterpart for accessing nested complex type fields within STRUCT input vectors. -
ChunkWriter::vector(col_idx)— rawduckdb_vectoraccess for complex column types (LIST, MAP, ARRAY) from within aChunkWriter. -
ChunkWriter::column_count()— returns the number of columns in the chunk without needing a separateDataChunk. -
VectorWriter::set_valid(row)— marks a row as non-NULL, undoing a previousset_null()call. Callsensure_validity_writableautomatically. -
StructWriter::set_valid(row, field_idx)— batched version ofVectorWriter::set_valid()for STRUCT fields. -
ReplacementScanInfo::add_parameter_raw(value)— adds anyduckdb_valueas a parameter to a replacement scan redirect, enabling non-VARCHAR parameter types (INTEGER, BIGINT, BOOLEAN, etc.). -
ReplacementScanInfo::add_i64_parameter(value)— convenience method for adding BIGINT parameters to replacement scan redirects. -
ReplacementScanInfo::add_bool_parameter(value)— convenience method for adding BOOLEAN parameters to replacement scan redirects.
Changed
table_scan_callback!error reporting — the macro now extracts the panic message and reports it to DuckDB viaduckdb_function_set_errorbefore setting chunk size to 0. Previously, panics silently ended the stream with no error message visible to the user.
0.10.0 — 2026-03-29
Added
-
StructWriter(vector::struct_writermodule) — batched, typed writer for STRUCT output vectors. Pre-createsVectorWriters for all fields at construction, then exposeswrite_bool,write_varchar,write_i64,write_date,write_timestamp,write_time,write_blob,write_uuid,set_null, etc. Eliminates ~120 rawduckdb_struct_vector_get_childcalls across typical extensions. -
StructReader(vector::struct_readermodule) — batched, typed reader for STRUCT input vectors. Read-side counterpart toStructWriterwithread_bool,read_str,read_i64,read_date,read_timestamp,read_blob,read_uuid,is_valid, etc. -
ChunkWriter(chunk_writermodule) — auto-sizing chunk writer for table function scan callbacks. Tracks rows vianext_row()and automatically callsduckdb_data_chunk_set_sizeonDrop, preventing forgotten-set-size bugs. -
scalar_callback!/table_scan_callback!macros (callbackmodule) — wrapunsafe extern "C"callbacks withstd::panic::catch_unwind, preventing undefined behaviour from panics unwinding across the FFI boundary. Scalar errors are reported viaduckdb_scalar_function_set_error; table scan panics set chunk size to 0 (end of stream). -
Valueextraction methods —as_i8(),as_i16(),as_u8(),as_u16(),as_u32(),as_u64(),as_i128()covering every DuckDB integer type viaduckdb_get_int8/int16/uint8/uint16/uint32/uint64/hugeint. Plusas_str_or(),as_str_or_default(), and_or(default)null-safe variants for all types. -
VectorReader—read_date(),read_timestamp(),read_time(),read_blob(),read_uuid()semantic methods for DATE, TIMESTAMP, TIME, BLOB, and UUID column types. -
VectorWriter—write_date(),write_timestamp(),write_time(),write_blob(),write_uuid()semantic methods matching reader additions. -
DataChunkconvenience methods —struct_writer(col, fields),struct_reader(col, fields),struct_field_reader(col, field),into_chunk_writer()bridging to the newStructWriter,StructReader, andChunkWritertypes. -
ChunkWriter::struct_writer(col, fields)— convenience bridge toStructWriterfrom within aChunkWriter. -
MockVectorWriter—write_blob(),write_date(),write_timestamp(),write_time(),write_uuid()matching realVectorWriteradditions. -
MockVectorWriter/MockVectorReader—try_get_i8(),try_get_i16(),try_get_u8(),try_get_u16(),try_get_u32(),try_get_u64(),try_get_f32(),try_get_i128(),try_get_blob(),try_get_uuid()closing the type coverage asymmetry between mock and real vector types. -
MockVectorReaderconstructors —from_i8s(),from_i16s(),from_u8s(),from_u16s(),from_u32s(),from_u64s(),from_f32s(),from_i128s(),from_intervals(),from_blobs()for everyMockDuckValuevariant. -
MockDuckValue::Blob(Vec<u8>)— new variant for BLOB testing. -
Prelude additions —
StructReader,StructWriter,ChunkWriterre-exported.
Changed
-
TableDescription::column_type()now returnsOption<LogicalType>(RAII) instead of rawduckdb_logical_type, eliminating manual destroy calls by callers. -
Version references updated — all documentation, examples, scaffold templates, and book pages now reference
quack-rs = "0.10"(was"0.9").
Fixed
-
FFI callback panic safety — replaced 13
CString::new(...).expect(...)calls in FFI callback contexts (table/info, scalar/info, cast/builder, aggregate/info, copy_function/info, replacement_scan) with non-panickingstr_to_cstring()that truncates at interior null bytes. Fully honours the "no panics across FFI" design principle (Pitfall L3). -
Non-idiomatic
&mut { expr }syntax — replaced 8 instances in builderregister()methods and 1 in replacement scan with idiomatic&raw mut.
0.9.0 — 2026-03-29
Added
-
ValueRAII wrapper (valuemodule) — owned wrapper aroundduckdb_valuewith automatic cleanup viaDrop. Typed extraction methods:as_str(),as_i64(),as_i32(),as_f64(),as_f32(),as_bool(). Eliminates manualduckdb_destroy_valuecalls and prevents memory leaks in bind parameter extraction. -
DataChunkwrapper (data_chunkmodule) — ergonomic non-owning wrapper aroundduckdb_data_chunkwithreader(col),writer(col),size(),set_size(n),column_count(), andvector(col)methods. Eliminates rawduckdb_data_chunk_get_vector/duckdb_data_chunk_set_sizecalls in scan callbacks. -
VectorWriter::write_str(idx, value)— alias forwrite_varcharfor discoverability. Extension authors searching forwrite_strnow find it immediately. -
BindInfo::get_parameter_value(index)— returns an ownedValueinstead of a rawduckdb_value, preventing memory leaks. -
BindInfo::get_named_parameter_value(name)— same for named parameters. -
MapVector::key_writer(vector)/value_writer(vector)— createVectorWriterinstances for MAP key and value child vectors directly. -
MapVector::key_reader(vector, count)/value_reader(vector, count)— createVectorReaderinstances for MAP key and value child vectors. -
MockVectorWriter::write_str(idx, value)— alias forwrite_varcharmatching theVectorWriterAPI addition. -
Prelude additions —
Value,DataChunk, andValidityBitmapare now re-exported fromquack_rs::prelude.
Changed
- Version references updated — all documentation, examples, scaffold
templates, and book pages now reference
quack-rs = "0.9"(was"0.7").
0.8.0 — 2026-03-28
Added
-
LogicalType::from_raw(ptr)— construct aLogicalTypefrom an existing rawduckdb_logical_typehandle, taking ownership. -
LogicalTypecomplex type constructors —decimal(width, scale),array(element, size),array_from_logical(element, size),union_type(members),union_type_from_logical(members),enum_type(members). -
LogicalType_from_logicalvariants —struct_type_from_logical,list_from_logical,map_from_logicalacceptLogicalTypevalues for nested complex types that cannot be expressed as simpleTypeId. -
LogicalTypeintrospection methods (20 methods) —get_type_id,get_alias,set_alias,decimal_width,decimal_scale,decimal_internal_type,enum_internal_type,enum_dictionary_size,enum_dictionary_value,list_child_type,map_key_type,map_value_type,struct_child_count,struct_child_name,struct_child_type,union_member_count,union_member_name,union_member_type,array_size,array_child_type. -
TypeId::from_duckdb_type(raw)— reverse conversion from rawDUCKDB_TYPEC enum toTypeId. -
ScalarFunctionBuilder::extra_info(data, destroy)— attach arbitrary data to a scalar function, accessible viaduckdb_function_get_extra_infoin callbacks. -
ScalarOverloadBuilder::extra_info(data, destroy)— same for scalar function set overloads. -
AggregateFunctionBuilder::extra_info(data, destroy)— attach arbitrary data to an aggregate function. -
TableFunctionBuilder::param_logical(logical_type)— add a positional parameter with a complexLogicalType. -
TableFunctionBuilder::named_param_logical(name, logical_type)— add a named parameter with a complexLogicalType. -
CastFunctionBuilder::new_logical(source, target)— construct a cast builder usingLogicalTypevalues for complex source/target types. -
ScalarFunctionInfo— callback wrapper withget_extra_info(),set_error(), and (duckdb-1-5)get_bind_data(),get_state(). -
ScalarBindInfo(duckdb-1-5) — scalar bind callback wrapper withargument_count(),get_argument(),get_extra_info(),set_bind_data(),set_error(),get_client_context(). -
ScalarInitInfo(duckdb-1-5) — scalar init callback wrapper withget_extra_info(),get_bind_data(),set_state(),set_error(),get_client_context(). -
AggregateFunctionInfo— aggregate callback wrapper withget_extra_info()andset_error(). -
CopyBindInfo(duckdb-1-5) — copy bind callback wrapper withcolumn_count(),column_type(),get_extra_info(),set_bind_data(),set_error(),get_client_context(). -
CopyGlobalInitInfo(duckdb-1-5) — copy global init callback wrapper withget_bind_data(),get_extra_info(),get_file_path(),set_global_state(),set_error(),get_client_context(). -
CopySinkInfo(duckdb-1-5) — copy sink callback wrapper withget_bind_data(),get_extra_info(),get_global_state(),set_error(),get_client_context(). -
CopyFinalizeInfo(duckdb-1-5) — copy finalize callback wrapper withget_bind_data(),get_extra_info(),get_global_state(),set_error(),get_client_context(). -
BindInfo::get_parameter(index)— retrieve positional parameter value in table function bind callbacks. -
BindInfo::get_named_parameter(name)— retrieve named parameter value in table function bind callbacks. -
BindInfo::get_extra_info(),InitInfo::get_extra_info(),FunctionInfo::get_extra_info()— access extra info from table function callbacks. -
get_client_context()— available onBindInfo(table),ScalarBindInfo,ScalarInitInfo,CopyBindInfo,CopyGlobalInitInfo,CopySinkInfo,CopyFinalizeInfo. Returns aClientContextRAII wrapper. -
ArrayVector— helper for fixed-size array vectors withget_child(). -
vector_size()— returns the default DuckDB vector size (typically 2048). -
vector_get_column_type(vector)— returns theLogicalTypeof a vector. -
Prelude additions —
StructVector,ListVector,MapVector,ArrayVector,ScalarFunctionInfo,AggregateFunctionInfonow re-exported fromquack_rs::prelude.
Changed
-
CastFunctionBuilder::source()/target()now returnOption<TypeId>instead ofTypeId, returningNonewhen the builder was created vianew_logical(). This is a breaking change. -
CastRecord::source/targetfields changed fromTypeIdtoOption<TypeId>to match the builder change.
0.7.1 — 2026-03-27
Added
-
TypeId::Any— wildcard type for function overload resolution. Maps toDUCKDB_TYPE_ANYin the C API. Requiresduckdb-1-5feature. -
TypeId::Varint— variable-length arbitrary-precision integer. Maps toDUCKDB_TYPE_BIGNUMin the C API, exposed asVARINTin SQL. Requiresduckdb-1-5feature. -
TypeId::SqlNull— explicit SQL NULL type representing the type of a bareNULLliteral before type resolution. Maps toDUCKDB_TYPE_SQLNULLin the C API. Requiresduckdb-1-5feature. -
TypeId::IntegerLiteral— internal type for unresolved integer literals during overload resolution. Maps toDUCKDB_TYPE_INTEGER_LITERAL. Requiresduckdb-1-5feature. -
TypeId::StringLiteral— internal type for unresolved string literals during overload resolution. Maps toDUCKDB_TYPE_STRING_LITERAL. Requiresduckdb-1-5feature. -
MockVectorReader/MockVectorWritertests — 12 new tests coveringfrom_i32s,from_f64s,from_boolsconstructors, typed getters (i32,f64,bool),u16/i128/intervalround-trips, wrong-type returns None, andis_empty. -
DuckDB v1.5.1 compatibility evaluation — comprehensive analysis of all 80+ changes in DuckDB v1.5.1 against quack-rs. See
docs/duckdb-v1.5.1-evaluation.md.
Fixed
- ARM64 / aarch64 build — replaced all
.cast::<i8>()and*const i8pointer casts withstd::os::raw::c_char, which resolves toi8on x86-64 andu8on ARM64 (where Ccharis unsigned). EliminatesE0308/E0277mismatched-types errors when cross-compiling or building natively on aarch64. Affected files:replacement_scan/mod.rs,types/logical_type.rs,vector/writer.rs.
Changed
- DuckDB v1.5.1 compatibility — updated
DUCKDB_API_VERSIONdoc comment and version range documentation to explicitly cover v1.5.1. The C API version remains"v1.2.0"(unchanged from v1.5.0). Users are strongly recommended to upgrade their DuckDB runtime to v1.5.1 for critical WAL corruption and ART index correctness fixes.
Internal
-
CI action update —
dtolnay/rust-toolchainpinned to631a55b12751854ce901bb631d5902ceb48146f7(PR #59). -
Mutation testing —
mutants.tomlnow setsfeatures = ["duckdb-1-5"]so thatcargo mutantscompiles and tests feature-gated code paths. Previously, four mutants inMockRegistrar::copy_function_names,has_copy_function, andtotal_registrationswere unreachable because their tests were also feature-gated. Addedmock_registrar_total_registrations_scalar_plus_copy_functionto robustly kill the+ with -mutation intotal_registrationsby using a non-zerobasecount.
0.7.0 — 2026-03-22
Added
-
duckdb-1-5feature modules — theduckdb-1-5feature flag is no longer a placeholder. When enabled, it gates five new modules wrapping DuckDB 1.5.0 C Extension API additions:catalog— catalog entry lookup (CatalogEntry,Catalog,CatalogEntryType)client_context— client context access (ClientContext) for retrieving catalogs, config options, and connection IDs from within registered function callbacksconfig_option— extension-defined configuration options (ConfigOptionBuilder,ConfigOptionScope) registered viaSET/RESET/current_setting()copy_function— customCOPY TOhandlers (CopyFunctionBuilder) with bind → global init → sink → finalize lifecycletable_description— table metadata queries (TableDescription) for column count, names, and logical types
-
TypeId::TimeNs— newTIME_NScolumn type variant for nanosecond- precision time of day (DuckDB 1.5.0+, requiresduckdb-1-5feature) -
ScalarFunctionBuilder::varargs()/varargs_logical()— mark a scalar function as accepting variadic arguments (requiresduckdb-1-5) -
ScalarFunctionBuilder::volatile()— mark a scalar function as volatile (re-evaluated for every row even with constant arguments, requiresduckdb-1-5) -
ScalarFunctionBuilder::bind()— set a bind callback invoked once during query planning for per-query state allocation (requiresduckdb-1-5) -
ScalarFunctionBuilder::init()— set an init callback invoked once per thread for per-thread local state allocation (requiresduckdb-1-5)
Changed
-
DuckDB 1.5.0 support — upgraded default
libduckdb-sysfrom 1.4.4 to 1.10500.0 (DuckDB 1.5.0) andduckdbfrom 1.4.4 to 1.10500.0. The version range">=1.4.4, <2"inCargo.tomlis unchanged, preserving backward compatibility with DuckDB 1.4.x. -
Transitive dependency updates —
cc1.2.56→1.2.57,tar0.4.44→0.4.45,rustls-webpki0.103.9→0.103.10,arrow56.2.0→57.3.0,clap4.5.60→4.6.0,tempfile3.14.0→3.27.0, plus ~30 other minor/patch updates. -
CI action updates —
Swatinem/rust-cachev2.8.2→v2.9.1,actions/download-artifactv8.0.0→v8.0.1,actions/cache5.0.3→5.0.4,codecov/codecov-action5.4.3→5.5.3.
Fixed
- COPY format handlers — previously listed as a known limitation (no C API
counterpart). DuckDB 1.5.0 adds
duckdb_create_copy_functionand related symbols; the newcopy_functionmodule wraps them behindduckdb-1-5.
0.6.0 — 2026-03-12
Added
-
InMemoryDbdispatch table initialisation —InMemoryDb::open()now correctly initialises theloadable-extensiondispatch table from bundled DuckDB symbols before opening a connection, allowing all threeInMemoryDbunit tests to pass undercargo test --features bundled-test. Previously every call toInMemoryDb::open()panicked with"DuckDB API not initialized or DuckDB feature omitted"because theloadable-extensiondispatch table was never populated incargo test. -
src/testing/bundled_api_init.cpp— thin C++ shim that wraps DuckDB's internalCreateAPIv1()function (fromduckdb/main/capi/extension_api.hpp) as a C-linkage symbol (quack_rs_create_api_v1). Called once at test startup to populate all 459AtomicPtrslots in the dispatch table with real bundled DuckDB function pointers. -
build.rs— Cargo build script that, when thebundled-testfeature is active, locates thelibduckdb-sysbuild output directory, finds the bundled DuckDB include path, and compilesbundled_api_init.cppvia thecccrate. -
CI:
test-bundledjob — new CI job runscargo test --all-targets --features bundled-teston all three platforms (Linux, macOS, Windows) on every push and pull request, closing the gap that allowed this failure to reach the release workflow undetected. -
Pitfall P9 documented —
LESSONS.mdnow contains a full analysis of theloadable-extensiondispatch table failure mode: root cause, theCreateAPIv1()solution, ABI compatibility details, risks of relying on DuckDB's internal C++ header, and a mitigation table.
Fixed
InMemoryDb::open()no longer panics when called incargo testwith thebundled-testfeature enabled. This was a regression introduced whenInMemoryDbwas first shipped in 0.5.1 without the dispatch table initialisation step.
Changed
bundled-testfeature documentation updated to accurately describe the dispatch table initialisation behaviour (previously claimed to "bypass" the dispatch mechanism; it now correctly initialises it).
0.5.1 — 2026-03-12
Added
-
Testing primitives (
quack_rs::testing) — new mock types for unit-testing extension logic without a live DuckDB process:MockVectorWriter— in-memory output buffer matching theVectorWriterAPI; use to test scalar/aggregate finalize/scan callbacksMockVectorReader— in-memory input buffer with convenience constructors (from_i64s,from_strs,from_bools,from_f64s,from_i32s)MockDuckValue— typed enum covering all DuckDB scalar typesMockRegistrar— implements theRegistrartrait using interior mutability; records registered functions without any C API callCastRecord— records source/target types for cast registrations
-
bundled-testCargo feature — links the bundled DuckDB static library via theduckdbcrate and enablesInMemoryDb::open()for SQL-level assertions incargo test. Does not initialize theloadable-extensiondispatch table. -
InMemoryDb— wrapsduckdb::Connectionfor SQL-level integration tests; available behind thebundled-testfeature. -
Builder introspection accessors —
pub fn name(&self) -> &stradded toScalarFunctionBuilder,ScalarFunctionSetBuilder,AggregateFunctionBuilder,AggregateFunctionSetBuilder, andTableFunctionBuilder.pub fn source(&self) -> Option<TypeId>andpub fn target(&self) -> Option<TypeId>added toCastFunctionBuilder.
Security
- Bump
quinn-proto0.11.13 → 0.11.14 in root andexamples/hello-extCargo.lockfiles (addresses RUSTSEC advisory).
0.5.0 — 2026-03-10
Added
-
param_logical(LogicalType)on all builders — register parameters with complex parameterized types (LIST(BIGINT),MAP(VARCHAR, INTEGER),STRUCT(...)) thatTypeIdalone cannot express. Available onAggregateFunctionBuilder,AggregateFunctionSetBuilder::OverloadBuilder,ScalarFunctionBuilder, andScalarOverloadBuilder. Parameters added viaparam()andparam_logical()are interleaved by position, so the order you call them is the order DuckDB sees them. -
returns_logical(LogicalType)on all builders — set a complex parameterized return type. When bothreturns(TypeId)andreturns_logical(LogicalType)are called, the logical type takes precedence. Available onAggregateFunctionBuilder,AggregateFunctionSetBuilder,ScalarFunctionBuilder, andScalarOverloadBuilder. This eliminates the need for raw FFI when returningLIST(BOOLEAN),LIST(TIMESTAMP),MAP(K, V), or any other parameterized type. -
null_handling(NullHandling)on set overload builders — per-overload NULL handling configuration forAggregateFunctionSetBuilder::OverloadBuilderandScalarOverloadBuilder. Previously only available on single-function builders.
Notes
- Upstream fix:
duckdb-loadable-macrospanic-at-FFI-boundary — the safe entry-point pattern developed inquack-rs(using?/ok_or_elsethroughout instead of.unwrap()) was contributed upstream as duckdb/duckdb-rs#696 and merged 2026-03-09. All users of theduckdb_entrypoint_c_api!macro fromduckdb-loadable-macroswill receive this fix in the nextduckdb-rsrelease.quack-rsusers have always been protected via the safeentry_point!/entry_point_v2!macros provided by this crate.
0.4.0 — 2026-03-09
Added
-
ConnectionandRegistrartrait — version-agnostic extension registration facade (src/connection.rs).Connectionwraps theduckdb_connectionandduckdb_databasehandles provided at initialization time. TheRegistrartrait provides uniform methods for registering all extension components (scalar, scalar set, aggregate, aggregate set, table, SQL macro, cast), making registration code interchangeable across DuckDB 1.4.x and 1.5.x. Replacement scans are exposed as direct methods onConnectionsince they requireduckdb_database, not the connection handle. -
init_extension_v2— new entry point helper that passes&Connectionto the registration callback instead of a rawduckdb_connection. Prefer this overinit_extensionfor new extensions. -
entry_point_v2!macro — companion macro toentry_point!that generates the#[no_mangle] unsafe extern "C"entry point usinginit_extension_v2. -
duckdb-1-5cargo feature — placeholder feature flag for DuckDB 1.5.0-specific C API wrappers. Currently empty; will be populated whenlibduckdb-sys1.5.0 is published on crates.io.
Changed
- DuckDB version support broadened to 1.4.x and 1.5.x — the
libduckdb-sysdependency requirement was relaxed from an exact pin (=1.4.4) to a range (>=1.4.4, <2). DuckDB v1.5.0 (released 2026-03-09) does not change the C API version string (v1.2.0) used induckdb_rs_extension_api_init; the existingDUCKDB_API_VERSIONconstant remains correct for both releases. Extension authors can now pin their ownlibduckdb-systo either=1.4.4or=1.5.0and resolve cleanly againstquack-rs. The scaffold template and CI workflow template were updated to default to DuckDB v1.5.0.
0.3.0 — 2026-03-08
Added
-
TableFunctionBuilder— type-safe builder for registering DuckDB table functions (theSELECT * FROM my_function(args)pattern). Covers the full bind/init/scan lifecycle with ergonomic callbacks, eliminating ~100 lines of raw FFI boilerplate. Helper typesBindInfo,FfiBindData<T>, andFfiInitData<T>manage parameter extraction and per-scan state with zero raw pointer manipulation. Seetableandexamples/hello-ext(generate_series_ext) for a fully-tested end-to-end example verified against DuckDB 1.4.4. -
ReplacementScanBuilder— builder for registering DuckDB replacement scans (theSELECT * FROM 'file.xyz'pattern where a file path triggers a table-valued scan). The builder handles callback registration, path extraction, and bind-info population through a 4-method chain. Seereplacement_scan. -
StructVector— safe wrapper for reading and writing STRUCT child vectors.get_child(vec, idx),field_reader(vec, idx, row_count), andfield_writer(vec, idx)replace manual offset arithmetic over child vector handles. -
ListVector— safe wrapper for reading and writing LIST child vectors.get_child,get_entry,set_entry,reserve,set_size,child_reader, andchild_writercover the complete LIST read/write workflow without raw pointer casts. -
MapVector— safe wrapper for DuckDB MAP vectors (stored asLIST<STRUCT{key, value}>).keys(vec),values(vec),struct_child(vec),reserve,set_size,set_entry, andget_entryexpose the full MAP interface. -
vector::complexmodule — re-exportsStructVector,ListVector,MapVectoratquack_rs::vector::complexand documents the read-vs-write workflow for nested types with working code examples in the module doc. -
preludeadditions —TableFunctionBuilder,BindInfo,FfiBindData,FfiInitData,ReplacementScanBuilder,StructVector,ListVector,MapVector,CastFunctionBuilder,CastFunctionInfo,CastModeare now all re-exported fromquack_rs::prelude. -
CastFunctionBuilder— type-safe builder for registering custom type cast functions viaduckdb_cast_function_*. Covers both explicitCAST(x AS T)and implicit coercions (with optionalimplicit_cost). The companionCastFunctionInfowrapper exposescast_mode(),set_error(), andset_row_error()inside callbacks, giving correctTRY_CAST/CASTerror handling with zero raw pointer boilerplate. Seecastfor the full API. -
DbConfig— RAII wrapper forduckdb_config(extension configuration parameters). Builder-style.set(name, value)?chain, automaticduckdb_destroy_configon drop, andflag_count()/get_flag(index)for enumerating all available options. Useful when an extension needs to open a secondaryDuckDBdatabase from within its callbacks. Seeconfig. -
ScalarFunctionSetBuilder— builder for registering scalar function sets (multiple overloads under one name), mirroringAggregateFunctionSetBuilder. -
TypeIdvariants —Decimal,Struct,Map,UHugeInt,TimeTz,TimestampS,TimestampMs,TimestampNs,Array,Enum,Union,Bit. -
From<TypeId> for LogicalType— idiomatic conversion fromTypeId. -
#[must_use]on builder structs —ScalarFunctionBuilder,AggregateFunctionBuilder,AggregateFunctionSetBuilder, andOverloadBuildernow warn at compile time if constructed but never consumed. -
NullHandlingenum and.null_handling()builder method — configurable NULL propagation for scalar and aggregate functions viaduckdb_scalar_function_set_special_handling/duckdb_aggregate_function_set_special_handling. -
VectorWriter::write_interval— writes INTERVAL values to output vectors using the correct 16-byte{ months: i32, days: i32, micros: i64 }layout. -
append_metadatabinary — native Rust replacement for the Pythonappend_extension_metadata.pyscript, now shipping with the crate. Install withcargo install quack-rs --bin append_metadata. -
hello-extcast function demo —examples/hello-extnow registers aCAST(VARCHAR AS INTEGER)cast function usingCastFunctionBuilder, demonstrating bothCAST(abort-on-error) andTRY_CAST(NULL-on-error) code paths. Five unit tests coverparse_varchar_to_int, including boundary values and overflow.
Not implemented (upstream C API gap)
-
Window functions —
duckdb_create_window_functionand related symbols do not exist in DuckDB's public C extension API. They are implemented only in the C++ layer and are therefore not wrappable byquack-rsor any other C-API binding. Verified against the DuckDB stable C API reference andlibduckdb-sys1.4.4 bindings. -
COPY format handlers —
duckdb_create_copy_functionand related symbols are similarly absent from the C extension API for the same reason.
Fixed
hello-extgs_bindcallback — replaced incorrectduckdb_value_int64(param)(wrong arity: takes 3 arguments) withduckdb_get_int64(param)(correct 1-argument form). The extension now builds cleanly and all 11 live SQL tests pass against DuckDB 1.4.4.
Changed
- Bump
criteriondev-dependency from0.5to0.8. - Bump
Swatinem/rust-cacheGitHub Action fromv2.7.5tov2.8.2. - Bump
dtolnay/rust-toolchainCI pin fromv2.7.5to latest SHA. - Bump
actions/attest-build-provenancefromv2tov4. - Bump
actions/configure-pagesto latest SHA (d5606572…). - Bump
actions/upload-pages-artifactfromv3.0.1tov4.0.0.
0.2.0 — 2026-03-07
Added
-
validate::description_ymlmodule — parse and validate a completedescription.ymlmetadata file end-to-end. Includes:DescriptionYmlstruct — structured representation of all required and optional fieldsparse_description_yml(content: &str)— parse and validate in one stepvalidate_description_yml_str(content: &str)— pass/fail validationvalidate_rust_extension(desc: &DescriptionYml)— enforce Rust-specific fields (language: Rust,build: cargo,requires_toolchainsincludesrust)- 25+ unit tests covering all required fields, optional fields, error paths, and edge cases
-
preludemodule — ergonomic glob-import for the most commonly used items.use quack_rs::prelude::*;brings in all builder types, state traits, vector helpers, types, error handling, and the API version constant. Reduces boilerplate for extension authors. -
Scaffold:
extension_config.cmakegeneration — the scaffold generator now producesextension_config.cmake, which is referenced by theEXT_CONFIGvariable in the Makefile and required byextension-ci-toolsfor CI integration. -
Scaffold: SQLLogicTest skeleton —
generate_scaffoldnow producestest/sql/{name}.test, a ready-to-fill SQLLogicTest file withrequiredirective, format comments, and example query/result blocks. E2E tests are required for community extension submission (Pitfall P3). -
Scaffold: GitHub Actions CI workflow —
generate_scaffoldnow produces.github/workflows/extension-ci.yml, a complete cross-platform CI workflow that builds and tests the extension on Linux, macOS, and Windows against a real DuckDB binary. -
validate::validate_excluded_platforms_str— validates theexcluded_platformsfield fromdescription.ymlas a semicolon-delimited string (e.g.,"wasm_mvp;wasm_eh;wasm_threads"). Splits on;and validates each token. An empty string is valid (no exclusions). -
validate::validate_excluded_platforms— re-exported at thevalidatemodule level (previously only accessible asvalidate::platform::validate_excluded_platforms). -
validate::semver::classify_extension_version— returnsExtensionStability(Unstable/PreRelease/Stable) classifying the tier a version falls into. -
validate::semver::ExtensionStability— enum for DuckDB extension version stability tiers (Unstable,PreRelease,Stable) withDisplayimplementation. -
scalarmodule —ScalarFunctionBuilderfor registering scalar functions with the DuckDB C Extension API. Includestry_newwith name validation,param,returns,functionsetters, andregister. Full unit tests included. -
entry_point!macro — generates the required#[no_mangle] extern "C"entry point with zero boilerplate from an identifier and registration closure. -
VectorWriter::write_varchar— writes VARCHAR string values to output vectors usingduckdb_vector_assign_string_element_len(handles both inline and pointer formats). -
VectorWriter::write_bool— writes BOOLEAN values as a single byte. -
VectorWriter::write_u16— writes USMALLINT values. -
VectorWriter::write_i16— writes SMALLINT values. -
VectorReader::read_interval— reads INTERVAL values from input vectors via the correct 16-byte layout helper. -
CI: Windows testing — the CI matrix now includes
windows-latestin thetestjob, covering all three major platforms (Linux, macOS, Windows). -
CI:
example-checkjob — CI now checks, lints, and testsexamples/hello-extas part of every PR, ensuring the example extension always compiles and its tests pass. -
validate::validate_release_profile— checks Cargo release profile settings for loadable-extension correctness. Validatespanic,lto,opt-level, andcodegen-units.
Fixed
- MSRV documentation now consistently states 1.84.1 across
README.md,CONTRIBUTING.md, andCargo.toml(previouslyREADME.mdstated 1.80).
0.1.0 — 2025-05-01
Added
- Initial release
entry_pointmodule:init_extensionhelper for correct extension initializationaggregatemodule:AggregateFunctionBuilder,AggregateFunctionSetBuilderaggregate::statemodule:AggregateStatetrait,FfiState<T>wrapperaggregate::callbacksmodule: type aliases for all 6 callback signaturesvectormodule:VectorReader,VectorWriter,ValidityBitmap,DuckStringViewtypesmodule:TypeIdenum,LogicalTypeRAII wrapperintervalmodule:DuckInterval,interval_to_micros,read_interval_aterrormodule:ExtensionError,ExtResult<T>testingmodule:AggregateTestHarness<S>for pure-Rust aggregate testingvalidatemodule:validate_extension_name,validate_function_name,validate_semver,validate_extension_version,validate_spdx_license,validate_platform,validate_release_profilescaffoldmodule:generate_scaffoldfor generating complete extension projectssql_macromodule:SqlMacrofor registering SQL macros without FFI callbacks- Complete
hello-extexample extension - Documentation of all 15 DuckDB Rust FFI pitfalls (
LESSONS.md) - CI pipeline: check, test, clippy, fmt, doc, MSRV, bench-compile
SECURITY.mdvulnerability disclosure policy