Day 054 — C の strdup が返した文字列の所有権を型で表す:6件のバグをレビューで見つけて直す

2026-09-24 🟣 上級者 / Phase 3 デバッグ・コードレビュー FFI(C言語との相互運用)②

📚 背景知識(読んでから問題へ)

Day 053 では、C にデータを借用で渡すだけ(&CStr や &mut [i32] を渡し、呼び出しが終われば返ってくる)に絞りました。今日はその先、メモリの所有権そのものが Rust と C の境界を越えるケースを扱います。

Rust 同士なら「誰が解放するか」はコンパイラが決めます。値の持ち主が1人に決まっていて、スコープを抜けると Drop が1回だけ走ります。ところが C の関数が返した char * は、Rust から見ればただの整数(アドレス)です。コンパイラには次のどれも分かりません。

問い例: strdup の場合(POSIX)コンパイラに分かるか
誰が確保したかstrdup 内部の malloc分からない
誰が解放するか呼び出し側が free で分からない
何で解放するかC の free(Rust の dealloc ではない)分からない
NULL がありうるか確保失敗時に NULL(errno = ENOMEM)分からない
いつまで有効かfree されるまで分からない

この5つを型に書き込むのが今日の仕事です。道具は次の4つです。

道具表すこと
所有型 + impl Drop「解放責任を持つのはこの値1つだけ」「解放方法は free」
NonNull<T>「NULL ではない」。確保失敗は構築時に Result / Option で弾く
戻り値の寿命を &self に縛る(fn f(&self) -> &CStr)「持ち主が生きている間だけ読める」
into_raw / from_raw + ManuallyDrop所有権を C に一時的に渡す/受け取る。渡している間は Drop を止める

もう1つ大事な前提があります。**Rust の String / CString / Box は Rust のグローバルアロケータで確保され、C の malloc とは別の仕組み**です。macOS や Linux の既定では Rust のアロケータ(std::alloc::System)が内部で malloc を呼んでいるので、取り違えてもたまたま動いてしまうことがあります。今日のコードレビューでは、この「たまたま動く」を実測で崩します。

📝 問題

同僚が「C の strdup が返した文字列を Rust で安全に扱う型」を書き、レビューを依頼してきました。cargo new ffi_day054(edition 2024)の src/main.rs にそのまま貼ると、警告1件でビルドが通り、cargo run -- 1 〜 cargo run -- 5 のシナリオが実行できます。

// レビュー対象: C の strdup が確保した文字列を所有する型
use std::ffi::{CStr, CString, c_char, c_void};

unsafe extern "C" {
    fn strdup(s: *const c_char) -> *mut c_char;
    fn free(p: *mut c_void);
}

#[derive(Clone)]
pub struct CMallocStr {
    ptr: *mut c_char,
}

impl CMallocStr {
    pub fn new(s: &str) -> CMallocStr {
        let p = CString::new(s).unwrap().as_ptr();
        CMallocStr { ptr: unsafe { strdup(p) } }
    }

    pub fn as_c_str(&self) -> &'static CStr {
        unsafe { CStr::from_ptr(self.ptr) }
    }

    pub fn into_string(self) -> String {
        let s = unsafe { CString::from_raw(self.ptr) };
        std::mem::forget(self);
        s.into_string().unwrap()
    }
}

impl Drop for CMallocStr {
    fn drop(&mut self) {
        unsafe { free(self.ptr.cast()) }
    }
}

/// Rust で作った文字列を C 側へ渡す。受け取った C 側は使い終わったら free(p) する
pub fn hand_to_c(s: &str) -> *mut c_char {
    CString::new(s).unwrap().into_raw()
}

fn main() {
    match std::env::args().nth(1).as_deref() {
        Some("1") => {
            let a = CMallocStr::new("hello, ffi");
            println!("{:?}", a.as_c_str());
        }
        Some("2") => {
            let a = CMallocStr::new("hello");
            let b = a.clone();
            println!("{:?} {:?}", a.as_c_str(), b.as_c_str());
        }
        Some("3") => {
            let r;
            {
                let a = CMallocStr::new("hello, dangling");
                r = a.as_c_str();
            }
            let _x = CMallocStr::new("XXXXXXXXXXXXXXX");
            println!("{:?}", r);
        }
        Some("4") => {
            let a = CMallocStr::new("hello");
            println!("{}", a.into_string());
        }
        Some("5") => {
            let p = hand_to_c("hello");
            unsafe { free(p.cast()) }; // C 側の free を模擬
            println!("freed");
        }
        _ => eprintln!("usage: cargo run -- <1..5>"),
    }
}
🗒️
同僚のメモ: 「5つのシナリオを手元(macOS)で全部走らせた。落ちたのは 2 だけ。警告は1件出るけど動いてる」

(1) 指摘: このコードには、メモリ安全性・所有権に関わる問題が少なくとも6件あります。それぞれについて次の3点を表にしてください。

  • どの関数の何が問題か
  • 何が起きるか(use-after-free / 二重解放 / アロケータの取り違え / NULL 参照 など)
  • rustc と clippy が検出するか(するならどんなメッセージか)

(2) 修正: 次のシグネチャで、呼ぶ側が unsafe を書かない限り UB を起こせない型に直してください。外部クレートは使いません。

pub struct CMallocStr { /* ??? */ }
pub enum NewError { InteriorNul(NulError), OutOfMemory }

impl CMallocStr {
    pub fn new(s: &str) -> Result<Self, NewError>;
    pub fn as_c_str(&self) -> &CStr;
    pub fn to_string(&self) -> Result<String, std::str::Utf8Error>;
    pub fn into_raw(self) -> *mut c_char;
    pub unsafe fn from_raw(p: *mut c_char) -> Option<Self>;
}
impl Clone for CMallocStr { /* ??? */ }
impl Drop for CMallocStr { /* ??? */ }

pub fn hand_to_c(s: &str) -> Result<*mut c_char, NulError>;
pub unsafe extern "C" fn rust_string_free(p: *mut c_char); // C 側に呼んでもらう解放関数

