[Rust 実践] #10 パスと文字コード — 読めないファイル名と、Shift_JIS のファイル

[Rust 実践] #10 パスと文字コード — 読めないファイル名と、Shift_JIS のファイル

はじめに

第8回の lsx は、ファイル名を to_string_lossy() で String にしていました。第6回の fs::read_to_string は、読めるファイルだけを相手にしていました。今回は、どちらも失敗するケースを、実際に作って動かします。

出てきた回書き方今回の節
第8回item.file_name().to_string_lossy().into_owned()ファイル名は UTF-8 とは限らない
第6回fs::read_to_string(...)ファイルの中身の文字コード
第9回「次回は、ファイル操作・パス・文字コード」全体

Path と PathBuf

ファイルの場所を表す型が、Path と PathBuf です。&str と String の関係(第2〜3回)に似ていて、Path::new("...") は借りるだけの &Path、PathBuf は所有する側で、push で書き換えられます。

use std::path::{Path, PathBuf};

fn main() {
    let p = Path::new("/home/lain/docs/report.final.txt");
    println!("{:?}", p.file_name());
    println!("{:?}", p.extension());
    println!("{:?}", p.file_stem());
    println!("{:?}", p.parent());
    println!("{:?}", Path::new("Makefile").extension());
    println!("{:?}", Path::new(".bashrc").extension());
    println!("{:?}", Path::new("/").file_name());
    println!("{:?}", Path::new("/").parent());

    let mut b = PathBuf::from("/home/lain");
    b.push("docs");
    b.push("a.txt");
    println!("{}", b.display());
    let c = Path::new("/home/lain").join("docs").join("b.txt");
    println!("{}", c.display());
    let d = Path::new("/home/lain").join("/etc/passwd");
    println!("{}", d.display());
    println!("{}", b.with_extension("md").display());
    println!("{:?}", b.to_str());
    println!("{} {}", b.exists(), Path::new("/tmp").is_dir());
}
Some("report.final.txt")
Some("txt")
Some("report.final")
Some("/home/lain/docs")
None
None
None
None
/home/lain/docs/a.txt
/home/lain/docs/b.txt
/etc/passwd
/home/lain/docs/a.md
Some("/home/lain/docs/a.txt")
false true
書き方結果
file_name()、extension()、file_stem()、parent()どれも Option を返す。Makefile や .bashrc の extension()、/ の file_name()・parent() は None
push("docs")、join("docs")パスを継ぎ足す(push は PathBuf を書き換え、join は新しい PathBuf を返す)
join("/etc/passwd")継ぎ足さず、置き換わる(結果は /etc/passwd)
with_extension("md")拡張子を差し替えた新しい PathBuf
to_str()Option<&str>。Some のとき、UTF-8 の文字列として取り出せる
display()println! の {} で表示するためのもの
exists()、is_dir()bool

join("/etc/passwd") には、cargo clippy が警告を出しました(joining a path starting with separator will replace the path instead)。この警告は、置き換わることを見せるために、わざと書いたコードへの指摘です。

ファイル名は、UTF-8 とは限らない

file_name() が返す型は、String ではなく OsString(借りる側は OsStr)です。名前のとおり、OS が決めた形のままの文字列で、UTF-8 とは限りません。

この環境(Linux)で、UTF-8 として不正なバイト列を含むファイル名を、実際に作りました。

ファイル名のバイト列意図
61 ff 2e 74 78 74 / 61 fe 2e 74 78 74a?.txt の ? の位置に、不正なバイト(2種類)
62 61 64 ff fe 6e 61 6d 65 2e 74 78 74bad + 不正な2バイト + name.txt
83 81 83 82 2e 74 78 74Shift_JIS で書いた メモ.txt
6f 6b 2e 74 78 74 / UTF-8 の 日本語.txt普通のファイル名
use std::fs;
use std::os::unix::ffi::OsStrExt;

