Porting a 23-year-old C library to Rust
11 minutes read •
As an accepted contributor for Google Summer of Code 2026, I worked on porting parts of GNU libcdio to Rust.
This has achieved:
- A new implementation of libcdio, libcdio-rs, that is thread safe, memory safe and wrapped behind hard-to-misuse interfaces.
- Bug fixes and thread safety improvements to the existing C library.
Two new crates formed as a result:
In this article, I share the approach and learnings I’ve had from this experience.
What is GNU libcdio?
It’s a C library that provides several routines needed for programs that wish to work with optical media such as CD/DVD and BluRay, notably by providing an OS agnostic interface.
A few prominent programs that use GNU libcdio include ffmpeg, GNOME gvfs, mpv, KDE kiso, Kodi, Rufus and VLC.
The library’s first commit traces back to 2003. This is before Git, as the project originally used CVS.
Why port it to Rust?
Rocky Bernstein, the author and maintainer of the project was annoyed with the vulnerabilities reported due to memory management mistakes and buffer overflows and chose Rust for renowned memory safety.
He believed that this would also fix up deeply rooted problems and encourage the participation of the next generation of developers.
Scope of the project
- libcdio’s SCSI MMC routines.
- libcdio’s utility programs (four).
Porting the SCSI MMC routines
SCSI MMC is a standard defining a command set for accessing and controlling optical disc readers and writers.
Initial approach: Rust as a drop-in C replacement
This involves making the Rust interfaces to be C compabible, so that existing C code that used the library would continue to function, while gaining the improvements brought upon by Rust.
“Rust Out Your C” by Carol Nichols is a good resource on this. 
This comes with the cost of requiring the Rust toolchain to build and use GNU libcdio.
With that, the first task was to figure out adding Rust to its build system.
Rust and the GNU build system
Being part of GNU, libcdio had naturally used the GNU build system, which includes Autoconf, Automake and Libtool.
These tools come from a very lively era that had a variety of Unices and other Unix like operating systems. The only thing they all had supported in common was the POSIX make command and shell scripts.
The GNU build system is kindof a hack that accepted a set of input files which declared what the program needs such as certain C libraries, headers and system features such as large file support and produced a configure shell script that can runs on any system. Its task is to probe the system for the program’s requirements and produce a Makefile.
The GNU manuals were helpful in understanding them.
I chose an approach where Cargo would handle just the Rust bits and produce a static, C-compatible library, which the existing build system can use to link against the C bits.
The article “Librsvg’s build infrastructure: Rust and Autotools” by Federico Mena Quintero was a strong inspiration.
“build: Add Rust” is the pull request incorporating this.
However, the users did not agree with the proposal, primarily due to concerns about Rust’s relatively limited compiler ecosystem, compared to C.
After a bit of discussion, I went with a change of approach.
Final approach: A separate Rust library
This has the advantage of unlocking the full capabilities and benefits of Rust, as there won’t be any constraints associated with having a C-compatible interface.
libcdio-rs: The Rust implementation of GNU libcdio
Implementing the SCSI MMC commands in Rust involved communicating with a disc drive in binary:
- Constructing requests per the SCSI and MMC specifications
- Passing them to the OS specific device interface (such as
ioctl()) - Parsing binary responses
The OS specific interfaces were abstracted by libcdio’s mmc_run_cmd() function, which I’d re-used via FFI.
Parsing binary responses were done using the winnow crate’s routines, which are designed to make these tasks less prone to human error.
Every function had a unit test, which were performed on a USB based CD/DVD drive.
Rust’s idioms, such as the newtype pattern, were applied where possible. There are routines that take a track number as input. However, track numbers, per the spec are valid only from 1 to 100. Here’s a newtype Example: An instance of using the newtype pattern
TrackNumber:/// Track number.
///
/// Values must be between 1 and 99.
/// Use [`TrackNumber::try_from`] to construct a new value.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct TrackNumber(u8);
impl TryFrom<u8> for TrackNumber {
type Error = InvalidTrackNumber;
/// Construct `Self` from given track number.
///
/// # Errors
/// If the track number not within the range of 1 to 99, inclusive.
fn try_from(track_number: u8) -> Result<Self, Self::Error> {
if (1..=99).contains(&track_number) {
Ok(Self(track_number))
} else {
Err(InvalidTrackNumber(track_number))
}
}
}
#[derive(Debug, Error)]
#[error("invalid track number '{0}'; value must be between 1 and 99")]
pub struct InvalidTrackNumber(u8);
impl Default for TrackNumber {
fn default() -> Self {
Self(1)
}
}
Routines that accept a track number now have a compile-time guarantee of track numbers being within range:
/// Get the International Standard Recording Code (ISRC) of given track number.
pub fn isrc(&self, track_number: TrackNumber) -> Result<Option<String>, MmcSubchannelError> {
/// use track_number confidently, without any checks!
}It also removes the need to verify track number inputs at every routine (and having to define an error). The Here’s the existing C implementation of this command from libcdio: One problem here is the reliance on human judgement to set Rust algebraic types helped eliminate the invalid input of having both eject and power condition being set. Moreover, the exhaustive pattern matching also ensures that all possible values are handled (such as Example: Implementing SCSI MMC `START STOP UNIT`
START STOP UNIT command is used to:/**
Load or Unload media using a SCSI-MMC START/STOP UNIT command.
@param p_cdio the CD object to be acted upon.
@param b_eject eject if true and close tray if false
@param b_immediate wait or don't wait for operation to complete
@param power_condition Set CD-ROM to idle/standby/sleep. If nonzero,
eject/load is ignored, so set to 0 if you want to eject or load.
@see mmc_eject_media or mmc_close_tray
*/
driver_return_code_t
mmc_start_stop_unit(const CdIo_t *p_cdio, bool b_eject, bool b_immediate,
uint8_t power_condition, unsigned int i_timeout_ms)
{
uint8_t buf[1];
void * p_buf = &buf;
const unsigned int i_size = 0;
MMC_CMD_SETUP(CDIO_MMC_GPCMD_START_STOP_UNIT);
/* this command doesn't have an allocation length in its CDB */
if (b_immediate) cdb.field[1] |= 1;
if (power_condition)
cdb.field[4] = power_condition << 4;
else {
if (b_eject)
cdb.field[4] = 2; /* eject */
else
cdb.field[4] = 3; /* close tray for tray-type */
}
return MMC_RUN_CMD(SCSI_MMC_DATA_WRITE, mmc_timeout_ms);
}power_condition to zero for b_eject to not be ignored.StartDisc, Jump, which the C implementation missed)./// Operations of the `START STOP UNIT` command
enum StartStopOperation {
StopDisc,
StartDisc,
EjectDisc,
LoadStartDisc,
/// Change the online format-layer to the specified value for hybrid discs.
/// Only the last two bits will be set.
Jump {
layer_number: u8,
},
/// Place the device in the specified power condition
Power(PowerCondition),
}
/// A power state as defined under MMC `START STOP UNIT`
enum PowerCondition {
Idle = 0x2,
Standby = 0x3,
Sleep = 0x5,
}
impl Mmc {
fn start_stop_unit(&self, operation: StartStopOperation) -> Result<(), MmcStartStopError> {
let mut cdb = Cdb::default();
cdb[0] = MmcCommand::StartStopUnit as u8;
cdb[1] = 0; // not using the immediate bit for now
if let StartStopOperation::Jump { layer_number } = operation {
cdb[3] = layer_number & LAYER_NUM_BITMASK;
cdb[4] |= 1 << FORMAT_LAYER_BITPOS;
}
// Sets the LoEj and Start fields
// as described in 6.42.3.1 of MMC-6 2g
cdb[4] |= match operation {
StartStopOperation::StartDisc => 0b01,
StartStopOperation::EjectDisc => 0b10,
StartStopOperation::LoadStartDisc | StartStopOperation::Jump { .. } => 0b11,
_ => 0b00,
};
if let StartStopOperation::Power(pow_cond) = operation {
cdb[4] |= (pow_cond as u8 & POWER_COND_BITMASK) << POWER_COND_BITPOS;
}
self.run_command(Some(MmcDirection::Write), &mut [], cdb)?;
Ok(())
}
}
type Cdb = [u8; 12];
Porting the utility programs
The utility programs were essentially a demonstration of the library capabilities.
Designing a CLI program is pretty simple in Rust, thanks to the clap crate.
The Command line apps in Rust is a neat guide.
However, a wrapper is needed for these to-be-Rust binaries to interface with the C library.
libcdio-sys: Raw bindings over libcdio
The first layer in interacting with a C library is to have a -sys crate, which provides raw Rust bindings, i.e extern fns, consts, statics and structs for every type, value and function exposed by the C library.
Fortunately, libcdio-sys already existed. It used [bindgen][bindgen-guide] to generate the bindings from libcdio’s public C headers at build time.
However, it depended on having libcdio’s headers installed in the system, and also, did not support vendoring.
Adding vendoring support to libcdio-sys
Tip
Vendoring in Rust is a practice where a crate also includes the underlying C library that it provides bindings for.
Ideally, these programs should require minimum effort to be installed, i.e a cargo install should suffice. Having to install the C library separately from repositories, or worse, having to build it from source is not encouraging.
Moreover, the versions of libcdio in distro repositories varied:
- Fedora shipped
v2.1.0 - Debian shipped
v2.2.0(Newer than Fedora? 🧐) - Arch shipped
v2.3.0(the latest, btw)
While libcdio’s interfaces did not change much, the safe wrapper relied on bugfixes that were about to be introduced in the next release, v2.4.0. Given the slow pace of distro packaging, one would have to resort to building and installing libcdio from source to be able to use the Rust packages.
Vendoring solves this by also building the C library alongside the -sys crate.
The libgit2-inspired versioning scheme for the libcdio-sys crate also allows users to have a fine grained control over the version of the underlying C library.
A custom config.h was made with common values for the most supported targets to replace the Autotools generated one, and the cc crate was used to compile the C sources.
This was guarded behind a vendored feature, alongside an opt-out env variable LIBCDIO_NO_VENDOR, due to the additive nature of Cargo’s features that might set this anyway if another crate in the dependency chain happen to enable it.
And finally the default behavior was updated to probe the system first, and fall back to vendoring if that fails.
Here’s the Pull Request: libcdio-sys
As always, Jon Gjengset’s “Crust of Rust: Build scripts and FFI” was very helpful.
libcdio-rs: A safe Rust wrapper over libcdio
The raw Rust bindings provided by -sys crates are too cumbersome for direct use.
Therefore, another layer abstracts the -sys crate:
Converts raw and C compatible types such as
c_int,*const c_char, andCStringto standard Rust types such asi32,&strandString.Encapsulates the C library’s functionality through a neat, object-oriented interface.
Leverages Rust’s borrow checker to enforce memory safety.
Example: An ISO 9660 entry whose lifetime is tied to its parent filesystem instance
/// ISO 9660 file/directory entry. pub struct IsoEntry<'a> { /// The parent ISO 9660 object pub(crate) iso: &'a Iso, pub(crate) stat: NonNull<iso9660_stat_s>, }Handles memory management using the
Droptrait, rather than having to call destructors manually.Miscellaneous things, such as exposing C emitted logs, for example using the
tracingcrate.Capturing libcdio's C logs in Rust
libcdio provided for setting a custom log handler, through the
cdio_log_set_handler()function./** * Set a custom log handler for libcdio. The return value is the log * handler being replaced. If the provided parameter is NULL, then * the handler will be reset to the default handler. */ cdio_log_handler_t cdio_log_set_handler (cdio_log_handler_t new_handler); /** * This type defines the signature of a log handler. For every * message being logged, the handler will receive the log level and * the message string. */ typedef void (*cdio_log_handler_t) (cdio_log_level_t level, const char message[]);This handler function would be called for every log produced by the library and be provided with the log level and the message.
Thus, I defined an
extern "C" fn log_handler()which translated these logs totracingvia its macros passed it to the C library viainit_logger():/// Handle logs via tracing. extern "C" fn log_handler(level: cdio_log_level_t, message: *const c_char) { if message.is_null() { return; } // SAFETY: message is not null, and is backed by an array on the // caller's stack. let message = unsafe { CStr::from_ptr(message) }; let Ok(message) = message.to_str() else { return; }; #[allow(non_upper_case_globals)] match level { cdio_log_level_t_CDIO_LOG_ASSERT | cdio_log_level_t_CDIO_LOG_ERROR => { tracing::error!(message) } cdio_log_level_t_CDIO_LOG_WARN => tracing::warn!(message), cdio_log_level_t_CDIO_LOG_INFO => tracing::info!(message), _ => tracing::debug!(message), }; } /// Configures libcdio to emit tracing (or "log" crate) logs. /// This should be called before using any other methods. pub(crate) fn init_logger() { static LOG_HANDLER: Once = Once::new(); LOG_HANDLER.call_once(|| unsafe { libcdio_sys::cdio_log_set_handler(Some(log_handler)); // libcdio doesn't call our log handler unless its log level is // appropriate. Since the current log level is chosen by log // subscribers, hack around by setting libcdio's log level to debug. libcdio_sys::cdio_loglevel_default = cdio_log_level_t_CDIO_LOG_DEBUG; }); }To make sure logs are not missed,
init_logger()must be called at all entry points of the library such as constructors, so that the handler gets set as early as possible.
Improvements to the C library
As Rust code was calling C code, it has to depend on the C code upholding certain things to be safe. Things such as reentrancy and thread safety.
A few things were bypassed, such as the use of a Mutex at the Rust side to temporaily fix the dangers from C code’s use of mutable statics.
The C code was updated to use reentrant variants of functions, such as strtok_r() over strtok().
A few places were updated to use thread locals over statics to uphold thread safety.
A few implementations were found to be incorrect according to spec, which were also fixed.
The utility programs
libcdio has seven utility programs, out of which, I’d covered:
iso-read: Extracts files from an ISO 9660 filesystem.iso-info: Lists files and metadata of an ISO 9660 filesystem.cd-drive: Provides details and capabilities of a disc drive.mmc-tool: Allows to issue SCSI MMC commands to a disc drive.
These were renamed to iso-cp, iso-ls, drive-info and mmc-cli, since there were major changes in the inputs and outputs of these programs and it also avoided a name collision.
Thanks to the clap crate, designing the CLI interface was simple and idiomatic in a way that is strongly typed.
And now that we had a safe wrapper over libcdio-rs, all that remained was to plug in the CLI interface with the library routines.
The options and outputs were improved.
A three month journey
This project has allowed me to expand my Rust expertise to the domain of systems programming, a level that closer to hardware than the back end domain that I was already familiar with.
Interpreting lengthy specification sheets and collaborating with the community and other authors were all quite the experience.
GSoC has been very good program in getting familiar with distributed model of open source development, and I highly recommend it.