(3) テスト: 「確保したアロケータで解放されている」ことを cargo test で数えて確かめてください(ヒント: #[global_allocator] を自作すると、Rust のアロケータを通った確保・解放のバイト数を数えられます)。

(4) 記述: 同僚は「シナリオ 4 と 5 は macOS で問題なく動いた」と言っています。それでもバグである理由を2〜3文で説明してください。

💡 ヒント

ヒント1 — 方向性

6件は「確保・借用・複製・解放」のどこで起きるかで分けると見つけやすくなります。それぞれの関数について「このポインタは誰が確保し、今誰が持っていて、何で解放されるのか」を1行ずつ書き出してみてください。答えが「分からない」「2人いる」「確保した側と違う」になった所が問題です。

ヒント2 — アプローチ
  • CString::new(s).unwrap().as_ptr() の CString は一時値です。一時値は文(;)の終わりで破棄されます
  • #[derive(Clone)] はフィールドを1つずつコピーします。フィールドが生ポインタなら、コピーされるのはアドレスだけです
  • &'static CStr は「プログラム終了まで有効」という約束です。self の寿命と無関係な約束を返していないか
  • CString::from_raw の公式ドキュメントは「CString::into_raw が返したポインタにだけ使え」と書いています。strdup の戻り値はそうではありません
  • ManuallyDrop::new(self) で包むと、その値の Drop は走りません(mem::forget と同じ効果を、self を使い終わる前に宣言できます)
ヒント3 — コード骨格
use std::ffi::{CStr, CString, NulError, c_char, c_void};
use std::mem::ManuallyDrop;
use std::ptr::NonNull;

pub struct CMallocStr { ptr: NonNull<c_char> }

impl CMallocStr {
    pub fn new(s: &str) -> Result<Self, NewError> {
        let cs = CString::new(s)?;                 // 一時値にしない
        let p = unsafe { strdup(cs.as_ptr()) };
        NonNull::new(p).map(|ptr| Self { ptr }).ok_or(NewError::OutOfMemory)
    }
    pub fn as_c_str(&self) -> &CStr { unsafe { CStr::from_ptr(self.ptr.as_ptr()) } }
    pub fn to_string(&self) -> Result<String, std::str::Utf8Error> {
        /* C のバッファから Rust の String へ「コピー」する */
    }
    pub fn into_raw(self) -> *mut c_char {
        let me = ManuallyDrop::new(self);
        me.ptr.as_ptr()
    }
}
impl Clone for CMallocStr { fn clone(&self) -> Self { /* strdup し直す */ } }
impl Drop for CMallocStr { fn drop(&mut self) { unsafe { free(self.ptr.as_ptr().cast()) } } }

#[unsafe(no_mangle)]
pub unsafe extern "C" fn rust_string_free(p: *mut c_char) {
    if !p.is_null() { drop(unsafe { CString::from_raw(p) }); }
}

✅ 模範解答

(1) 指摘一覧

#場所問題起きることrustc / clippy
①newCString::new(s).unwrap().as_ptr() の CString が文末で破棄され、p は解放済み領域を指すuse-after-free。strdup が解放済みメモリを読む。実測では全シナリオの中身が空文字列 "" になった検出する(dangling_pointers_from_temporaries 警告。rustc の既定で有効、clippy も同じ1件)
②newstrdup の戻り値の NULL を確かめていない。さらに unwrap() で入力中の NUL により panic確保失敗時に NULL を持つ値ができ、as_c_str で NULL 参照(UB)、Drop で free(NULL)(これは無害)検出しない
③#[derive(Clone)]生ポインタのアドレスだけを複製する浅いコピー2つの値が同じ領域を持ち、両方の Drop で 二重解放検出しない(Copy まで derive すれば E0184 で止まるが、Clone だけなら通る)
④as_c_str戻り値が &'static CStr。self の寿命と無関係self の drop 後も参照が使え use-after-free。実測では後から確保した "XXXXXXXXXXXXXXX" が見えた検出しない(unsafe の中で寿命を自由に作れるため)
⑤into_stringstrdup(C の malloc)の領域を CString::from_raw(Rust のアロケータ)で解放アロケータの取り違え。既定の macOS/Linux では偶然動くが、#[global_allocator] を差し替えた瞬間に壊れる(下の実測)検出しない
⑥hand_to_cCString::into_raw(Rust のアロケータ)の領域を、C 側が free する契約⑤ の逆向きの取り違え検出しない
🔍
rustc と clippy が見つけたのは 6 件中 1 件(①)だけでした(実測: cargo build / cargo clippy とも警告 1 件)。残り 5 件は unsafe の中の「人間の約束」なので、コンパイラには見えません。

参考として derive(Clone) の代わりに Copy を足すと出るエラー、Send を実装しなかったときのエラーは「コンパイルエラーが出た場合」に載せています。

実測: レビュー対象コードの挙動

出題者の環境(rustc 1.96.0 / edition 2024 / Apple M1 macOS、2026-09-24)で、終了コードは echo $? で確認しました。

シナリオそのままのコード①だけ直したコード原因
1: 作って表示""(本来は "hello, ffi")/終了 0"hello, ffi"/終了 0①
2: clone して両方 drop"" "" の後に終了コード 133(SIGTRAP。macOS の malloc が二重解放を検出して停止)"hello" "hello"/終了 133③
3: 持ち主が消えた後に読む""/終了 0"XXXXXXXXXXXXXXX"/終了 0(別の文字列が見える)④
4: into_string空行/終了 0hello/終了 0⑤(見かけ上は動く)
5: hand_to_c → C の freefreed/終了 0freed/終了 0⑥(見かけ上は動く)

release ビルドでも 1〜5 の表示は同じでした。クラッシュしたのは 2 だけで、3〜5 は黙って誤動作するか、誤りが見えません。 同僚のメモ「落ちたのは 2 だけ」は正確な観察ですが、「だから他は大丈夫」とは言えない、というのが今日のレビューの核心です。

(2)(3) 修正版コード

// src/main.rs — Day 054: FFI ② 所有権が境界を越えるとき(修正版)
// cargo run / cargo test でそのまま実行できます(edition = "2024"、追加クレート不要)。
// strdup / free は OS の C 標準ライブラリ(macOS: libSystem / Linux: glibc・musl)の関数で、
// Rust の std が既にリンクしているため build.rs も -l 指定も要りません。

use std::ffi::{CStr, CString, NulError, c_char, c_void};
use std::fmt;
use std::mem::ManuallyDrop;
use std::ptr::NonNull;

unsafe extern "C" {
    /// # Safety
    /// `s` は NUL 終端された有効な C 文字列。戻り値は malloc で確保され、
    /// 呼び出し側が `free` で解放する責任を負う。確保に失敗すると NULL(errno = ENOMEM)。
    fn strdup(s: *const c_char) -> *mut c_char;

    /// # Safety
    /// `p` は NULL か、malloc 系(malloc / calloc / realloc / strdup)が返してまだ free していないポインタ。
    fn free(p: *mut c_void);
}

// ---------------------------------------------------------------------------
// C の malloc で確保された文字列を「所有」する型
// ---------------------------------------------------------------------------

/// C のヒープ(malloc)に置かれた NUL 終端文字列の所有者。
///
/// 不変条件(この型の safety invariant):
/// - `ptr` は malloc 系が返した、まだ free されていない NUL 終端文字列を指す
/// - その領域を free する責任を持つのは、この値ただ1つ
pub struct CMallocStr {
    ptr: NonNull<c_char>, // ①' NULL を型で排除する
}

#[derive(Debug)]
pub enum NewError {
    /// 入力の途中に NUL があり、C 文字列にできない
    InteriorNul(NulError),
    /// strdup が NULL を返した(C 側のメモリ確保失敗)
    OutOfMemory,
}

impl From<NulError> for NewError {
    fn from(e: NulError) -> Self {
        NewError::InteriorNul(e)
    }
}

impl CMallocStr {
    pub fn new(s: &str) -> Result<Self, NewError> {
        let cs = CString::new(s)?; // ① 一時値にしない。cs はこの関数の終わりまで生きる
        // SAFETY: cs.as_ptr() は NUL 終端された有効な文字列で、strdup の実行中 cs は生きている。
        let p = unsafe { strdup(cs.as_ptr()) };
        NonNull::new(p)
            .map(|ptr| CMallocStr { ptr })
            .ok_or(NewError::OutOfMemory) // ①' NULL を Err にする
    }

    /// 借用している間だけ有効な `&CStr` を返す(③ 'static にしない)
    pub fn as_c_str(&self) -> &CStr {
        // SAFETY: 不変条件により ptr は有効な NUL 終端文字列。
        // 戻り値の寿命は &self に縛られるので、self が drop された後には使えない。
        unsafe { CStr::from_ptr(self.ptr.as_ptr()) }
    }

    /// 中身を Rust の String に「コピー」し、C のバッファは Drop で free する(④)
    pub fn to_string(&self) -> Result<String, std::str::Utf8Error> {
        self.as_c_str().to_str().map(str::to_owned)
    }

    /// 所有権を C 側へ手放す。戻り値は C 側(または from_raw)が free する責任を負う
    pub fn into_raw(self) -> *mut c_char {
        let me = ManuallyDrop::new(self); // Drop を走らせない
        me.ptr.as_ptr()
    }

    /// C 側から所有権を受け取る
    ///
    /// # Safety
    /// `p` は malloc 系が返した、まだ free されていない NUL 終端文字列で、
    /// 呼び出し後は他の誰もそれを free しないこと。
    pub unsafe fn from_raw(p: *mut c_char) -> Option<Self> {
        NonNull::new(p).map(|ptr| CMallocStr { ptr })
    }
}

/// ② ポインタのコピーではなく、C のヒープ上に「中身」を複製する
impl Clone for CMallocStr {
    fn clone(&self) -> Self {
        // SAFETY: as_ptr() は有効な NUL 終端文字列(不変条件)。
        let p = unsafe { strdup(self.ptr.as_ptr()) };
        // clone は Result を返せないので、確保失敗は Vec / String と同じく abort 相当で扱う
        CMallocStr {
            ptr: NonNull::new(p).expect("strdup: out of memory"),
        }
    }
}

impl Drop for CMallocStr {
    fn drop(&mut self) {
        // SAFETY: 不変条件により ptr は malloc 系が返した未解放のポインタで、
        // 解放責任を持つのはこの値だけ(Clone は別領域を確保し、into_raw は Drop を止める)。
        unsafe { free(self.ptr.as_ptr().cast()) }
    }
}

impl fmt::Debug for CMallocStr {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.as_c_str().fmt(f)
    }
}

