gitのcredential helperを自作してみる

久々に書きます（しかも古典的な内容笑）


gitの認証情報管理の仕組みって面白いですよね
SSHならある程度やり方は一本道で設定さえ済めばユーザ名やパスワード入力の手間を省くことができますが、httpsの場合は認証情報の管理方法は様々で、gitを使い始めた頃は結局どれが安全で簡単な方法なのかわからず戸惑っていました。当時の私は↓のドキュメントを読んでとても納得した覚えがあります。

https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage

特に、以下の部分に記載のように、credential helperがプラグインになっていることを知った時は、「gitの開発者は神か何かか？」と思いました（実際、神でした）
> Given that git-credential-store and friends are separate programs from Git, it’s not much of a leap to realize that any program can be a Git credential helper

今回はそのcredential helperを[A Custom Credential Cache](https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage#:~:text=A%20Custom%20Credential%20Cache)の部分を読みながら自作していこうと思います

## 今回作るもの

基本的にドキュメントに記載の流れと同じですが、少しアレンジを加えて想定したユースケースは以下の通りです。

「チームの全員が共有しているGitHubアカウントがあり、 そのアカウントの認証情報はAWSのParameter Storeに保存されている。Linux（例えばEC2で構築された踏み台サーバ）上でGitHubからリポジトリをクローンする際にusername/passwordの入力の手間を省きたいが、頻繁に更新されるため、インスタンス上に認証情報を置いておきたくはない。」

こんなことを実現できるカスタムヘルパーを作成していきます。credential helperの仕組みを勉強するのも大きな目的なので、傍流の部分は雑に妥協しながら作っていきます。

## 作り始める

Rustで作ってみます。

### まずはプロジェクト作成

```zsh
$ cargo new git-credential-ssm
     Created binary (application) `git-credential-ssm` package
$ cd git-credential-ssm
$ cargo run
--省略--
Hello, world!
```

プロジェクト名はなんでもいいですが、`git-credential-`というprefixがつくとconfig設定時にprefix部分を省略できるので`git-credential-ssm`としました

### 依存関係を記載

```diff:Cargo.toml
[package]
name = "git-credential-ssm"
version = "0.1.0"
edition = "2021"

[dependencies]
+ aws-config = "0.56.1"
+ aws-sdk-ssm = "0.30.0"
+ tokio = { version="1.32.0", features=["full"] }
```

今回は`aws sdk for rust`を使うため、[getting-started](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/getting-started.html)を参考にして3つのcrateを追加しました

### AWS Systems Manager Parameter Storeに情報を格納する

コードを書き始める前に、パラメータを格納しておきます。
今回はget-parameters-by-pathを使ってprotocolとhostからユーザ名とパスワードを取得するので、以下のようなpathの構成にしてそれぞれ情報を格納しておきました。
私の検証環境ではprotocolとhostはそれぞれ`https`と`github.com`です。

```
/github-credentials/{protocol}/{host}/username
/github-credentials/{protocol}/{host}/password
```

### コードを書く

全部main関数に書いちゃってます。

```rust:main.rs
use aws_sdk_ssm as ssm;
use aws_sdk_ssm::types::Parameter;
use ssm::{operation::get_parameters_by_path::GetParametersByPathOutput, Client};
use std::collections::HashMap;
use std::env;
use std::io;

type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;

#[::tokio::main]
async fn main() -> Result<()> {
    // ①1番目の引数からアクションを取得
    let args: Vec<String> = env::args().collect();
    if args.len() < 2 {
        panic!("Please provide an action");
    }
    if args[1].to_lowercase().as_str() != "get" {
        return Ok(());
    }

    // ②標準入力からprotocol、hostを取得
    let lines = io::stdin().lines();
    let mut known = HashMap::<String, String>::new();
    for line in lines.into_iter() {
        let line_value = line.unwrap();
        if line_value.is_empty() {
            break;
        }
        let input: Vec<&str> = line_value.split("=").collect();
        if input.len() < 2 {
            continue;
        }
        let key = input[0];
        let value = input[1];
        known.insert(key.to_string(), value.to_string());
    }

    // ③Parameter StoreからGitHubのユーザー名とパスワードを取得
    let config = aws_config::load_from_env().await;
    let client = Client::new(&config);
    let protocol = match known.get("protocol") {
        Some(protocol) => protocol.as_str(),
        _ => {
            return Ok(())
        }
    };
    let host = match known.get("host") {
        Some(host) => host.as_str(),
        _ => {
            return Ok(())
        }
    };
    let path = format!("/github-credentials/{}/{}", protocol, host);

    let res: GetParametersByPathOutput = client
        .get_parameters_by_path()
        .path(path.as_str())
        .with_decryption(true)
        .send()
        .await?;
    let parameters: Vec<Parameter> = res.parameters.unwrap();
    let username_parameter = parameters
        .clone()
        .into_iter()
        .find(|p| p.name.as_ref().unwrap() ==  format!("{}/username", path).as_str());

    let password_parameter = parameters
        .clone()
        .into_iter()
        .find(|p| p.name.as_ref().unwrap() == format!("{}/password", path).as_str());

    // ④ユーザー名とパスワードが取得できれば標準出力に出力
    match (username_parameter, password_parameter) {
        (Some(username_parameter), Some(password_parameter)) => {
            let username = username_parameter.value.unwrap_or_default();
            let password = password_parameter.value.unwrap_or_default();
            println!("protocol={}", known.get("protocol").unwrap());
            println!("host={}", known.get("host").unwrap());
            println!("username={}", username);
            println!("password={}", password);
        },
        _ => {
            return Ok(())
        }
    }
    Ok(())
}

```

簡単に説明します

1. コマンドライン引数からアクションを取得します。ドキュメントに書かれている通り、Fullであればget, store, eraseの実装が必要なのですが、Parameter Storeからの取得のみの実装で良いのでget以外の場合は早期リターンしています
1. getアクションの場合、ヘルパーは標準入力からprotocolとhostの情報を受け取ります。空行が渡されるまでループして受け取った情報を保持しておきます
1. 標準入力から受け取ったprotocol, host情報を使って、Parameter Storeからユーザ名、パスワードを取得しています。実行環境のデフォルトのAWS認証情報が利用されるのでParameter Storeへアクセスできる認証情報が保存されている必要があります
1. 一致するユーザ情報があれば標準出力に出力します。所定の形式で出力することでgitが認証情報として利用してくれます。


### ビルドする

これで完成したのでビルドします。私はDockerコンテナ上でビルドして動作確認します。

```zsh
$ cargo build --release
$ ls ./target/release/git-credential-ssm
./target/release/git-credential-ssm*
```

あとはこれをPATHの通ったディレクトリに配置して、git configに設定するだけです。

```zsh
$ git config --global credential.helper ssm
```

私は確認用にDockerでコンテナを立てているので、その中でprivateなリポジトリのcloneを実行してみます

```zsh
$ git clone https://github.com/ys-oda/sample_repo.git
```

無事clone成功しました。

自作のcredential helperについては以上です。

## 参考

https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage

https://docs.aws.amazon.com/sdk-for-rust/latest/dg/getting-started.html