Skip to content

fix(ffi): make FFI struct fields private to close Drop soundness hole - #10431

Merged
Jefffrey merged 8 commits into
apache:mainfrom
bit2swaz:fix/ffi-private-fields
Aug 13, 2026
Merged

fix(ffi): make FFI struct fields private to close Drop soundness hole#10431
Jefffrey merged 8 commits into
apache:mainfrom
bit2swaz:fix/ffi-private-fields

Conversation

@bit2swaz

@bit2swaz bit2swaz commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

FFI_ArrowArray, FFI_ArrowSchema, and FFI_ArrowArrayStream had all of their fields pub. Several of those fields carry invariants that the Drop impl and the import path rely on, so safe code could set them to values that trigger undefined behavior with no unsafe block:

  • Setting FFI_ArrowSchema::format or name to a pointer that did not come from CString::into_raw causes UB when the release callback frees it with CString::from_raw.
  • Setting FFI_ArrowArray::buffers to an invalid pointer causes UB when it is dereferenced with ptr::read_unaligned on import.
  • Setting the release fn pointer (or the stream's get_schema / get_next / get_last_error fn pointers) to a bogus function causes UB when it is invoked on drop or import.

An earlier revision of this PR made only release and private_data private and left the data fields pub, on the assumption that the data fields were harmless. That was wrong: as noted in review, the pointer-carrying data fields are UB vectors too. Any write to these fields has to be treated as unsafe.

What changes are included in this PR?

  • All fields on FFI_ArrowArray, FFI_ArrowSchema, and FFI_ArrowArrayStream are now private.
  • Reads are unchanged: every field already has a typed getter, so consumers keep full read access.
  • The wrap-release use case from Expose FFI data structures fields #9771 keeps working through the existing unsafe setters set_release and set_private_data, which swap in a new callback and private data and return the old values so the caller can chain into the original release. No other field needs a write path (the only fields any external consumer writes are release and private_data).
  • #[repr(C)] is unchanged, so the C Data Interface layout and ABI are identical.

A separate soundness issue with FFI_ArrowSchema::with_metadata / with_name on foreign schemas was raised in review. It has a different root cause and changes the safe/unsafe surface of those methods, so it will be handled in its own issue and PR rather than bundled here.

Are these changes tested?

Yes. Each struct has a test_wrap_release_callback test covering the swap-and-restore path, and all three pass under Miri with no leak, use-after-free, or double-free. The existing FFI roundtrip tests also pass under Miri. The full workspace builds and tests with --features ffi.

Are there any user-facing changes?

Yes, this is a breaking change. Code that read these fields directly must switch to the getters. Code that constructed these structs with a struct literal, or wrote fields directly, must use the safe constructors (try_from, new, empty) or, for release / private_data, the unsafe setters. The #[repr(C)] layout is unchanged.

@github-actions github-actions Bot added the arrow Changes to the arrow crate label Jul 24, 2026
@bit2swaz
bit2swaz marked this pull request as ready for review July 24, 2026 18:55
@alamb alamb added api-change Changes to the arrow API next-major-release the PR has API changes and it waiting on the next major version labels Jul 25, 2026
@alamb

alamb commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

I believe you fixed the MIRI error on #10433

Merging up to retrigger and get a clean run

@bit2swaz
bit2swaz force-pushed the fix/ffi-private-fields branch from 4ad96c5 to 43db76b Compare July 26, 2026 15:33
@Jefffrey

Copy link
Copy Markdown
Contributor

looks like we had them (or at least 2/3) private before

wonder if @ashdnazg could weigh in here?

@bit2swaz

Copy link
Copy Markdown
Contributor Author

right, thanks for bringing that up. i can see that #9772 made these pub on purpose for @ashdnazg's cross-thread use case in #9771 so this PR as is reverts that

i think both goals can coexist tho. the only soundness relevant fields are release and private_data since those are what Drop calls. the plain data fields being pub is harmless. and the thing #9771 actually needs, wrapping release + private_data with your own, is an unsafe operation anyway since youre taking over memory ownership

one direction i think would suit this would be to make the fields private and give back that one capability through a small unsafe API in the same spirit as from_raw. exact shape depends on how @ashdnazg actually uses it

@ashdnazg do you mutate the struct in place (swap release + private_data on an existing one) or do you take it apart and rebuild with your own release? and do you need the originals read back out? that would decide whether this wants an unsafe swap method or an unsafe "from parts" constructor. happy to build whichever fits :)