// SAFETY: CMallocStr は領域を排他的に所有し、malloc の領域はどのスレッドから free してもよい
// (C11 7.22.3: malloc / free はスレッド安全)。スレッド固有の状態を持たないので Send にできる。
unsafe impl Send for CMallocStr {}
// SAFETY: &CMallocStr から得られるのは読み取り専用の &CStr だけで、内部可変性が無いので Sync にできる。
unsafe impl Sync for CMallocStr {}

// ---------------------------------------------------------------------------
// ⑤ Rust が確保した文字列を C へ渡す: 解放関数を Rust 側から提供する
// ---------------------------------------------------------------------------

/// Rust のヒープに確保した C 文字列を C へ渡す。
/// C 側は使い終わったら **free ではなく `rust_string_free`** を呼ぶ契約にする。
pub fn hand_to_c(s: &str) -> Result<*mut c_char, NulError> {
    Ok(CString::new(s)?.into_raw())
}

/// `hand_to_c` が返したポインタを解放する。C からも呼べるよう C ABI・シンボル名固定で公開する。
///
/// # Safety
/// `p` は NULL か、`hand_to_c`(= CString::into_raw)が返してまだ解放していないポインタ。
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rust_string_free(p: *mut c_char) {
    if !p.is_null() {
        // SAFETY: 関数の契約により p は CString::into_raw 由来で未解放。
        drop(unsafe { CString::from_raw(p) });
    }
}