fn main() {
    let mut names = Vec::new();
    for item in fs::read_dir("../nd").unwrap() {
        names.push(item.unwrap().file_name());
    }
    names.sort();
    for n in &names {
        println!("--- {n:?}");
        println!("to_str         : {:?}", n.to_str());
        println!("to_string_lossy: {}", n.to_string_lossy());
        println!("bytes          : {:02x?}", n.as_bytes());
        println!("into_string    : {:?}", n.clone().into_string());
    }
}
--- "a\xFE.txt"
to_str         : None
to_string_lossy: a�.txt
bytes          : [61, fe, 2e, 74, 78, 74]
into_string    : Err("a\xFE.txt")
--- "a\xFF.txt"
to_str         : None
to_string_lossy: a�.txt
bytes          : [61, ff, 2e, 74, 78, 74]
into_string    : Err("a\xFF.txt")
--- "bad\xFF\xFEname.txt"
to_str         : None
to_string_lossy: bad��name.txt
bytes          : [62, 61, 64, ff, fe, 6e, 61, 6d, 65, 2e, 74, 78, 74]
into_string    : Err("bad\xFF\xFEname.txt")
--- "ok.txt"
to_str         : Some("ok.txt")
to_string_lossy: ok.txt
bytes          : [6f, 6b, 2e, 74, 78, 74]
into_string    : Ok("ok.txt")
--- "\x83\x81\x83\x82.txt"
to_str         : None
to_string_lossy: ����.txt
bytes          : [83, 81, 83, 82, 2e, 74, 78, 74]
into_string    : Err("\x83\x81\x83\x82.txt")
--- "日本語.txt"
to_str         : Some("日本語.txt")
to_string_lossy: 日本語.txt
bytes          : [e6, 97, a5, e6, 9c, ac, e8, aa, 9e, 2e, 74, 78, 74]
into_string    : Ok("日本語.txt")
取り出し方不正なバイト列を含む名前普通の名前
to_str()NoneSome("ok.txt")
into_string()Err(元の OsString)Ok("ok.txt")
to_string_lossy()不正なバイトを U+FFFD(�)に置き換えた文字列(1バイトにつき1文字、ff fe なら ��)そのまま

(as_bytes() は、Unix 系の OS だけで使える OsStrExt のメソッドです)

lsx で、何が起きるか

第8回までの lsx に、このディレクトリを読ませると、こうなります。

             2  a�.txt
             1  a�.txt
             5  bad��name.txt
             1  ok.txt
             9  日本語.txt
             9  ����.txt
(6 件)
最大のファイル: 日本語.txt
合計: 27 B (27 バイト)

a?.txt が2行出ますが、見た目は同じで、見分けられません(実際は、別々のファイルです)。to_string_lossy は、情報を捨てて、表示できる形にします。

scan.rs の名前の作り方を、次のように変えました。

let name = match item.file_name().into_string() {
    Ok(s) => s,
    Err(os) => format!("{os:?}"),
};

into_string() が Err のときは、OsString の {:?}(デバッグ表示)で、読めないバイトを \xFF の形で見せます。

             9  "\x83\x81\x83\x82.txt"
             2  "a\xFE.txt"
             1  "a\xFF.txt"
             5  "bad\xFF\xFEname.txt"
             1  ok.txt
             9  日本語.txt
(6 件)
最大のファイル: "\x83\x81\x83\x82.txt"
合計: 27 B (27 バイト)