@bit2swaz
bit2swaz force-pushed the fix/ffi-private-fields branch from 43db76b to 540fd48 Compare July 27, 2026 11:18
@ashdnazg

Copy link
Copy Markdown
Contributor

Thanks for pinging me!

Since I don't want to assume anything about the existing implementation and where the structs came from, I'm accessing the fields directly and I store the original release and private_data in my new private_data:

        let private_data = Box::new(PrivateData {
            original_private_data: schema.private_data,
            original_release: schema.release,
            // other stuff
        });
        schema.private_data = Box::into_raw(private_data) as *mut c_void;
        schema.release = Some(release_schema);

My release function does what it needs with my private data, then restores the original release and private data, and finally calls the original release function:

        let private_data = unsafe { Box::from_raw(schema.private_data as *mut PrivateData) };
        schema.release = private_data.original_release;
        schema.private_data = private_data.original_private_data;
        // call release if not null

So in my opinion getters and unsafe setters are the simplest approach that provides the most flexibility while keeping soundness of safe code.

@ashdnazg

Copy link
Copy Markdown
Contributor

There is another problem though - the function with_metadata relies on private_data being a SchemaPrivateData, which wouldn't be the case if we got this schema from e.g., Java and didn't construct it locally (which should be a valid use for an FFI object).

with_name currently avoids this by leaking the old name, but if we fixed it not to leak, it would have a similar issue.

We might want to make these functions unsafe and not public.

@bit2swaz

Copy link
Copy Markdown
Contributor Author

ah nice, that settles it then. getters + unsafe setters it is. maps right onto your flow too; read both out, overwrite both, restore on release, and you never have to guess where the struct came from. ill add release() / private_data() getters and unsafe set_release() / set_private_data() setters (just plain overwrites since you pull the originals out first and own them from there). same on FFI_ArrowArray

also, good one pointing out the with_metadata. its already live on the safe api and a foreign schema's private_data ismt a SchemaPrivateData so that cast is straight up UB. it is its own thing tho and fixing it changes the safe/unsafe surface of with_metadata + with_name so i dont wanna bundle it in here and hold up the field change.
gonna spin off a separate issue and link back to this thread. that alright with you?

anyways, appreciate you hopping in on this :)

@ashdnazg

Copy link
Copy Markdown
Contributor

Sounds good, thanks!

@bit2swaz
bit2swaz force-pushed the fix/ffi-private-fields branch from 540fd48 to 543edbd Compare July 27, 2026 13:53
@bit2swaz

Copy link
Copy Markdown
Contributor Author

alright, made the changes. went with getters + unsafe setters like we talked about on all three structs (FFI_ArrowArray, FFI_ArrowSchema, FFI_ArrowArrayStream) since they all had the same pub release hole. only release + private_data are private now, the rest of the fields stay pub. also updated the PR description to match the new approach

the with_metadata thing ill spin off into its own issue like i mentioned. lmk what you think :)

@ashdnazg

ashdnazg commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

I don't think you can leave any fields pub.

For instance, changing schema.name or schema.format to anything which isn't a CString will result in UB.

@ashdnazg

ashdnazg commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

In addition, you can't know what an externally received release function might do, so any alteration of the inside fields must be treated as unsafe.

@alamb alamb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this @bit2swaz

Comment thread arrow-array/src/ffi_stream.rs
Comment thread arrow-data/src/ffi.rs Outdated
@bit2swaz

Copy link
Copy Markdown
Contributor Author

yeah youre both right, ill make all the fields private. i was wrong that the data fields were harmless, my bad on that