fn main() {
    let a = CMallocStr::new("hello, ffi").expect("new");
    let b = a.clone();
    println!(
        "a = {a:?}, b = {b:?}, same buffer? {}",
        a.as_c_str().as_ptr() == b.as_c_str().as_ptr()
    );

    let moved = std::thread::spawn(move || b.to_string().unwrap())
        .join()
        .unwrap();
    println!("to_string on another thread = {moved:?}");

    let raw = a.into_raw();
    // SAFETY: raw は直前の into_raw が返した strdup 由来のポインタで、他に解放者はいない。
    let back = unsafe { CMallocStr::from_raw(raw) }.expect("non-null");
    println!("round-trip via into_raw/from_raw = {back:?}");

    println!("new(\"a\\0b\") = {:?}", CMallocStr::new("a\0b").map(|_| ()));

    let p = hand_to_c("to C").unwrap();
    // SAFETY: p は hand_to_c が返したもので、ここで一度だけ解放する。
    unsafe { rust_string_free(p) };
    println!("hand_to_c + rust_string_free: ok");
}

// ---------------------------------------------------------------------------
// テスト: 「どちらのアロケータで確保・解放されたか」を数えて確かめる
// ---------------------------------------------------------------------------

#[cfg(test)]
mod counting {
    use std::alloc::{GlobalAlloc, Layout, System};
    use std::cell::Cell;

    thread_local! {
        /// このスレッドで Rust のグローバルアロケータを通った「確保 − 解放」バイト数
        pub static LIVE: Cell<isize> = const { Cell::new(0) };
    }

    pub struct Counting;

    unsafe impl GlobalAlloc for Counting {
        unsafe fn alloc(&self, l: Layout) -> *mut u8 {
            let _ = LIVE.try_with(|c| c.set(c.get() + l.size() as isize));
            unsafe { System.alloc(l) }
        }
        unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
            let _ = LIVE.try_with(|c| c.set(c.get() - l.size() as isize));
            unsafe { System.dealloc(p, l) }
        }
    }

    #[global_allocator]
    static A: Counting = Counting;

    pub fn live() -> isize {
        LIVE.with(Cell::get)
    }
}

#[cfg(test)]
mod tests {
    use super::counting::live;
    use super::*;

    #[test]
    fn new_and_drop_leave_rust_allocator_balanced() {
        let before = live();
        {
            let s = CMallocStr::new("hello").unwrap();
            assert_eq!(s.as_c_str(), c"hello");
            // CString の一時確保(6 バイト)は new の中で解放済み。
            // strdup の 6 バイトは C の malloc 側なので Rust のカウンタには現れない
            assert_eq!(live() - before, 0);
        }
        assert_eq!(live() - before, 0);
    }

    #[test]
    fn interior_nul_is_an_error_not_a_panic() {
        assert!(matches!(
            CMallocStr::new("a\0b"),
            Err(NewError::InteriorNul(_))
        ));
    }

    #[test]
    fn clone_owns_a_separate_buffer() {
        let a = CMallocStr::new("hello").unwrap();
        let b = a.clone();
        assert_ne!(a.as_c_str().as_ptr(), b.as_c_str().as_ptr());
        assert_eq!(a.as_c_str(), b.as_c_str());
        drop(a);
        assert_eq!(b.as_c_str(), c"hello"); // a の解放後も b は有効
    }

    #[test]
    fn to_string_copies_and_counts_only_the_string() {
        let s = CMallocStr::new("hello").unwrap();
        let before = live();
        let owned = s.to_string().unwrap();
        assert_eq!(owned, "hello");
        assert_eq!(live() - before, 5); // Rust 側に増えたのは String の 5 バイトだけ
        drop(s); // C のバッファは free で返る(Rust のカウンタは動かない)
        assert_eq!(live() - before, 5);
    }

    #[test]
    fn to_string_rejects_invalid_utf8() {
        // C から来る文字列は UTF-8 とは限らない(Latin-1 の 'é' = 0xE9)
        let raw = unsafe { strdup(c"caf\xe9".as_ptr()) };
        let s = unsafe { CMallocStr::from_raw(raw) }.unwrap();
        assert!(s.to_string().is_err());
        assert_eq!(s.as_c_str().to_bytes(), b"caf\xe9");
    }

    #[test]
    fn into_raw_then_from_raw_frees_exactly_once() {
        let a = CMallocStr::new("owned by C for a while").unwrap();
        let p = a.into_raw(); // ここで free されていれば次の行が use-after-free
        let b = unsafe { CMallocStr::from_raw(p) }.unwrap();
        assert_eq!(b.as_c_str(), c"owned by C for a while");
        assert!(unsafe { CMallocStr::from_raw(std::ptr::null_mut()) }.is_none());
    }

    #[test]
    fn hand_to_c_is_freed_by_rust_side_function() {
        let before = live();
        let p = hand_to_c("hello").unwrap();
        assert_eq!(live() - before, 6); // "hello\0" が Rust のアロケータから出ている
        unsafe { rust_string_free(p) };
        assert_eq!(live() - before, 0); // 同じアロケータに返った
        unsafe { rust_string_free(std::ptr::null_mut()) }; // NULL は何もしない
    }

    #[test]
    fn is_send_and_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<CMallocStr>();
        let s = CMallocStr::new("across threads").unwrap();
        let got = std::thread::spawn(move || s.to_string().unwrap())
            .join()
            .unwrap();
        assert_eq!(got, "across threads");
    }
}

実測(出題者の環境: rustc 1.96.0 / edition 2024 / Apple M1 macOS、2026-09-24)

$ cargo run
a = "hello, ffi", b = "hello, ffi", same buffer? false
to_string on another thread = "hello, ffi"
round-trip via into_raw/from_raw = "hello, ffi"
new("a\0b") = Err(InteriorNul(NulError(1, [97, 0, 98])))
hand_to_c + rust_string_free: ok

$ cargo test
running 8 tests
........
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

$ cargo clippy --all-targets   # 警告 0 件

(4) ⑤⑥ が「macOS で動いた」のにバグである理由を実測で示す

次の examples/mismatch.rs は、⑤⑥ と同じ操作を「数えるアロケータ」と「先頭にヘッダを置くアロケータ」の2通りで走らせます。後者は、jemalloc・mimalloc など実在のアロケータが独自のメタデータを持つことの代役です(実物の jemalloc / mimalloc では試していません。外部クレートなしで再現するための模型です)。

