文字列とパス

このページの目次

RustのString/strは有効なUTF-8を維持しますが、オペレーティングシステムのパスは必ずしもUTF-8で表現できるとは限りません。データがテキスト、生バイト、またはシステムパスのいずれを表しているかをまず判断し、情報損失のない型と操作を選択する必要があります。

所有と借用は第1の軸

所有内容借用ビュー保証
Stringstr / &str有効なUTF-8テキスト
Vec[u8] / &[u8]任意のバイト、テキストエンコーディングは保証しない
OsStringOsStr / &OsStrプラットフォーム文字列、表現可能な内容を保持
PathBufPath / &Pathプラットフォームのパスコンポーネントセマンティクス

&strはStringの一部を借用できます。文字列リテラルを指すだけでなく、自動的に'staticライフタイムを持つわけではありません。Stringの安全なAPIはUTF-8不変条件を維持しますが、unsafeで構築または変更する場合、呼び出し手はそれを維持し続けなければならず、コンパイラがすべてのメモリ操作を自動的に検証するわけではありません。String

バイト、char、表示文字は異なる

UTF-8バイト:6バイト65 | CC 81 | E4 B8 ADUnicodeスカラー値:3個U+0065、U+0301、U+4E2D拡張グラフームクラス:通常2個同じテキストでも、カウント単位が異なる:e + 結合アクセント + 中e + 結合アクセントが1つのクラスを構成;中がもう1つのクラスを構成RustのcharはUnicodeスカラー値を表します。ユーザーが認識する文字や端末での表示幅には、他のルールが必要です。
fn main() {
    let text = "e\u{301}中";
    assert_eq!(text.len(), 6);
    assert_eq!(text.chars().count(), 3);
    assert_eq!(text.char_indices().collect::<Vec<_>>(),
               [(0, 'e'), (1, '\u{301}'), (3, '中')]);
    assert_eq!(text.get(1..3), Some("\u{301}"));
    assert_eq!(text.get(2..3), None); // 2はUTF-8エンコーディングの途中にある
}

lenはバイト数をカウントし、charsはUnicodeスカラー値を反復処理します。Unicodeにおける「コードポイント」には、Rustのcharとして不適切なサロゲートなども含まれるため、正確に議論する際には「スカラー値」という概念を使用すべきです。

ユーザーが見る1文字は複数のスカラー値で構成される場合があり、絵文字シーケンスが常に4バイトのスカラー値1つだけである必要はありません。エディタでのカーソル移動やユーザー名の切り捨てなどのタスクには、通常、グラフームクラス分割ルールが必要です。表示される列幅はまた別の問題です。Unicodeテキスト分割

インデックスはまず単位を選択する必要がある

Rustには、text[n]で「n文字目」を返すインターフェースは提供されていません。なぜなら、この言葉はバイト、スカラー値、またはグラフームクラスのいずれかを指す可能性があり、コストとセマンティクスが異なるからです。

  • nバイト目の読み取り:text.as_bytes().get(n)。
  • nスカラー値目の読み取り:text.chars().nth(n)。通常、前方のエンコーディングをスキャンする必要がある。
  • バイト範囲によるテキストの借用:text.get(a..b)。範囲およびUTF-8境界が有効である必要がある。
  • グラフームクラスが必要:対応するUnicode分割ルールを実装するライブラリを採用し、そのUnicodeバージョンを明確にする。

無効な範囲または境界で直接&text[a..b]を行うとパニックします。getはOptionを使用して失敗を表します。ループ内で繰り返しchars().nth(i)を行うと、前方スキャンが重複して二次的な計算量になる可能性があるため、通常はchars/char_indicesの反復を1回で完了させるべきです。

有効なUTF-8であっても、Unicode正規化済みであるとは限りません。プリコンポーズされたéとe+結合アクセントは、通常、文字列の等値比較では等しく見なされません。検索や一致にどのような正規化戦略が必要かは、ビジネスロジックが明確に決定すべきです。

バジェットを制限されたバイト数で前方に移動し、有効な境界を探す

fn prefix_bytes(text: &str, budget: usize) -> &str {
    let mut end = budget.min(text.len());
    while !text.is_char_boundary(end) {
        end -= 1;
    }
    &text[..end]
}
fn main() {
    assert_eq!(prefix_bytes("中ab", 2), "");
    assert_eq!(prefix_bytes("中ab", 4), "中a");
    assert_eq!(prefix_bytes("中ab", 100), "中ab");
    assert_eq!(prefix_bytes("e\u{301}", 1), "e");
}

これは、「指定されたUTF-8バイト数を超えず、結果が有効なテキストである」という契約を満たします。最後のアサートは、その境界を露呈しています。結合アクセントを文字の後ろから切り離すことができます。ネットワークフィールドのバイト上限には適しているかもしれませんが、ユーザーに表示されるニックネームには、グラフームクラスルールに基づいて切り捨て点を決定する必要があります。返されるのは元のテキストの借用であり、新しいStringは作成されません。そのため、借用がまだ使用されている間に、元の文字列を任意に変更することはできません。