2つの a?.txt を、見分けられるようになりました。引用符(")で始まるので、名前順では、ほかの名前より前に並びます。cargo test(3件)も、変更後に通りました。

Java の場合

Java で、同じディレクトリのファイル名を、File.list() で取り出して、文字のコード値を表示しました。この環境の Java は、sun.jnu.encoding = ANSI_X3.4-1968(ASCII)でした。

a�.txt が2つ(どちらも 0061 fffd 002e 0074 0078 0074)
bad��name.txt(0062 0061 0064 fffd fffd 006e …)
ok.txt
日本語.txt → fffd が9個 + .txt(13 文字)
メモ.txt(Shift_JIS)→ fffd が4個 + .txt

LC_ALL=C.UTF-8 を付け、-Dsun.jnu.encoding=UTF-8 を指定して実行すると、日本語.txt は 65e5 672c 8a9e 002e 0074 0078 0074(7 文字)と、正しく取り出せました。ただし、不正なバイトを含む名前は、どちらの設定でも、fffd に置き換わった文字列です。

Java では、File.list() が返す String の時点で、不正なバイトが fffd になっていて、元のバイト列は、その String の中に残っていません。Rust では、OsString のまま持てるので、元のバイト列が残ります。

ファイルの中身の文字コード

次に、中身の文字コードが違うファイルを、fs::read_to_string、String::from_utf8、String::from_utf8_lossy で読みました。用意したのは、同じ文章(こんにちは、Rust。 で始まる2行)を、文字コードを変えて保存した、次のファイルです。

use std::fs;

fn main() {
    let path = std::env::args().nth(1).unwrap();
    match fs::read_to_string(&path) {
        Ok(s) => println!("read_to_string: OK, {} 文字", s.chars().count()),
        Err(e) => println!("read_to_string: Err kind={:?} msg={e}", e.kind()),
    }
    let bytes = fs::read(&path).unwrap();
    println!(
        "fs::read: {} バイト, 先頭 {:02x?}",
        bytes.len(),
        &bytes[..bytes.len().min(4)]
    );
    match String::from_utf8(bytes.clone()) {
        Ok(_) => println!("from_utf8: OK"),
        Err(e) => println!("from_utf8: Err {e}"),
    }
    let lossy = String::from_utf8_lossy(&bytes);
    println!(
        "from_utf8_lossy: {:?}",
        lossy.chars().take(12).collect::<String>()
    );
}
ファイルサイズread_to_stringfrom_utf8 のエラー
UTF-875OK(29 文字)なし
UTF-8(BOM 付き)78OK(30 文字。先頭に U+FEFF が残る)なし
Shift_JIS52Err、kind は InvalidData、stream did not contain valid UTF-8invalid utf-8 sequence of 1 bytes from index 0
UTF-16LE(BOM 付き)60同上同上(index 0)
ASCII のみ12OK(12 文字)なし
空0OK(0 文字)なし
UTF-8 の途中に不正な2バイト23同上の Errinvalid utf-8 sequence of 1 bytes from index 13

String(と &str)は、常に UTF-8 です。UTF-8 でないバイト列を String にしようとすると、read_to_string は io::Error(kind は InvalidData)で、from_utf8 は FromUtf8Error で、止まります。

from_utf8_lossy は、不正な部分を � に置き換えて、必ず String を返します。Shift_JIS のファイルでは、次のようになりました。

from_utf8_lossy: "����ɂ��\u{341}ARus"

� だけではなく、\u{341}(結合文字)も混ざっています。Shift_JIS の2バイトの並びの一部が、たまたま UTF-8 として有効な並びになり、別の文字として読まれた結果です。lossy で読んでも、元の文字は戻りません。

サンプル: 文字コードを UTF-8 にそろえる toutf8

read_to_string で読めないファイルを、UTF-8 に直すツールを作ります。方針は、次のとおりです。

順判定
1先頭の BOM(EF BB BF、FF FE、FE FF)があれば、その文字コードとして読む(BOM は取り除く)
2UTF-8 として読めるなら、UTF-8
3どちらでもなければ、Shift_JIS として読む
cargo new toutf8
cd toutf8
cargo add encoding_rs
use std::env;
use std::fs;
use std::process;

use encoding_rs::{Encoding, SHIFT_JIS};

struct Decoded {
    text: String,
    name: &'static str,
    had_errors: bool,
}

fn decode(bytes: &[u8]) -> Decoded {
    if let Some((enc, _)) = Encoding::for_bom(bytes) {
        let (text, had_errors) = enc.decode_with_bom_removal(bytes);
        return Decoded {
            text: text.into_owned(),
            name: enc.name(),
            had_errors,
        };
    }
    match std::str::from_utf8(bytes) {
        Ok(s) => Decoded {
            text: s.to_string(),
            name: "UTF-8",
            had_errors: false,
        },
        Err(_) => {
            let (text, had_errors) = SHIFT_JIS.decode_without_bom_handling(bytes);
            Decoded {
                text: text.into_owned(),
                name: SHIFT_JIS.name(),
                had_errors,
            }
        }
    }
}

fn run(args: &[String]) -> Result<(), String> {
    if args.len() < 2 || args.len() > 3 {
        return Err(String::from(
            "引数が正しくありません\n使い方: toutf8 入力 [出力]",
        ));
    }
    let bytes = fs::read(&args[1]).map_err(|e| format!("{}: {e}", args[1]))?;
    let d = decode(&bytes);
    let note = if d.had_errors {
        "(変換できない部分あり)"
    } else {
        ""
    };
    eprintln!("検出: {}{note}", d.name);
    match args.get(2) {
        Some(out) => fs::write(out, d.text.as_bytes()).map_err(|e| format!("{out}: {e}"))?,
        None => print!("{}", d.text),
    }
    Ok(())
}

fn main() {
    let args: Vec<String> = env::args().collect();
    if let Err(msg) = run(&args) {
        eprintln!("エラー: {msg}");
        process::exit(1);
    }
}

実行は、toutf8 入力 で標準出力に、toutf8 入力 出力 でファイルに、UTF-8 で書き出します。判定の結果は、標準エラー出力に出します。

入力検出の表示結果
UTF-8検出: UTF-8そのまま表示
UTF-8(BOM 付き)検出: UTF-8BOM を除いて出力(出力の先頭が ef bb bf から e3 81 93 に変わった)
Shift_JIS検出: Shift_JIS正しい日本語で表示。出力ファイルは、UTF-8 の元ファイルと cmp で一致
UTF-16LE(BOM 付き)検出: UTF-16LE正しい日本語で表示。出力ファイルは、UTF-8 の元ファイルと cmp で一致
ASCII のみ検出: UTF-8そのまま表示
空ファイル検出: UTF-8何も出力しない
Shift_JIS の拡張文字(①ア~㈱ー髙)検出: Shift_JISPython の cp932 で変換した結果と、cmp で一致
UTF-8 の途中に不正な2バイト検出: Shift_JIS(変換できない部分あり)文字化けした行(豁」蟶ク縺ェ陦�)が出る

最後の行は、この判定の弱点です。ほとんどが正しい UTF-8 でも、1か所でも不正なバイトがあると、UTF-8 とは認められず、Shift_JIS として読まれて、文字化けします。ただし、(変換できない部分あり) と出るので、おかしいことには、気づけます。

存在しないファイルと、引数の間違いは、第8回と同じ形(終了コード 1)です。

エラー: ../tf/none.txt: No such file or directory (os error 2)
エラー: 引数が正しくありません
使い方: toutf8 入力 [出力]

初めて出てきた書き方

書き方意味
Encoding::for_bom(bytes)先頭の BOM から、文字コードを調べる。BOM がなければ None
enc.decode_with_bom_removal(bytes)BOM を取り除いて、文字列にする。戻り値は、文字列と、変換できない部分があったかの bool
SHIFT_JIS.decode_without_bom_handling(bytes)BOM を調べずに、Shift_JIS として文字列にする
text.into_owned()借りている文字列を、所有する String にする(戻り値の正式な型 Cow は、この連載では扱っていません)
.map_err(|e| format!(...))?Err の中身を別の型(ここでは String)に変えてから、? で返す
args.get(2)3つめの引数。なければ None(Vec の get は、第6回)
&'static strname: &'static str(文字コード名)。第2回・第8回の 'static

歴史

年月できごと
1992年9月ごろUTF-8 が、ニュージャージー州の食堂で考案されたと、Rob Pike が書いている

Rob Pike の文章によると、Ken Thompson が夕食のあとにビットの詰め方を考え、その晩のうちに変換のコードを書いたとされます。Plan 9 は、それまで、ISO 10646 の最初の UTF(16 ビット文字)に対応していましたが、出荷の直前に UTF-8 へ移し、金曜までに UTF-8 だけで動いていた、と書かれています。X/Open の委員会が検討していた FSS/UTF には、途中から読み始めたときに文字の境目を見つけ直せる性質が欠けていたので、別の方式を作った、とも書かれています。

Shift JIS は、Wikipedia の記事によると、日本の ASCII Corporation が Microsoft と共同で開発したとされ、JIS X 0208 の附属書 1 として標準化されました。Microsoft が広めた拡張は、IANA には Windows-31J として登録されています。WHATWG の Encoding Standard は、shift_jis と windows-31j のラベルを同じものとして扱い、IBM と NEC の拡張を含めています。encoding_rs の SHIFT_JIS で、①(NEC 特殊文字)、髙(IBM 拡張)を含む文字列が、Python の cp932 と同じ結果になりました。WHATWG の扱いと、矛盾しない結果です。

動作確認

確認できたこと

  • 上のコードと出力は、すべて Linux(Rust 1.97.0、Cargo 1.97.0、edition 2024)で、実際に実行した結果です。Java も、同じ環境(OpenJDK 21)で実行しました
  • Path / PathBuf の各メソッドの結果(Option が None になる場合、join が置き換わる場合、clippy の警告)
  • 非 UTF-8 のファイル名(この環境の Linux で作れたこと)、to_str・into_string・to_string_lossy の結果、lsx で2つの名前が同じ表示になること、into_string と {:?} による対処後の表示、cargo test の成功
  • read_to_string・from_utf8・from_utf8_lossy の、7種類のファイルに対する結果。BOM が残ること(30 文字)
  • toutf8 の、8種類の入力に対する検出と出力。cmp での一致の確認。cargo clippy --all-targets と cargo fmt が指摘なしであること
  • Java の File.list() の結果(sun.jnu.encoding が ASCII のときと、UTF-8 を指定したとき)、Files.readString の MalformedInputException: Input length = 1、windows-31j を指定すると Shift_JIS のファイルが読めること(29 文字、先頭は 3053)、BOM が残ること(30 文字)

歴史の出典

  • UTF-8 の経緯は、Rob Pike の文章(doc.cat-v.org/bell_labs/utf-8_history)の記述です。取得ツールが返した要約を通して確認しました。文章には、2003年の追記で、Russ Cox が当時のメールを調べたことも書かれています。この記事では、そこには触れていません
  • Shift JIS の経緯は、Wikipedia「Shift JIS」の記述です。取得ツールの要約を通して確認しました。記事に作成年の記載はないため、年は書いていません。1つの出典だけです

確認できていないこと

  • Windows と macOS での、パスと OsStr の動き(区切り文字、非 UTF-8 のファイル名、OsStrExt が Unix 系だけであること)
  • EUC-JP と ISO-2022-JP の判定。toutf8 は、BOM、UTF-8、Shift_JIS の3通りだけです
  • UTF-8 としても Shift_JIS としても読めてしまうバイト列の扱い(toutf8 は UTF-8 を優先します)
  • 大きなファイルを、一度にメモリに読み込む点(fs::read)の影響
  • 既存のファイルへの上書き(toutf8 入力 出力 の、出力が既にある場合)
  • Java で、fffd を含むファイル名から、元のファイルを開けるか
  • encoding_rs の内部の動き。この記事で確認したのは、表の入出力だけです

次回

次回は、**小さな CLI を作り込む(簡易アーカイバ)**を扱います。複数のファイルを、1つにまとめて書き出して、また取り出すツールを作ります。

連載の目次(予定)

入門編(8回・完結)

  1. Rust とは / 環境構築 / Hello World
  2. 変数・型・関数(Java との対比)
  3. 所有権(ムーブ)
  4. 借用と参照
  5. 構造体・enum・match
  6. Result / Option とエラー処理
  7. トレイトとジェネリクス(Java の interface との違い)
  8. ライフタイム入門と、総まとめの CLI

実践編(8回・予定。内容は、書きながら変わる可能性があります)

  1. Cargo・クレート・モジュール構成
  2. ファイル操作・パス・文字コード(今回・連載の第10回)
  3. 小さな CLI を作り込む(簡易アーカイバ)
  4. Tauri v2 の構造(フロントと Rust の役割分担)
  5. コマンドとイベントの橋渡し
  6. グローバルホットキー常駐(shotkey を分解)
  7. 設定の保存・トレイ・自動起動
  8. ビルド・署名・GitHub リリース