// examples/mismatch.rs — ④⑤「確保した側と違うアロケータで解放する」を観測する
// cargo run --example mismatch -- count   : Rust アロケータの収支を数える
// cargo run --example mismatch -- header  : 先頭にヘッダを置くアロケータ(jemalloc/mimalloc 等の代役)で ④ を走らせる
// cargo run --example mismatch -- header5 : 同じアロケータで ⑤ だけを走らせる
use std::alloc::{GlobalAlloc, Layout, System};
use std::ffi::{CString, c_char, c_void};
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering::Relaxed};

unsafe extern "C" {
    fn strdup(s: *const c_char) -> *mut c_char;
    fn free(p: *mut c_void);
}

static LIVE: AtomicIsize = AtomicIsize::new(0);
static HEADER_MODE: AtomicBool = AtomicBool::new(false);
const MAGIC: u64 = 0x5255_5354_414C_4C43; // "RUSTALLC"
const H: usize = 16;

struct Demo;
unsafe impl GlobalAlloc for Demo {
    unsafe fn alloc(&self, l: Layout) -> *mut u8 {
        LIVE.fetch_add(l.size() as isize, Relaxed);
        if !HEADER_MODE.load(Relaxed) {
            return unsafe { System.alloc(l) };
        }
        // 先頭 16 バイトに印を書き、その後ろを利用者に返す(実在のアロケータも独自のメタデータを持つ)
        let big = Layout::from_size_align(l.size() + H, l.align().max(H)).unwrap();
        unsafe {
            let p = System.alloc(big);
            (p as *mut u64).write(MAGIC);
            p.add(H)
        }
    }
    unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
        LIVE.fetch_sub(l.size() as isize, Relaxed);
        if !HEADER_MODE.load(Relaxed) {
            return unsafe { System.dealloc(p, l) };
        }
        unsafe {
            let base = p.sub(H);
            if (base as *const u64).read() != MAGIC {
                let msg = b"dealloc: this pointer was not allocated by the Rust global allocator\n";
                libc_write(msg);
                std::process::abort();
            }
            let big = Layout::from_size_align(l.size() + H, l.align().max(H)).unwrap();
            System.dealloc(base, big)
        }
    }
}
fn libc_write(b: &[u8]) {
    unsafe extern "C" {
        fn write(fd: i32, buf: *const c_void, n: usize) -> isize;
    }
    unsafe { write(2, b.as_ptr().cast(), b.len()) };
}
#[global_allocator]
static A: Demo = Demo;

fn main() {
    let mode = std::env::args().nth(1).unwrap_or_default();
    if mode == "header" {
        HEADER_MODE.store(true, Relaxed);
    }
    if mode != "header5" {
        // ④ strdup(C の malloc)で確保 → CString::from_raw(Rust のアロケータ)で解放
        let before = LIVE.load(Relaxed);
        let p = unsafe { strdup(c"hello".as_ptr()) };
        let s = unsafe { CString::from_raw(p) };
        drop(s);
        println!(
            "④ strdup → CString::from_raw → drop : Rust allocator live delta = {}",
            LIVE.load(Relaxed) - before
        );
    }
    if mode == "header5" {
        HEADER_MODE.store(true, Relaxed);
    }
    // ⑤ CString::into_raw(Rust のアロケータ)で確保 → C の free で解放
    let before = LIVE.load(Relaxed);
    let p = CString::new("hello").unwrap().into_raw();
    unsafe { free(p.cast()) };
    println!(
        "⑤ CString::into_raw → free           : Rust allocator live delta = {}",
        LIVE.load(Relaxed) - before
    );
}
$ cargo run --example mismatch -- count ④ strdup → CString::from_raw → drop : Rust allocator live delta = -6 ⑤ CString::into_raw → free : Rust allocator live delta = 6 $ cargo run --example mismatch -- header ; echo $? dealloc: this pointer was not allocated by the Rust global allocator 134 # abort $ cargo run --example mismatch -- header5 ; echo $? 133 # macOS の free が「自分の確保した領域ではない」ポインタを受け取り停止

(出力の「④」「⑤」はこの例のコード内のラベルで、指摘一覧の ⑤・⑥ に当たります)

(4) の回答: Rust のアロケータは「Rust が確保した領域」だけを解放する契約で、CString::from_raw を strdup の戻り値に使うと、Rust のアロケータは確保していない 6 バイトを解放します(収支 −6)。逆に into_raw の領域を C の free で返すと、Rust から見ると 6 バイトが返ってこないままです(収支 +6)。macOS で動いたのは、既定の System アロケータが内部で malloc / free を呼んでいる実装上の偶然にすぎず、#[global_allocator] を差し替える(プロダクションでは jemalloc / mimalloc がよく使われる)、Windows で DLL ごとに C ランタイムのヒープが分かれる、といった前提の変化で即座に abort か静かなヒープ破壊になります。

🪜 Step-by-Step 解説

1
① 一時値の寿命は「文の終わり」まで
let p = CString::new(s).unwrap().as_ptr(); // ← この ; で CString が drop
CMallocStr { ptr: unsafe { strdup(p) } }   // p は解放済み領域を指している

Rust の一時値は、それを含む文の終わりで破棄されます(Rust Reference の "Temporary scopes")。as_ptr() が返すのは生ポインタで、借用ではないので、借用チェッカーは CString の寿命と p を結びつけません。rustc はこの形だけは典型パターンとして認識し、dangling_pointers_from_temporaries 警告を出します。

なお、strdup(CString::new(s).unwrap().as_ptr()) と1つの文の中で使えば、一時値は文の終わりまで生きるので実は安全です。ただしこの書き方でも同じ警告が出るので、「警告が出るが安全」と「警告が出て危険」を人間が見分ける必要があります。変数に束縛する(let cs = CString::new(s)?;)のが、読み手にも lint にも誤解の余地を残さない書き方です。

2
② NULL を型から追い出す — NonNull と Result

strdup は確保に失敗すると NULL を返します(POSIX)。ptr: *mut c_char のままだと、その NULL を持った CMallocStr が普通に作れてしまい、後の as_c_str がすべて NULL 参照の危険を抱えます。NonNull<c_char> にすると「このフィールドが NULL でないこと」が型の不変条件になり、NULL の判定は構築時の1か所に集まります。

