LoginSignup
20
12

More than 3 years have passed since last update.

Rust で derive(Debug) を安心して使うために秘匿情報をマスクする

Last updated at Posted at 2020-02-14

コロナウィルス騒動によって入手不可能になっていますがそのマスクの話ではありません。
花粉が激化する3月をどう乗り越えたらいいのか…

マスクってどんな処理?

ここで欲しいのはこんな処理のことです。

let key = SecretKey::new("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY");
println("{:?}", key);// SecretKey("****************EKEY")

どうして?

こんなコードを見るとギョッとしますよね。しなければ今からしましょう。


#[derive(Debug)]
pub struct User {
    name: String,
    credentials: Credentials,
}
#[derive(Debug)]
pub struct Credentials {
    pub access_key: AccessKey,
    pub secret_key: SecretKey,
}
#[derive(Debug)]
pub struct AccessKey(String);

#[derive(Debug)]// ギョッとするのはここ!
pub struct SecretKey(String);

なぜギョッとするのかというと :

let taro = User {
    name: "太郎".to_string(),
    credentials: Credentials {
        access_key: AccessKey("access-key".to_string()),
        secret_key: SecretKey("secret-key".to_string()),
    }
};
// どこかで作られたユーザ情報をログに出力していたら…
println!("log: {:#?}", taro);
/*
log: User {
    name: "太郎",
    credentials: Credentials {
        access_key: AccessKey(
            "access-key",
        ),
        secret_key: SecretKey(
            // そのメンバの構造体に含まれる秘匿情報まで吐かれていた…!
            "secret-key",
        ),
    },
}
*/

あるあるですね。

どうすれば?

  • 雑にログに出さなければ済む?
  • 秘匿情報をダンプしないように注意する?
  • UserCredentials では derive(Debug) を避ける?

いずれの答えも No です。人間はミスをします。
そもそもログに出力されても問題が起きないように作りましょう。

(ログに出力 できないように 作るという選択肢もありそうですが、いろいろと大変なので今回はそのアプローチには触れません)

具体的な実装例

ということで解答編に入ると :

  • 実装すべきは秘匿情報 (ここでは SecretKey ) の fmt::Debug のみ
  • その他の derive(Debug) については手を加える必要なし

です。

use std::fmt;

// これはもう不要
// #[derive(Debug)]
pub struct SecretKey(String);

impl fmt::Debug for SecretKey {
    fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
        let visible_length = 4;
        let masked = {
            let start = self.0.len() - visible_length;
            format!("{:*>20}", &self.0[start..])
        };
        f.debug_tuple("SecretKey").field(&masked).finish()
    }
}

動作がどのように変わったのか見てみましょう :

let taro = User {
    name: "太郎".to_string(),
    credentials: Credentials {
        access_key: AccessKey("access-key".to_string()),
        secret_key: SecretKey("secret-key".to_string()),
    }
};
println!("log: {:#?}", taro);
/*
log: User {
    name: "太郎",
    credentials: Credentials {
        access_key: AccessKey(
            "access-key",
        ),
        // 雑にダンプしても読めなくなった!安心!
        secret_key: SecretKey(
            "****************-key",
        ),
    },
}
*/

問題なさそうですね。
せっかくなので簡単なテストも残しておきます。

#[cfg(test)]
mod tests {
    use super::SecretKey;

    #[test]
    fn it_should_be_masked() {
        let key = SecretKey("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string());
        let masked = format!("{:?}", key);

        assert_eq!(masked, r#"SecretKey("****************EKEY")"#);
        assert_eq!(masked.len(), 20 + r#"SecretKey("")"#.len());
    }
}

ちなみに最後の四文字だけ見せるという挙動は AWS CLI のそれを パクり 参考にしました。

追記 : マルチバイト文字が含まれると…

ここまで書いてから、日本語が入ると panic で落ちることに気が付きました :innocent:

    #[test]
    fn it_should_be_masked_even_if_multibyte_given() {
        let key = SecretKey("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAM途中に日本語".to_string());
        let masked = format!("{:?}", key);

        assert_eq!(masked, r#"SecretKey("****************に日本語")"#);
        assert_eq!(masked.chars().count(), 20 + r#"SecretKey("")"#.len());
    }

oops!

thread '...secret_key::tests::it_should_be_masked_even_if_multibyte_given'
  panicked at 'byte index 48 is not a char boundary;
    it is inside '本' (bytes 46..49) of `wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAM途中に日本語`',
      src/libcore/str/mod.rs:2068

test ...secret_key::tests::it_should_be_masked_even_if_multibyte_given ... FAILED

このテストを通すための修正版はこちら :

impl fmt::Debug for SecretKey {
    fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
        let visible_length = 4;
        let masked = {
            let length = self.0.chars().count();
            let start = length - visible_length;
            let extracted: String = self.0.chars().skip(start).collect();
            format!("{:*>20}", &extracted)
        };
        f.debug_tuple("SecretKey").field(&masked).finish()
    }
}

初期バージョンが使えるのはマルチバイト文字が絶対に来ないと言える場面だけになりそうですね… :cry:

余談 : コードの詳細について

上記の実装はわずか数十行程度ですが、
普段あまり使わない & 地味に便利 & おもしろいポイントが何点かあったので書き残しておきます。

std::fmt::DebugTuple

debug_tuple メソッドによっていい感じの pretty-print が手に入ります。
(記事本文で SecretKey{:#?} 出力が完璧にインデントされていたことに着目してください!)

log: User {
    name: "太郎",
    credentials: Credentials {
        access_key: AccessKey(
            "access-key",
        ),
        secret_key: SecretKey(
            "****************-key",
        ),

参考リンク

Fill/Alignment

"{:*>20}" で "20 文字に達するまで左側を * で埋める" の意です。
文字を * 以外に変えるのはもちろん、右側を埋めたり左右を埋めたりもできます。

  • 最初はこれを知らずに危うく正規表現 (rf. regex crate) でゴリ押しするところでした
  • 参考 : std::fmt - Rust

Raw string literals

r#"..."# の記法によって "\ を手作業でエスケープする簡単なお仕事から解放されます。
リテラルの中に # を入れたいときは r##"..."## です。

String Slices

foo[start..] で "start バイト以降を切り出す" の意です。
添字には範囲を指定できるので foo[start..end]foo[..end] も書けます。

ちなみにドキュメントをよく見たらマルチバイト文字の扱いについても明記されていました :joy:

Note: String slice range indices must occur at valid UTF-8 character boundaries. If you attempt to create a string slice in the middle of a multibyte character, your program will exit with an error.

std::str::Chars

.chars().count() によってマルチバイト文字の「文字数」を得られます。
のはずだったんですが、調査の過程で知ったこわおもしろい例を紹介しておきます :

String: 'é' has length 3 and character count 2
String: 'é' has length 2 and character count 1

この e の頭についている記号は grapheme (書記素) なるもので、別途ライブラリ :

を使えばこの grapheme を考慮して文字数をカウントできるようです。
今回の記事では

  • 標準ライブラリの範囲外
  • どこまで実装すべきなのかはおそらく要件による
  • 検証がめんどい

という点から対応を見送りました。

参考リンク

20
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
12