Rustは「安全な言語」として知られています。特にメモリ安全性の面では優れた言語設計が施されていますが、それだけですべての問題が解決するわけではありません。コンパイラは多くのバグを捕捉してくれますが検出できない落とし穴も存在します。
上記の記事にRustで安全なコードを書く際によく遭遇する落とし穴と、それらを避けるためのベストプラクティスが非常によくまとまっていました。いくつか紹介します。
Rustコンパイラが検出できない問題の種類
Rustのコンパイラは優秀ですが、以下のような問題は検出できません:
- 数値型の変換ミスによるオーバーフロー
- ロジックのバグ
-
unwrap
やexpect
の使用による意図しないパニック - サードパーティクレートの問題のある
build.rs
スクリプト - 依存ライブラリの不適切なunsafeコード
- 競合状態
それでは、よくある落とし穴とその対策を見ていきましょう。
1. 整数オーバーフローに気をつける
整数のオーバーフローはよくある問題です。次のコードを見てみましょう:
// 良くない例:オーバーフローの可能性がある
fn calculate_total(price: u32, quantity: u32) -> u32 {
price * quantity // 大きな数値の場合、オーバーフローする可能性あり!
}
この例では、price
とquantity
が大きな値の場合、結果がオーバーフローする可能性があります。デバッグモードではパニックしますが、リリースモードではラップアラウンドします(値が最大値を超えると0から再度カウントを始める)。
対策:チェック付き算術演算を使う
// 良い例:チェック付き算術演算
fn calculate_total(price: u32, quantity: u32) -> Result<u32, ArithmeticError> {
price.checked_mul(quantity)
.ok_or(ArithmeticError::Overflow)
}
checked_mul
などのメソッドを使うと、オーバーフローが発生した場合にNone
を返してくれるので、適切にエラー処理ができます。
また、Cargo.toml
に以下を追加しておくとリリースビルドでもオーバーフローチェックが有効になるそうです。覚えておきたいですね:
[profile.release]
overflow-checks = true
2. 数値型の変換時はas
を安易に使わない
数値型の変換にはよくas
キーワードが使われますが、これは安全でない場合があります:
let x: i32 = 1000;
let y: i8 = x as i8; // 値が範囲外なので切り捨てられる!
この例では、i32
からi8
への変換で値が切り捨てられ、予期しない結果になります。
対策:安全な変換方法を使う
Rustには3つの主要な数値変換方法があります:
-
as
キーワード:便利だが安全でない(値が切り捨てられる可能性あり) -
From::from()
:データ損失のない変換のみ許可(拡大変換に最適) -
TryFrom
:変換が失敗する可能性がある場合にResult
を返す
// 良い例:TryFromを使った安全な変換
use std::convert::TryFrom;
let x: i32 = 1000;
let y = i8::try_from(x).map_err(|_| "値が大きすぎます")?;
この方法なら、変換が失敗した場合にエラー処理ができます。
3. 数値に境界つき型を使う
数値が特定の条件を満たす必要がある場合、単純なプリミティブ型ではなく、境界つき型を使いましょう。
// 良くない例:無制限の数値型
struct Product {
price: f64, // マイナスの可能性あり!
quantity: i32, // マイナスの可能性あり!
}
対策:カスタム型で制限を設ける
// 良い例:境界つき型
#[derive(Debug, Clone, Copy)]
struct NonNegativePrice(f64);
impl NonNegativePrice {
pub fn new(value: f64) -> Result<Self, PriceError> {
if value < 0.0 || !value.is_finite() {
return Err(PriceError::Invalid);
}
Ok(NonNegativePrice(value))
}
}
struct Product {
price: NonNegativePrice,
quantity: std::num::NonZeroU32, // 0以外の正の整数のみ
}
この方法では、不正な値を持つオブジェクトが作れなくなります。
4. 配列のインデックスアクセスはget
メソッドを使う
配列やベクトルへの直接のインデックスアクセスは危険です:
let arr = [1, 2, 3];
let elem = arr[3]; // 範囲外アクセスでパニック!
対策:get
メソッドでオプション値を返す
let arr = [1, 2, 3];
if let Some(elem) = arr.get(3) {
println!("4番目の要素: {}", elem);
} else {
println!("4番目の要素はありません");
}
5. split_at
よりsplit_at_checked
を使う
スライスを分割する際も同様に注意が必要です:
let arr = [1, 2, 3];
let (left, right) = arr.split_at(4); // 範囲外でパニック!
対策:チェック付きの分割方法を使う
let arr = [1, 2, 3];
match arr.split_at_checked(3) {
Some((left, right)) => println!("分割成功: {:?} と {:?}", left, right),
None => println!("インデックスが範囲外です")
}
6. ビジネスロジックではプリミティブ型を避ける
文字列などのプリミティブ型をそのまま使うと、バリデーションが困難になります:
// 良くない例:制限のないString型
fn authenticate_user(username: String) {
// ユーザー名が空かも?特殊文字が含まれているかも?
}
対策:バリデーション付きのカスタム型を作る
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Username(String);
impl Username {
pub fn new(name: &str) -> Result<Self, UsernameError> {
if name.is_empty() {
return Err(UsernameError::Empty);
}
if name.len() > 30 {
return Err(UsernameError::TooLong);
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Err(UsernameError::InvalidCharacters);
}
Ok(Username(name.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
fn authenticate_user(username: Username) {
// ユーザー名は常に有効!
}
この方法なら、関数に渡される時点で値が有効であることが保証されます。
7. 無効な状態を型システムで表現できないようにする
フィールドの組み合わせによっては無効な状態が生じることがあります:
// 良くない例:不正な組み合わせが可能
struct Configuration {
port: u16,
host: String,
ssl_enabled: bool,
ssl_cert: Option<String>, // ssl_enabled=trueなのに証明書がNoneという状態が可能
}
対策:列挙型で有効な状態のみを表現する
// 良い例:型で状態を強制
enum ConnectionSecurity {
Insecure,
Ssl { cert_path: String }, // 証明書なしのSSLは表現できない
}
struct Configuration {
port: u16,
host: String,
security: ConnectionSecurity,
}
この方法では、「SSL有効だが証明書がない」という無効な状態を表現できなくなります。
8. Default
の実装は慎重に
何も考えずにDefault
を実装すると問題が生じることがあります:
// 良くない例:不適切なデフォルト値
#[derive(Default)]
struct ServerConfig {
port: u16, // 0になる(有効なポートではない)
max_connections: usize, // 0になる
timeout_seconds: u64, // 0になる
}
対策:意味のあるデフォルト値を提供するか、Defaultを実装しない
// 良い例:明示的な初期化
struct ServerConfig {
port: Port,
max_connections: std::num::NonZeroUsize,
timeout: std::time::Duration,
}
impl ServerConfig {
pub fn new(port: Port) -> Self {
Self {
port,
max_connections: NonZeroUsize::new(100).unwrap(),
timeout: Duration::from_secs(30),
}
}
}
9. Debug
実装時は機密情報に注意
Debug
トレイトを自動導出すると、機密情報が漏洩する恐れがあります:
// 良くない例:機密情報が表示される
#[derive(Debug)]
struct User {
username: String,
password: String, // デバッグ出力で見えてしまう!
}
対策:機密情報を含む型はDebug
を手動実装する
#[derive(Debug)]
struct User {
username: String,
password: Password,
}
struct Password(String);
impl std::fmt::Debug for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED]")
}
}
これにより、デバッグ出力時にパスワードが「[REDACTED]」と表示されるようになります。
また、次のような実装もあります:
impl std::fmt::Debug for DatabaseURI {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// 構造体を分解して、フィールドが追加された場合にコンパイルエラーになるようにする
let DatabaseURI { scheme, user, password: _, host, database, } = self;
write!(f, "{scheme}://{user}:[REDACTED]@{host}/{database}")?;
Ok(())
}
}
10. シリアライズ・デシリアライズは慎重に
serde
のSerialize
とDeserialize
を自動導出すると、思わぬ問題が生じることがあります:
// 良くない例:チェックなしのシリアライズ
#[derive(Serialize, Deserialize)]
struct UserCredentials {
#[serde(default)] // デシリアライズ時に空文字を受け入れる
username: String,
#[serde(default)]
password: String, // シリアライズ時に平文で出力される
}
対策:カスタム実装で検証を組み込む
#[derive(Deserialize)]
#[serde(try_from = "String")]
pub struct Password(String);
impl TryFrom<String> for Password {
type Error = PasswordError;
fn try_from(value: String) -> Result<Self, Self::Error> {
if value.len() < 8 {
return Err(PasswordError::TooShort);
}
Ok(Password(value))
}
}
この方法では、デシリアライズ時に自動的に検証が行われます。
11. 時間差攻撃から守る
機密データを比較する際は、実行時間の差から情報が漏れる可能性があります:
// 良くない例:単純な比較
fn verify_password(stored: &[u8], provided: &[u8]) -> bool {
stored == provided // タイミング攻撃に弱い
}
対策:定数時間比較を使用する
// 良い例:定数時間比較
use subtle::{ConstantTimeEq, Choice};
fn verify_password(stored: &[u8], provided: &[u8]) -> bool {
stored.ct_eq(provided).unwrap_u8() == 1
}
12. 入力サイズを制限する
無制限の入力を受け付けると、DoS攻撃の原因になります:
// 良くない例:サイズ制限なし
fn process_request(data: &[u8]) -> Result<(), Error> {
let decoded = decode_data(data)?; // 巨大なデータでメモリを使い果たす可能性
// 処理
Ok(())
}
対策:明示的なサイズ制限を設ける
// 良い例:明示的なサイズ制限
const MAX_REQUEST_SIZE: usize = 1024 * 1024; // 1MiB
fn process_request(data: &[u8]) -> Result<(), Error> {
if data.len() > MAX_REQUEST_SIZE {
return Err(Error::RequestTooLarge);
}
let decoded = decode_data(data)?;
// 処理
Ok(())
}
13. Path::join
の挙動に注意
パスを結合する際のPath::join
メソッドには注意が必要です:
use std::path::Path;
fn main() {
let path = Path::new("/usr").join("/local/bin");
println!("{path:?}"); // "/local/bin" と出力される
}
第2引数が絶対パスの場合、第1引数は無視されます。 これは意図しない動作につながる可能性があります。
14. 依存パッケージのunsafeコードをチェックする
自分のコードだけでなく、依存ライブラリのunsafeコードも脆弱性の原因になります。
対策:cargo-geiger
で依存関係をチェック
cargo install cargo-geiger
cargo geiger
このツールを使うと、プロジェクトの依存関係に含まれるunsafe関数の数を確認できます。
15. Clippyでコンパイル時に問題を検出する
Rustのlintツール「Clippy」を使うと、前述の多くの問題をコンパイル時に検出できます:
// 数値演算関連
#![deny(arithmetic_overflow)]
#![deny(clippy::checked_conversions)]
#![deny(clippy::cast_possible_truncation)]
// unwrap関連
#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
// 配列インデックス関連
#![deny(clippy::indexing_slicing)]
// その他
#![deny(clippy::join_absolute_paths)]
#![deny(clippy::serde_api_misuse)]
覚えておきたいのが:
-
cargo check
はこれらの問題を報告しません -
cargo run
は実行時にパニックするか失敗します
一方
-
cargo clippy
はコンパイル時にすべての問題を検出します!
まとめ
Rustは安全な言語として設計されていますが、コンパイラだけでは検出できない問題も多くあります。実践的な対策も紹介されており大変参考になりました。