CString::new(s).unwrap() も直します。入力の途中に \0 があると CString::new は Err(NulError) を返します。ライブラリの new が利用者の入力で panic するのは API として不親切なので、NewError::InteriorNul として返します。? で書けるよう From<NulError> を実装しました。

NonNull にはもう1つ効果があります。Option<CMallocStr> のサイズが CMallocStr と同じ 8 バイトになります(NULL を None の表現に使える「ニッチ最適化」。実測: size_of がどちらも 8)。from_raw が Option<Self> を返してもコストは増えません。

3
③ Clone は「中身の複製」を書く

#[derive(Clone)] は各フィールドの Clone を呼ぶだけで、*mut c_char の Clone はアドレスのコピーです。所有型では解放責任まで複製されてしまい、二重解放になります。手書きの Clone で strdup をもう一度呼び、別の領域を確保します。テスト clone_owns_a_separate_buffer は、2つのポインタが違うこと・片方を drop しても他方が読めることを確かめています。

Clone を実装しないという選択肢もあります(下の「設計思想」の表)。

4
④ 戻り値の寿命を &self に縛る
pub fn as_c_str(&self) -> &CStr          // 省略規則で &'a self -> &'a CStr

CStr::from_ptr は unsafe fn で、戻り値の寿命は呼び出し側が自由に選べます。だから &'static CStr と書いてもコンパイルが通ります。寿命を省略して &self に結びつけると、持ち主より長く参照を使うコードが E0597 で止まるようになります(「コンパイルエラーが出た場合」に実際のエラー)。**unsafe の中で作った参照の寿命は、人間が正しく選ばないと誰も直してくれない**、というのが Day 052 の「safety invariant」の具体例です。

5
⑤ C の領域を Rust の型に「変身」させない — コピーする

into_string は「C の malloc の領域を、そのまま Rust の String として使い続けたい」という発想ですが、String は Rust のアロケータで解放されるので、取り違えは避けられません。正しくは:

  1. as_c_str().to_str() で UTF-8 を検査し(C の文字列は UTF-8 とは限らない。テスト to_string_rejects_invalid_utf8 は Latin-1 の 0xE9 で確認)
  2. to_owned() で Rust のアロケータにコピーし
  3. C の領域は Drop の free で返す

テスト to_string_copies_and_counts_only_the_string は、Rust 側のカウンタが String の 5 バイトだけ増えることを確かめています。コピーのコストは文字列長に比例しますが、境界を越えるときの唯一の安全な受け渡しです。

6
⑥ 解放関数は「確保した側」が提供する

Rust で確保した領域を C に渡すときは、解放関数も Rust が提供します。#[unsafe(no_mangle)] pub unsafe extern "C" fn rust_string_free は、C からは void rust_string_free(char *p); として呼べます(Rust 2024 では no_mangle は unsafe(...) で包む必要があります。シンボル名の衝突が UB になりうるため)。テスト hand_to_c_is_freed_by_rust_side_function は、確保で +6、rust_string_free で 0 に戻ることを確かめています。

7
into_raw は ManuallyDrop で Drop を止める

所有権を C に渡すときは「このあと Rust 側では free しない」ことを保証しなければなりません。ManuallyDrop::new(self) で包むと Drop が走らなくなり、中のポインタを取り出して返せます。

元のコードの into_string は CString::from_raw(self.ptr) の直後に mem::forget(self) を呼んでいるので、この順序だけ見れば二重解放にはなりません(問題は ⑤ のアロケータ取り違えのほう)。ただし forget を「最後に呼ぶ」書き方は、将来だれかが from_raw と forget の間に panic しうる処理(unwrap や添字アクセス)を挟んだ瞬間に、巻き戻し中の Drop と合わせて二重解放になります。**先に ManuallyDrop で包む**書き方は、この順序の問題を構造で消します(標準ライブラリの Box::into_raw や CString::into_raw も内部でこの形です)。

8
Send / Sync を自分で判断して付ける

生ポインタ(*mut T・NonNull<T>)を持つ型は、自動では Send / Sync になりません。コンパイラは「このポインタがスレッドをまたいで安全か」を知らないからです。今日の型は領域を排他的に所有し、malloc / free はスレッド安全(C11 7.22.3)なので Send、&self からは読み取りしかできないので Sync を、// SAFETY: の理由つきで付けました。Rc を内部に持つ C のオブジェクトや、「作ったスレッドでしか解放できない」API(一部の GUI ツールキットなど)を包むときは、同じ判断で付けないのが正解になります。

⚖️ 設計思想・なぜこう書くのか

Rust の所有権は、C の世界では「ドキュメントに書かれた約束」でしかありません。 strdup の man ページの「呼び出し側が free すること」、sqlite3_mprintf の「sqlite3_free で解放すること」、どれも C コンパイラは検査しません。Rust の FFI ラッパーの仕事は、その文章で書かれた所有権を型に翻訳することです。翻訳の結果が NonNull + Drop + 寿命つきの借用で、翻訳を誤ると(今日の6件)コンパイラは黙って通します。

今日の判断で迷った点を3つ残します。

判断1: Rust が確保した文字列を C に渡す方法(⑥ の直し方)

選択肢利点欠点今日の判断
A. Rust が解放関数 rust_string_free を公開する確保と解放が同じアロケータ。#[global_allocator] を変えても壊れないC 側に「free ではなくこれを呼ぶ」という規則が1つ増える採用。SQLite(sqlite3_free)、OpenSSL(OPENSSL_free)、libcurl(curl_free)など主要な C ライブラリ自身がこの形
B. Rust 側で strdup / malloc を呼んで C のヒープに確保し、C に free させるC 側はいつもの free で済む確保が C の関数経由になり、Rust の型の恩恵(String の容量管理など)を使えないC 側の API が「free で解放する」と既に決まっているとき(既存 C コードのコールバック規約など)
C. C 側がバッファを用意し、Rust は書き込むだけ(snprintf 型)所有権が境界を越えない。いちばん事故が少ない必要な長さを先に問い合わせる2段階呼び出しが要る頻繁に呼ぶ API や、C 側でメモリを管理したいとき(組み込みなど)

前提が変わると判断も変わります。Windows の DLL では、DLL ごとに C ランタイムのヒープが別になりうるため、B でも「DLL 側の malloc と EXE 側の free」の取り違えが起きます(Microsoft のドキュメントが CRT オブジェクトを DLL 境界で渡す危険として明記)。どの OS でも通用するのは A と C です。