テキストの等値比較は正規化を自動的に行わない

fn main() {
    let composed = "\u{e9}";
    let decomposed = "e\u{301}";
    assert_ne!(composed, decomposed);
    assert_eq!(composed.len(), 2);
    assert_eq!(decomposed.len(), 3);
    assert_eq!("a\nb\n".lines().collect::<Vec<_>>(), ["a", "b"]);
    assert_eq!("a\nb\n".split('\n').collect::<Vec<_>>(), ["a", "b", ""]);
}

linesとsplitのどちらを選択するかは、プロトコルによっても異なります。前者は行セマンティクスで処理し、後者は区切り文字で末尾の空セグメントを保持します。空フィールドを保持したり、改行を正確に保持したりする必要がある場合、簡潔さのために安易に置き換えてはいけません。正規化、大文字小文字の変換、空白の除去も同様に、データ解釈の戦略に属し、入力境界で明確にし、表示や監査のために必要な元の表現を保持する必要があります。

UTF-8 の境界と見える文字の境界は違う

バイト上限の切り詰めでは、UTF-8 を壊さないことと、見える文字の分割を許すかを別々に考えます。2・3 バイトは「中」の符号の途中、5 バイトは有効な UTF-8 でも e のアクセントを切り離します。境界ごとに異なる方針が必要です。

図を準備しています
UTF-8 の境界と見える文字の境界は違う

有効な UTF-8 で切っても、e と結合アクセントを分離することがあります。

バイトからテキストへの変換で、損失の許容を明確にする

String::from_utf8は所有するバイトベクトルをチェックし、失敗時はエラーから元のバイトを取り戻すことができます。str::from_utf8は借用スライスに対してチェックを行います。from_utf8_lossyは不正なシーケンスを置き換え文字で表示し、特定のログには適していますが、損失のない戻しが必要なプロトコルやファイル名には適していません。

Stringは末尾にC言語のNULバイトがあることを保証しません。C文字列との相互運用にはCString/CStrを使用し、内部のNULと外部のバッファのライフサイクルを考慮してください。C ABIとbindgenを参照。

第1チャンク:41 E4 B8完全なA + 「中」の最初の2バイト第2チャンク:AD 42「中」を補完 + 完全なB結合バイト:41 E4 B8 AD 42テキスト結果 A中BUTF-8エンコーディングの境界は、読み取りチャンクの境界と一致するとは限らないerror_len = None は、末尾が未完成であることを示す可能性があります。ストリーム終了後も未完成の場合は、切り捨てエラーとして処理する必要があります。

未完了のバイトと確定した不正なエンコーディングを区別する

fn main() {
    let mut bytes = vec![b'A', 0xe4, 0xb8];
    let error = std::str::from_utf8(&bytes).unwrap_err();
    assert_eq!(error.valid_up_to(), 1);
    assert_eq!(error.error_len(), None);
    bytes.extend([0xad, b'B']);
    assert_eq!(std::str::from_utf8(&bytes), Ok("A中B"));

    let bad = vec![b'A', 0xff, b'B'];
    let error = String::from_utf8(bad).unwrap_err();
    assert_eq!(error.utf8_error().valid_up_to(), 1);
    assert_eq!(error.utf8_error().error_len(), Some(1));
    assert_eq!(error.into_bytes(), [b'A', 0xff, b'B']);
}

第1チャンクを読み取ったとき、末尾のE4 B8は単に「中」がチャンクを跨いでいるだけかもしれないため、すぐに破棄したり置き換えたりすることはできません。プロトコルがUTF-8ストリームを規定している場合、確認済みのプレフィックスを出力し、未完成の末尾を保持し、次のチャンクで継続してチェックできます。メッセージやストリームが終了している場合は、切り捨てを報告するか、明示的な置き換え戦略を実行する必要があります。読み取りチャンクごとに個別にfrom_utf8_lossyを行うと、本来有効なチャンクを跨ぐ文字が置き換え文字になってしまいます。Utf8Error

この例では、境界を示すためにすべてのバイトをVecに保存しています。実際のストリーミング実装では、累積バッファサイズを制限し、確認済みの長いプレフィックスを毎回先頭から再検証しないようにする必要があります。

OsStrは特定の永続化可能な内部エンコーディングを保証しない

Unixのファイル名コンポーネントにはUTF-8以外のバイトを含めることができますが、NULを含めることはできず、スラッシュはパスの区切り文字です。Windowsのシステムワイド文字インターフェースは16ビットエンコーディングユニットを使用しており、有効なUnicodeスカラーシーケンスとして扱えない内容が含まれる可能性があります。Windowsファイルシステムのネイティブフォーマットを単にWTF-8と呼ぶことはできません。