@ashdnazg's point on format/name is valid (setting them to anything that didnt come from CString::into_raw blows up in release) and @alamb's buffers example is the same thing on the array side. buffer() does a read_unaligned on self.buffers, so a bad pointer there is UB with no unsafe at the call site. so any write to the inner fields has to be treated as unsafe. going all-fields-private

reads are already covered, all three structs have typed getters for every field, so nothing new needed there

on the write side, ill keep the per-field unsafe setters (set_release / set_private_data). those are the only fields the #9771 wrap-release flow actually writes, and like you said @alamb, setters don't force an api change when a field gets added later. the unsafe fn new_from_parts(...) idea is nice for building a struct from scratch but the safe try_from / new constructors already cover that path and nobodys asked to hand-build these field by field yet, so id rather not add it speculatively. easy to tack on later if a use case shows up. lmk if youd rather have it now tho

the with_metadata thing ill spin off into its own issue later like i mentioned

@bit2swaz
bit2swaz force-pushed the fix/ffi-private-fields branch from 543edbd to 37bf69c Compare July 28, 2026 15:33
@bit2swaz
bit2swaz force-pushed the fix/ffi-private-fields branch from 37bf69c to c721e1e Compare July 29, 2026 14:56
@bit2swaz

Copy link
Copy Markdown
Contributor Author

made those changes. all fields private on all three structs now. you were both right, the data fields are UB vectors too so leaving them pub was a hole. thats my bad on the earlier "harmless" take. reads didnt need anything, every field already has a getter

kept the unsafe setters for release + private_data since thats all the wrap-release flow writes. left a note on @alamb's new_from_parts thread on why i skipped it for now, happy to add it if you want tho

#[repr(C)] is untouched so the layout/abi is identical. lmk if anything else is needed from my side :)

@bit2swaz
bit2swaz requested a review from alamb July 29, 2026 16:31

@Jefffrey Jefffrey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some suggestions on the documentation; we dont need to repeat ourselves in the internal struct field documentation, just simply state its for safety reasons. then on the setters themselves we can put the safety requirements.

(this goes for the other structs too)

Comment thread arrow-array/src/ffi_stream.rs Outdated
Comment thread arrow-array/src/ffi_stream.rs Outdated
Comment thread arrow-array/src/ffi_stream.rs Outdated
@bit2swaz

Copy link
Copy Markdown
Contributor Author

we dont need to repeat ourselves in the internal struct field documentation, just simply state its for safety reasons. then on the setters themselves we can put the safety requirements

done and applied it to all three structs. the struct fields just say theyre private for safety now, and the safety details live on the setters. i moved the wrap-release note + #9771 link onto set_release too so it doesnt get lost

btw, i applied your suggestions by hand so the three structs stay consistent, so the diff already has them. best to not click "commit suggestion" or itll double up. lmk if the wording works for you :)

@Jefffrey Jefffrey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • does this also close #10253?

was doing a bit of digging and seems FFI_ArrowArrayStream was generated/added with pub fields from the start, so doesnt seem there was any historical reason to make it public. I guess when using bindgen it just generated as public by default?

@bit2swaz

Copy link
Copy Markdown
Contributor Author

does this also close #10253?

yep. #10253 was the same hole on the stream: safe try_new ends up calling a caller-supplied get_schema fn pointer so with pub fields you could hand it a garbage stream and hit UB with no unsafe anywhere

private fields shut that path and #10253 offered private fields as one of its two fixes. try_new can stay safe now since a safe caller cant build a bogus stream. ill add a closes line for it :)

@Jefffrey
Jefffrey merged commit c87638f into apache:main Aug 13, 2026
34 checks passed
@Jefffrey

Copy link
Copy Markdown
Contributor

thanks @bit2swaz, @ashdnazg & @alamb

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api-change Changes to the arrow API arrow Changes to the arrow crate arrow-array arrow-data arrow-schema next-major-release the PR has API changes and it waiting on the next major version

Projects

None yet

4 participants