**判断2: Clone をどうするか(③ の直し方)**

選択肢利点欠点今日の判断
A. 手書きの Clone で strdup し直すString と同じ感覚で使える複製のたびに C のヒープ確保。clone は Result を返せないので確保失敗は panic で扱うしかない採用(Vec / String も確保失敗は abort 扱いで、標準ライブラリと揃う)
B. Clone を実装しない最も単純。複製したければ to_string で Rust 側にコピーさせる使う側が少し不便大きなバッファや、複製の意味が曖昧なハンドル(ファイル・ソケット)ならこちら
C. Rc / Arc で共有し、最後の1つが free複製が安い参照カウントの分だけ重く、Send の判断も変わる同じ C オブジェクトを多数で読み回すとき

**判断3: into_string を「コピー」にするか「ゼロコピー」を狙うか(⑤ の直し方)**

ゼロコピーで String にする方法は、Rust のアロケータが malloc と同一である保証が無い限り存在しません。コピーのコストは長さに比例しますが、「C の領域は C の free で、Rust の領域は Rust の dealloc で」という規則を例外なく守れるのはコピーだけです。コピーが本当にボトルネックになる場合は、String にせず &CStr / &[u8] のまま処理する(as_c_str().to_bytes())のが次の手です。

🚨 コンパイルエラーが出た場合

以下はすべて、出題者の環境(rustc 1.96.0、edition 2024、aarch64 macOS)で実際に出た出力です。

警告1: 一時値の CString からポインタを取った(①)

warning: this creates a dangling pointer because temporary `CString` is dropped at end of statement --> src/main.rs:16:42 | 16 | let p = CString::new(s).unwrap().as_ptr(); | ------------------------ ^^^^^^ pointer created here | | | this `CString` is dropped at end of statement | = help: bind the `CString` to a variable such that it outlives the pointer returned by `as_ptr` = note: a dangling pointer is safe, but dereferencing one is undefined behavior = note: `#[warn(dangling_pointers_from_temporaries)]` on by default

読み方: 下線が2本あり、------ の範囲が「文末で破棄される一時値」、^^^^^^ が「そこからポインタを作った場所」です。note の「dangling pointer を作ること自体は安全、デリファレンスが UB」は Day 051 の「生成は安全・デリファレンスだけが危険」と同じ話です。これは error ではなく warning なので、ビルドは通ります。 CI では RUSTFLAGS="-D warnings" などで警告をエラー扱いにしておかないと、今日の同僚のように見落とします。

エラー1: ④ を直した後、持ち主より長く参照を使った

修正版の main の先頭にシナリオ 3 と同じコードを足してビルドした結果です。

error[E0597]: `a` does not live long enough --> src/main.rs:145:13 | 144 | let a = CMallocStr::new("hello, dangling").unwrap(); | - binding `a` declared here 145 | r = a.as_c_str(); | ^ borrowed value does not live long enough 146 | } | - `a` dropped here while still borrowed 147 | println!("{r:?}"); | - borrow later used here

これは直した結果として出てほしいエラーです。シナリオ 3 のコードが、実行時の use-after-free からコンパイル時のエラーに変わりました。読み方はこれまでの借用エラーと同じで、「宣言」「借用」「drop」「後で使用」の4点が順に示されています。

エラー2: derive(Clone) の代わりに Copy も付けようとした

error[E0184]: the trait `Copy` cannot be implemented for this type; the type has a destructor --> src/main.rs:290:12 | 289 | #[derive(Clone, Copy)] | ---- in this derive macro expansion 290 | pub struct Bad { p: *mut u8 } | ^^^ `Copy` not allowed on types with destructors

Drop を持つ型は Copy にできません。Rust は「暗黙のビットコピー」と「解放処理」の両立を言語レベルで禁止しています。ところが **Clone は明示的な呼び出しなので禁止されません**。③ がコンパイルを通るのはこのためで、derive(Clone) が浅いコピーでよいかは人間が判断します。

エラー3: unsafe impl Send を書かずにスレッドへ渡した

error[E0277]: `NonNull<i8>` cannot be sent between threads safely --> src/main.rs:149:36 | 149 | let moved = std::thread::spawn(move || b.to_string().unwrap()) | ------------------ -------^^^^^^^^^^^^^^^^^^^^^^^ | | | | | `NonNull<i8>` cannot be sent between threads safely | required by a bound introduced by this call | = help: within `{closure@src/main.rs:149:36: 149:43}`, the trait `Send` is not implemented for `NonNull<i8>` note: required because it appears within the type `CMallocStr`

note をたどると「クロージャが CMallocStr を持ち、その中の NonNull<i8> が Send でない」と原因の連鎖が分かります。ここで**何も考えずに unsafe impl Send を足すのは危険**です。Step 8 のとおり、包んでいる C のオブジェクトが本当に別スレッドで使って・解放してよいかを確かめてから付けます(i8 は aarch64 macOS の c_char。aarch64 Linux なら u8 と表示されます)。

実行時: 二重解放(③)

$ cargo run -- 2 ; echo $? "hello" "hello" 133

macOS の malloc は二重解放を検出すると SIGTRAP で止めます(終了コード 133 = 128 + 5)。メッセージは何も出ませんでした。Linux の glibc では free(): double free detected in tcache 2 のようなメッセージとともに abort するのが一般的です(Linux では未実測)。「落ちた」という事実だけで原因の Drop を特定するのは難しく、今日のように所有権の表を書いて読むレビューのほうが早く見つかります。

🌐 他言語との比較