RustのWindows内部表現はWTF-8と実装上の関連がありますが、クロスプラットフォームなOsStr抽象化は、アプリケーションが特定の内部エンコーディングを永続化プロトコルとして依存することを許可しません。UnixではOsStrExt::as_bytesなどのプラットフォームインターフェースを使用して情報を失わずにアクセスでき、Windowsではencode_wideを使用してワイド文字ユニットを取得できます。OsStr、Windows OsStrExt

#[cfg(unix)]
fn main() {
    use std::ffi::OsString;
    use std::os::unix::ffi::OsStringExt;
    use std::path::PathBuf;
    let path = PathBuf::from(OsString::from_vec(vec![b'f', 0xff]));
    assert!(path.to_str().is_none());
    assert_eq!(path.into_os_string().into_vec(), [b'f', 0xff]);
}
#[cfg(not(unix))]
fn main() {}

Path::displayとto_string_lossyは人間が読むのに適していますが、表示結果を元のパスのアイデンティティとして再使用すべきではありません。ファイル操作では、可能な限り&Path/PathBufを渡すようにし、UTF-8への変換往復を避けてください。

Path操作はファイルシステムへのアクセスや隔離を自動的に行わない

join、parent、extension、componentsなどの主要な操作は、主にプラットフォームのパス構文に従って処理されます。joinの右側が絶対パスの場合、前の基本パスを置き換えます。Windowsでは、プレフィックスとルートパスにプラットフォーム固有のルールが存在します。Path

use std::path::Path;
fn main() {
    let path = Path::new("reports").join("2026").join("summary.txt");
    assert_eq!(path.file_name().unwrap(), "summary.txt");
    assert_eq!(path.extension().unwrap(), "txt");
}

componentsは、シンボリックリンクが意味を変更する可能性があるため、a/../bを任意に折りたたみません。canonicalizeはファイルシステムにアクセスして対応するパスを解決しますが、その結果が、その後のアクセス時にディレクトリやリンクが変更されていないことを証明するものではありません。

したがって、PathBuf::joinはパス連結を正しく表現するツールですが、「ユーザー入力が常に特定のディレクトリ内に留まることを保証する」安全な境界ではありません。タスクでアクセスルートディレクトリを制限する必要がある場合は、対象システムに応じて適切なディレクトリハンドルと解析制約を使用する必要があり、単なる文字列のプレフィックスチェックだけでは不十分です。

パスプレフィックスはコンポーネントで比較されるが、ファイルシステムの包含関係を意味しない

use std::path::{Component, Path};
fn normal_relative(path: &Path) -> bool {
    !path.as_os_str().is_empty()
        && path.components().all(|c| matches!(c, Component::Normal(_)))
}
fn main() {
    assert!(normal_relative(Path::new("reports/summary.txt")));
    assert!(!normal_relative(Path::new("../outside")));
    assert!(!normal_relative(Path::new("")));
    assert!(!Path::new("reports-old/a").starts_with("reports"));
    assert!(Path::new("reports/../outside").starts_with("reports"));
    assert!(!normal_relative(Path::new("reports/../outside")));
}

この関数は、非空であり、解析されたコンポーネントがすべてNormalであるという、1つの構文入力戦略を表しています。componentsは限定的な構文正規化を行いますが、重複する区切り文字などの元のスペル情報は保持されない場合があります。異なるプラットフォームでは、それぞれ独自の区切り文字とプレフィックスルールを使用します。これはパスの存在をチェックするものではなく、reports内のシンボリックリンクが外部を指すことを防ぐこともできません。

Path::starts_withは文字列のプレフィックスよりもコンポーネントセマンティクスに近いので、reports-oldはreportsのサブパスにはなりません。ただし、..を含むパスは、このコンポーネントプレフィックス判定を通過する可能性があります。..を拒否した場合でも、シンボリックリンクと並行したディレクトリの変更は、実際のオープン段階で処理する必要があり、上記のnormal_relativeを完全なファイルアクセス隔離ソリューションとして扱うことはできません。Pathコンポーネントとプレフィックス

表示、保存、操作インターフェースを分離する

用途適切な表現変換時に保持されるもの
テキストプロトコルフィールド&str / Stringエンコーディングエラー、長さ単位、正規化戦略
不明なエンコーディング入力&[u8] / Vec生バイト、フォーマットを確認してからデコード
現在のプラットフォームのファイル操作&Path / PathBuf元のOsStr内容とプラットフォームのコンポーネントセマンティクス
ログ内のファイル名displayまたは明示的なエスケープ表現表示には損失がある可能性があり、元のファイルの特定には使用しない
クロスプラットフォームでのパス識別子の保存明確に設計されたフォーマットプラットフォーム、ルート/プレフィックスルール、および表現できない内容の処理

ファイルAPIがパスを受け取る際、呼び出し手にAsRef<Path>または&Pathを渡すことを優先し、内部で直接パス操作を行います。強制的にStringを受け取ると、一部の有効なシステムパスが入口で除外されてしまいます。逆に、ネットワークプロトコルのリソース名がパスのように見えても、プロトコルの規定に従って解釈すべきであり、現在のオペレーティングシステムのPathルールをそのまま適用してはいけません。