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:

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

  1. libcdio’s SCSI MMC routines.
  2. 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. Rust to C iteration loop. Image taken from “Rust out your C” by Carol Nichols

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:

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.

Example: An instance of using the newtype pattern

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 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).

Example: Implementing SCSI MMC `START STOP UNIT`

The START STOP UNIT command is used to:

  • Eject or Close/Open the tray of a drive
  • Set the drive to a power state such as idle, poweroff or sleep

Here’s the existing C implementation of this command from libcdio:

/**
   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);
}

One problem here is the reliance on human judgement to set power_condition to zero for b_eject to not be ignored.

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 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:

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:

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:

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.