観点C++Go(cgo)Java(FFM API)Python(ctypes)Rust
C の malloc 領域を所有する型std::unique_ptr<char, decltype(&free)> で free を削除子に指定型は無い。defer C.free(unsafe.Pointer(p)) を手で書くMemorySegment を Arena に紐づけ、Arena を閉じると解放型は無い。libc.free を手で呼ぶ所有型 + Drop(今日の CMallocStr)
言語の文字列を C 文字列にするstd::string::c_str()(string が生きている間だけ有効)C.CString(s) は C の malloc で確保し、呼び出し側が C.free するarena.allocateFrom(s)(Arena の寿命に従う)c_char_p(b"...") / create_string_bufferCString::new(s)?(Rust のアロケータ。into_raw したら Rust 側で解放)
寿命の誤り(④)の検出検出しない(c_str() のダングリングは古典的なバグ)検出しないArena を閉じた後のアクセスは実行時に例外検出しない寿命を正しく書けばコンパイル時に E0597
二重解放(③)の防止unique_ptr はコピー禁止(ムーブのみ)で防ぐ防がないArena 単位で1回だけ解放防がないDrop 型は Copy 不可。ただし Clone の中身は人間の責任

Go 経験者向けの要点: cgo の C.CString は C のヒープに確保するので C.free で正しく解放できます。Rust の CString は Rust のヒープに確保するので、free してはいけません。名前が似ていて逆なので、Go から来た人がちょうど ⑥ の誤りを書きやすい点に注意してください。

Python 経験者向けの要点: ctypes で C 関数の restype = c_char_p にすると、戻り値が自動で bytes に変換され元のポインタが失われるため、strdup のような「呼び出し側が free する」関数ではメモリリークになります。restype = c_void_p で受けて自分で解放するのが定石で、今日の「コピーしてから free」と同じ構造です。

🏭 実務での使いどころ

  • C ライブラリの文字列を返す API のラッパー: openssl クレートの OpensslString は OPENSSL_free を Drop で呼ぶ所有型、git2 クレートの Buf は libgit2 の git_buf を git_buf_dispose で解放する所有型です。構造は今日の CMallocStr と同じで、「解放関数を型に閉じ込める」のが実務の標準形です
  • Rust で書いたライブラリを C から使わせる(cdylib): Rust 製ライブラリを C / Python / Ruby から呼ばせるとき、文字列やバッファを返す関数には必ず対になる xxx_free 関数を公開します。今日の hand_to_c + rust_string_free がその最小形です
  • グローバルアロケータの差し替え: 性能のために jemalloc(tikv-jemallocator)や mimalloc を #[global_allocator] に設定するのはサーバでよくある最適化です。そのとき ⑤ ⑥ 型の取り違えが潜んでいると、アロケータを変えた PR で初めてクラッシュします。原因の PR と原因のコードが別になるため、調査が難しい種類の障害です
  • コードレビューの観点表: FFI のラッパーをレビューするときは、今日の「誰が確保/誰が持つ/何で解放/NULL/寿命」の5列の表を関数ごとに埋めるのが実用的です。rustc と clippy は今日の6件中1件しか見つけなかったので、この表が機械の代わりになります。動的な検査を足すなら、Miri(Rust の UB 検出インタプリタ)は既定では FFI 呼び出しの先(strdup / free)を実行できないため今日のコードには向かず、AddressSanitizer(nightly の -Zsanitizer=address)が候補です(今日は未実測)

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
C が返したポインタを CString::from_raw で受け取る「C 文字列を所有する型 = CString」という連想from_raw は into_raw の戻り値専用。C が確保した文字列は free を呼ぶ自前の所有型で包むか、CStr として借りてコピーする
CString::into_raw の戻り値を C の free で解放させるGo の C.CString + C.free の感覚、macOS/Linux で動いてしまう確保したアロケータで解放する。Rust 側から解放関数を公開する
「手元で落ちないから大丈夫」UB は「必ず落ちる」ではなく「何が起きてもよい」今日の実測では6件中クラッシュは1件。残りは空文字列・別の文字列・何も起きない、という形で現れた
生ポインタを持つ型に #[derive(Clone)] を付けるいつもの習慣所有権を持つポインタなら浅いコピーは二重解放。Clone を手書きするか、実装しない
unsafe の中で作った参照に 'static を付けるコンパイルが通るので正しいと思うCStr::from_ptr などの戻り値の寿命は呼び出し側が選べる。持ち主(&self)に結びつける
警告は後で直せばよいビルドが通るdangling_pointers_from_temporaries は今日の6件で唯一機械が見つけたもの。CI で -D warnings にする
mem::forget(self) を最後に呼べば Drop を止められる順序を意識しないforget より前で panic すると届かず、巻き戻しで Drop が走る。先に ManuallyDrop::new(self) で包む

📖 一次資料(参照日: 2026-09-24。記述は各資料の要旨で、原文の引用ではありません)

🧭 今日扱わなかった範囲(次回以降)

FFI テーマは今日で2問目です。今日は「文字列1本の所有権」に絞りました。

  • C から Rust を呼ぶ方向の本格版: cdylib としてビルドし、cbindgen で rust_string_free を含むヘッダを生成して、実際の C プログラムからリンクする(今日は Rust 内で呼ぶだけで、C コンパイラを使った検証はしていません)
  • ユーザーデータ付きコールバック: void *userdata 経由でクロージャを渡す(Box::into_raw で所有権を C に預け、コールバック終了時に Box::from_raw で回収する)。今日の into_raw / from_raw の応用
  • 構造体・配列の所有権: C が確保した構造体の配列(struct foo **list, size_t n)を包む型、Vec::from_raw_parts を C の領域に使ってはいけない理由
  • 動的検査: AddressSanitizer / Valgrind で今日の ③④⑤ を検出できるかの実測(nightly が必要なため割愛)
  • Linux での実測: 今日の終了コードと表示は macOS のもの。glibc での二重解放メッセージは一般的な挙動の記述で、未実測

🚀 次のステップ

  • 発展: examples/mismatch.rs の Demo アロケータを修正版のテストに組み込み、CMallocStr の全メソッドを通した後に Rust 側の収支が 0 になること、dealloc が一度も「知らないポインタ」を受け取らないことを1本のテストで確かめる。そのうえで、わざと to_string を元の CString::from_raw 版に戻し、テストが abort で止まることを確認する
  • 次回予告: FFI ③ C から Rust を呼ぶ — void *userdata 付きコールバックで Rust のクロージャを C に渡し、Box::into_raw / from_raw で所有権を往復させる(qsort_r の引数順が macOS と glibc で違う問題も扱う)

🎯 自己評価(解いた後に記入)

自分の回答(見つけた指摘の数と内容)

気づき・メモ