diff --git a/maa-cli/config_examples/cli.toml b/maa-cli/config_examples/cli.toml index df0f99b5..aa52b057 100644 --- a/maa-cli/config_examples/cli.toml +++ b/maa-cli/config_examples/cli.toml @@ -1,44 +1,61 @@ "$schema" = "../schemas/cli.schema.json" -# Configurations for MaaCore +# Configurations for MaaCore installation and update [core] -channel = "Beta" # update channel of MaaCore, can be "Alpha", "Beta" or "Stable" -test_time = 0 # time to test the speed of mirrors, 0 to disable -# url of the MaaCore version api, used to get the latest version of MaaCore, -# leave it empty to use the default url +# Update channel of MaaCore, can be "Alpha", "Beta" or "Stable" +channel = "Beta" +# Time to test the speed of mirrors, in seconds, set to 0 to disable the test +# Default value is 3, smaller value if you have a fast network +test_time = 0 +# URL of the MaaCore version API, used to get the latest version of MaaCore, +# leave it empty to use the default URL api_url = "https://github.com/MaaAssistantArknights/MaaRelease/raw/main/MaaAssistantArknights/api/version/" # Configurations for whether to install given components of MaaCore [core.components] -library = true # whether to install libraries of MaaCore -resource = true # whether to install resources of MaaCore +library = true # Whether to install libraries of MaaCore +resource = true # Whether to install resources of MaaCore -# Configurations for maa-cli +# Configurations for maa-cli self update [cli] -channel = "Alpha" # update channel of maa-cli, can be "Alpha", "Beta" or "Stable" -# url of the maa-cli version api, used to get the latest version of maa-cli, -# if you want to use jsdelivr, the double v in @vversion is necessary instead of a typo +# Update channel of maa-cli, can be "Alpha", "Beta" or "Stable". +channel = "Alpha" +# URL of the maa-cli version API, used to get the latest version of maa-cli. api_url = "https://cdn.jsdelivr.net/gh/MaaAssistantArknights/maa-cli@vversion/" -# url to download latest version of maa-cli, leave it empty to use the default url +# URL to download latest version of maa-cli, leave it empty to use the default URL. download_url = "https://github.com/MaaAssistantArknights/maa-cli/releases/download/" # Configurations for whether to install given components of maa-cli [cli.components] -binary = false # whether to install binary of maa-cli +binary = false # Whether to install binary of maa-cli # Configurations for hot update of resource # Note: this is different from `core.components.resource`, this is for hot update of resource # while this is hot update resource of MaaCore -# You can not use this to hot update without any base resource +# You cannot use this to hot update without any base resource [resource] -auto_update = true # whether to auto update resource each time run maa task -backend = "libgit2" # backend to manipulate repository, can be "git" or "libgit2" +auto_update = true # Whether to auto update resource each time run maa task +backend = "libgit2" # Backend to manipulate repository, can be `git` or `libgit2` # Configurations for remote git repository of resource [resource.remote] -branch = "main" # branch of remote resource repository -# url of remote resource repository, leave it empty to use the default url -url = "https://github.com/MaaAssistantArknights/MaaResource.git" -# if you want to use ssh, set url to ssh url and set ssh_key to the path of ssh key +branch = "main" # Branch of remote resource repository +# URL of remote resource repository, leave it empty to use the default URL +uril = "https://github.com/MaaAssistantArknights/MaaResource.git" +# Or you can use ssh to clone the repository # url = "git@github.com:MaaAssistantArknights/MaaResource.git" -# ssh_key = "~/.ssh/id_ed25519" # path to ssh key +# If you want to use ssh, a certificate is needed which can be "ssh-agent" or "ssh-key" +# To use ssh-agent, set `use_ssh_agent` to true, and leave `ssh_key` and `passphrase` empty +# use_ssh_agent = true # Use ssh-agent to authenticate +# To use ssh-key, set `ssh_key` to path of ssh key, +ssh_key = "~/.ssh/id_ed25519" # Path of ssh key +# A Passphrase is needed if the ssh key is encrypted +passphrase = "password" # Passphrase of ssh key +# Store plain text password in configuration file is unsafe, so there are some ways to avoid it +# 1. set `passphrase` to true, then maa-cli will prompt you to input passphrase each time +# passphrase = true +# 2. set `passphrase` to a environment variable, then maa-cli will use the environment variable as passphrase +# passphrase = { env = "MAA_SSH_PASSPHRASE" } +# 3. set `passphrase` to a command, then maa-cli will execute the command to get passphrase +# which is useful when you use a password manager to manage your passphrase +# passphrase = { cmd = ["pass", "show", "ssh/id_ed25519"] } diff --git a/maa-cli/docs/en-US/config.md b/maa-cli/docs/en-US/config.md index 7e832b92..fb160be4 100644 --- a/maa-cli/docs/en-US/config.md +++ b/maa-cli/docs/en-US/config.md @@ -417,11 +417,24 @@ backend = "libgit2" # the backend of resource, can be "libgit2" or "git" # the remote of resource [resource.remote] -branch = "main" # the branch of remote repository -# the url of remote repository, when using ssh, you should set ssh_key field -url = "https://github.com/MaaAssistantArknights/MaaResource.git" -# url = "git@github.com:MaaAssistantArknights/MaaResource.git" -# ssh_key = "~/.ssh/id_ed25519" # path to ssh key +branch = "main" # Branch of remote resource repository +# URL of remote resource repository, leave it empty to use the default URL +url = "git@github.com:MaaAssistantArknights/MaaResource.git" +# If you want to use ssh, a certificate is needed which can be "ssh-agent" or "ssh-key" +# To use ssh-agent, set `use_ssh_agent` to true, and leave `ssh_key` and `passphrase` empty +# use_ssh_agent = true # Use ssh-agent to authenticate +# To use ssh-key, set `ssh_key` to path of ssh key, +ssh_key = "~/.ssh/id_ed25519" # Path of ssh key +# A Passphrase is needed if the ssh key is encrypted +passphrase = "password" # Passphrase of ssh key +# Store plain text password in configuration file is unsafe, so there are some ways to avoid it +# 1. set `passphrase` to true, then maa-cli will prompt you to input passphrase each time +# passphrase = true +# 2. set `passphrase` to a environment variable, then maa-cli will use the environment variable as passphrase +# passphrase = { env = "MAA_SSH_PASSPHRASE" } +# 3. set `passphrase` to a command, then maa-cli will execute the command to get passphrase +# which is useful when you use a password manager to manage your passphrase +# passphrase = { cmd = ["pass", "show", "ssh/id_ed25519"] } ``` **NOTE**: diff --git a/maa-cli/docs/ja-JP/config.md b/maa-cli/docs/ja-JP/config.md index 34c40535..8addb862 100644 --- a/maa-cli/docs/ja-JP/config.md +++ b/maa-cli/docs/ja-JP/config.md @@ -408,10 +408,23 @@ backend = "libgit2" # 资源热更新后端,可选值为 "git" 或者 "libgit2 # 资源热更新远程仓库相关配置 [resource.remote] branch = "main" # 远程仓库的分支,默认为 "main" -# 远程仓库的 url,如果你想要使用 ssh,你必须配置 ssh_key 的路径 -url = "https://github.com/MaaAssistantArknights/MaaResource.git" -# url = "git@github.com:MaaAssistantArknights/MaaResource.git" -# ssh_key = "~/.ssh/id_ed25519" # path to ssh key +# 远程资源仓库的 URL,留空以使用默认 URL +url = "git@github.com:MaaAssistantArknights/MaaResource.git" +# 如果你想使用 ssh,那么你需要配置认证方式, 可以是 "ssh-agent" 或 "ssh-key" +# 要使用 ssh-agent,请将 `use_ssh_agent` 设置为 true,并将 `ssh_key` 和 `passphrase` 留空 +# use_ssh_agent = true # 使用 ssh-agent 进行身份验证 +# 要使用 ssh-key,请将 `ssh_key` 设置为 ssh 密钥的路径 +ssh_key = "~/.ssh/id_ed25519" # ssh 密钥的路径 +# 如果 ssh 密钥已加密,你需要提供密码 +passphrase = "password" # ssh 密钥的密码 +# 在配置文件中存储明文密码是不安全的,因此有一些方法可以避免这种情况 +# 1. 将 `passphrase` 设置为 true,然后 maa-cli 将每次提示你输入密码 +# passphrase = true +# 2. 将 `passphrase` 设置为环境变量名,然后 maa-cli 将使用环境变量作为密码 +# passphrase = { env = "MAA_SSH_PASSPHRASE" } +# 3. 将 `passphrase` 设置为命令,然后 maa-cli 将执行该命令以获取密码 +# 这在你使用密码管理器管理密码时非常有用 +# passphrase = { cmd = ["pass", "show", "ssh/id_ed25519"] } ``` **注意事项**: diff --git a/maa-cli/docs/ko-KR/config.md b/maa-cli/docs/ko-KR/config.md index 11452206..2883078a 100644 --- a/maa-cli/docs/ko-KR/config.md +++ b/maa-cli/docs/ko-KR/config.md @@ -398,13 +398,25 @@ binary = true # maa-cli 바이너리 파일을 설치할지 여부, 기본값은 auto_update = true # 각 작업 실행 시 리소스를 자동 업데이트할지 여부, 기본값은 false backend = "libgit2" # 리소스 핫 업데이트 백엔드, 가능한 값은 "git" 또는 "libgit2", 기본값은 "git" -# 리소스 핫 업데이트 원격 저장소 관련 설정 [resource.remote] -branch = "main" # 원격 저장소의 분기, 기본값은 "main" -# 원격 저장소의 URL, ssh를 사용하려면 ssh_key 경로를 설정해야 함 -url = "https://github.com/MaaAssistantArknights/MaaResource.git" -# url = "git@github.com:MaaAssistantArknights/MaaResource.git" -# ssh_key = "~/.ssh/id_ed25519" # ssh 키 경로 +branch = "main" # 원격 저장소의 브랜치, 기본값은 "main"입니다. +# 원격 리소스 저장소의 URL, 기본 URL을 사용하려면 비워 두세요. +url = "git@github.com:MaaAssistantArknights/MaaResource.git" +# ssh를 사용하려면 인증 방식을 구성해야 하며, "ssh-agent" 또는 "ssh-key" 중 하나를 사용할 수 있습니다. +# ssh-agent를 사용하려면 `use_ssh_agent`를 true로 설정하고, `ssh_key`와 `passphrase`는 비워 두세요. +# use_ssh_agent = true # ssh-agent를 사용하여 인증 +# ssh-key를 사용하려면 `ssh_key`에 ssh 키의 경로를 설정하세요. +ssh_key = "~/.ssh/id_ed25519" # ssh 키의 경로 +# ssh 키가 암호화된 경우, 암호를 입력해야 합니다. +passphrase = "password" # ssh 키의 암호 +# 설정 파일에 평문 암호를 저장하는 것은 안전하지 않으므로 이를 방지하기 위한 방법이 있습니다. +# 1. `passphrase`를 true로 설정하면, maa-cli가 매번 암호를 입력하라고 요청합니다. +# passphrase = true +# 2. `passphrase`를 환경 변수 이름으로 설정하면, maa-cli는 해당 환경 변수를 암호로 사용합니다. +# passphrase = { env = "MAA_SSH_PASSPHRASE" } +# 3. `passphrase`를 명령어로 설정하면, maa-cli는 해당 명령어를 실행하여 암호를 가져옵니다. +# 이는 암호 관리자를 사용하여 암호를 관리할 때 매우 유용합니다. +# passphrase = { cmd = ["pass", "show", "ssh/id_ed25519"] } ``` **주의사항**: diff --git a/maa-cli/docs/zh-CN/config.md b/maa-cli/docs/zh-CN/config.md index 5c68ca03..2e4744d1 100644 --- a/maa-cli/docs/zh-CN/config.md +++ b/maa-cli/docs/zh-CN/config.md @@ -404,10 +404,23 @@ backend = "libgit2" # 资源热更新后端,可选值为 "git" 或者 "libgit2 # 资源热更新远程仓库相关配置 [resource.remote] branch = "main" # 远程仓库的分支,默认为 "main" -# 远程仓库的 url,如果你想要使用 ssh,你必须配置 ssh_key 的路径 -url = "https://github.com/MaaAssistantArknights/MaaResource.git" -# url = "git@github.com:MaaAssistantArknights/MaaResource.git" -# ssh_key = "~/.ssh/id_ed25519" # path to ssh key +# 远程资源仓库的 URL,留空以使用默认 URL +url = "git@github.com:MaaAssistantArknights/MaaResource.git" +# 如果你想使用 ssh,那么你需要配置认证方式, 可以是 "ssh-agent" 或 "ssh-key" +# 要使用 ssh-agent,请将 `use_ssh_agent` 设置为 true,并将 `ssh_key` 和 `passphrase` 留空 +# use_ssh_agent = true # 使用 ssh-agent 进行身份验证 +# 要使用 ssh-key,请将 `ssh_key` 设置为 ssh 密钥的路径 +ssh_key = "~/.ssh/id_ed25519" # ssh 密钥的路径 +# 如果 ssh 密钥已加密,你需要提供密码 +passphrase = "password" # ssh 密钥的密码 +# 在配置文件中存储明文密码是不安全的,因此有一些方法可以避免这种情况 +# 1. 将 `passphrase` 设置为 true,然后 maa-cli 将每次提示你输入密码 +# passphrase = true +# 2. 将 `passphrase` 设置为环境变量名,然后 maa-cli 将使用环境变量作为密码 +# passphrase = { env = "MAA_SSH_PASSPHRASE" } +# 3. 将 `passphrase` 设置为命令,然后 maa-cli 将执行该命令以获取密码 +# 这在你使用密码管理器管理密码时非常有用 +# passphrase = { cmd = ["pass", "show", "ssh/id_ed25519"] } ``` **注意事项**: diff --git a/maa-cli/docs/zh-TW/config.md b/maa-cli/docs/zh-TW/config.md index 34c40535..daadac02 100644 --- a/maa-cli/docs/zh-TW/config.md +++ b/maa-cli/docs/zh-TW/config.md @@ -407,11 +407,23 @@ backend = "libgit2" # 资源热更新后端,可选值为 "git" 或者 "libgit2 # 资源热更新远程仓库相关配置 [resource.remote] -branch = "main" # 远程仓库的分支,默认为 "main" -# 远程仓库的 url,如果你想要使用 ssh,你必须配置 ssh_key 的路径 -url = "https://github.com/MaaAssistantArknights/MaaResource.git" -# url = "git@github.com:MaaAssistantArknights/MaaResource.git" -# ssh_key = "~/.ssh/id_ed25519" # path to ssh key +# 远程资源仓库的 URL,留空以使用默认 URL +url = "git@github.com:MaaAssistantArknights/MaaResource.git" +# 如果你想使用 ssh,那么你需要配置认证方式, 可以是 "ssh-agent" 或 "ssh-key" +# 要使用 ssh-agent,请将 `use_ssh_agent` 设置为 true,并将 `ssh_key` 和 `passphrase` 留空 +# use_ssh_agent = true # 使用 ssh-agent 进行身份验证 +# 要使用 ssh-key,请将 `ssh_key` 设置为 ssh 密钥的路径 +ssh_key = "~/.ssh/id_ed25519" # ssh 密钥的路径 +# 如果 ssh 密钥已加密,你需要提供密码 +passphrase = "password" # ssh 密钥的密码 +# 在配置文件中存储明文密码是不安全的,因此有一些方法可以避免这种情况 +# 1. 将 `passphrase` 设置为 true,然后 maa-cli 将每次提示你输入密码 +# passphrase = true +# 2. 将 `passphrase` 设置为环境变量名,然后 maa-cli 将使用环境变量作为密码 +# passphrase = { env = "MAA_SSH_PASSPHRASE" } +# 3. 将 `passphrase` 设置为命令,然后 maa-cli 将执行该命令以获取密码 +# 这在你使用密码管理器管理密码时非常有用 +# passphrase = { cmd = ["pass", "show", "ssh/id_ed25519"] } ``` **注意事项**: diff --git a/maa-cli/src/config/cli/mod.rs b/maa-cli/src/config/cli/mod.rs index 85a1cdf1..e338bd2d 100644 --- a/maa-cli/src/config/cli/mod.rs +++ b/maa-cli/src/config/cli/mod.rs @@ -196,14 +196,17 @@ mod tests { Token::Str("backend"), GitBackend::Libgit2.to_token(), Token::Str("remote"), - Token::Map { len: Some(3) }, + Token::Map { len: Some(5) }, Token::Str("branch"), Token::Some, Token::Str("main"), - Token::Str("ssh_key"), - Token::None, Token::Str("url"), Token::Str("https://github.com/MaaAssistantArknights/MaaResource.git"), + Token::Str("ssh_key"), + Token::Some, + Token::Str("~/.ssh/id_ed25519"), + Token::Str("passphrase"), + Token::Str("password"), Token::MapEnd, Token::MapEnd, Token::MapEnd, diff --git a/maa-cli/src/config/cli/resource.rs b/maa-cli/src/config/cli/resource.rs index 3abfe3f3..21bdaffa 100644 --- a/maa-cli/src/config/cli/resource.rs +++ b/maa-cli/src/config/cli/resource.rs @@ -1,7 +1,10 @@ -use std::path::{Path, PathBuf}; +use std::{borrow::Cow, path::PathBuf}; +use maa_dirs::expand_tilde; use serde::Deserialize; +use crate::value::userinput::{Input, UserInput}; + #[cfg_attr(test, derive(Debug, PartialEq))] #[derive(Deserialize, Default, Clone)] pub struct Config { @@ -41,17 +44,53 @@ pub enum GitBackend { } #[cfg_attr(test, derive(Debug, PartialEq))] -#[derive(Deserialize, Clone)] +#[derive(Clone)] pub struct Remote { /// URL to resource repository - #[serde(default = "default_url")] url: String, /// Branch of resource repository - #[serde(default)] branch: Option, - /// SSH key to access resource repository when fetch from SSH - #[serde(default)] - ssh_key: Option, + /// Certificate to access resource repository + certificate: Option, +} + +impl<'de> Deserialize<'de> for Remote { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + #[derive(Deserialize)] + struct RemoteHelper { + #[serde(default = "default_url")] + url: String, + #[serde(default)] + branch: Option, + #[serde(default)] + use_ssh_agent: bool, + #[serde(default)] + ssh_key: Option, + #[serde(default)] + passphrase: Passphrase, + } + + let helper = RemoteHelper::deserialize(deserializer)?; + + let certificate = match (helper.use_ssh_agent, helper.ssh_key, helper.passphrase) { + (true, None, _) => Some(Certificate::SshAgent), + (true, Some(_), _) => { + log::warn!("Using ssh-agent to fetch certificate, no need to specify ssh_key"); + Some(Certificate::SshAgent) + } + (false, Some(path), passphrase) => Some(Certificate::SshKey { path, passphrase }), + (false, None, _) => None, + }; + + Ok(Remote { + url: helper.url, + branch: helper.branch, + certificate, + }) + } } impl Default for Remote { @@ -59,7 +98,7 @@ impl Default for Remote { Self { url: default_url(), branch: None, - ssh_key: None, + certificate: None, } } } @@ -68,6 +107,203 @@ fn default_url() -> String { String::from("https://github.com/MaaAssistantArknights/MaaResource.git") } +#[cfg_attr(test, derive(Debug, PartialEq))] +#[derive(Clone)] +pub enum Certificate { + /// Use certificate from ssh-agent + SshAgent, + /// Use given private key as certificate + /// + /// Note: when using git backend, the encrypted key will not work in batch mode + /// because we can not pass the passphrase to git command and it will prompt for passphrase. + /// Use ssh-agent in this case. + SshKey { + path: PathBuf, + passphrase: Passphrase, + }, +} + +#[cfg(feature = "git2")] +impl Certificate { + pub fn fetch(&self, username: &str) -> Result { + match self { + Certificate::SshAgent => git2::Cred::ssh_key_from_agent(username), + Certificate::SshKey { path, passphrase } => git2::Cred::ssh_key( + username, + None, + expand_tilde(path).as_ref(), + passphrase + .get() + .map_err(|e| git2::Error::from_str(&format!("Failed to get passphrase {e}")))? + .as_deref(), + ), + } + } +} + +#[cfg_attr(test, derive(Debug, PartialEq))] +#[derive(Clone, Default)] +pub enum Passphrase { + /// No passphrase + #[default] + None, + /// Prompt for passphrase + /// + /// This will not work in batch mode + Prompt, + /// Plain text passphrase + /// + /// This is not recommended for security reasons + Plain(Str), + /// From Environment variable + /// + /// This is not recommended for security reasons + Env(Str), + /// Use a command to get passphrase + /// + /// A command that outputs the passphrase to stdout + /// This is useful to fetch passphrase from password manager + Command(Vec), +} + +impl<'de> Deserialize<'de> for Passphrase { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + enum PassphraseField { + Cmd, + Env, + } + + impl<'de> serde::Deserialize<'de> for PassphraseField { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + struct PassphraseFieldVisitor; + + impl<'de> serde::de::Visitor<'de> for PassphraseFieldVisitor { + type Value = PassphraseField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("`cmd` or `env`") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + match v { + "cmd" => Ok(PassphraseField::Cmd), + "env" => Ok(PassphraseField::Env), + _ => Err(serde::de::Error::unknown_field(v, &["cmd", "env"])), + } + } + } + + deserializer.deserialize_identifier(PassphraseFieldVisitor) + } + } + + struct PassphraseVisitor; + + impl<'de> serde::de::Visitor<'de> for PassphraseVisitor { + type Value = Passphrase; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a valid passphrase, which must be a bool, string, or map") + } + + fn visit_bool(self, v: bool) -> Result + where + E: serde::de::Error, + { + if v { + Ok(Passphrase::Prompt) + } else { + Ok(Passphrase::None) + } + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + Ok(Passphrase::Plain(v.to_owned())) + } + + fn visit_string(self, v: String) -> Result + where + E: serde::de::Error, + { + Ok(Passphrase::Plain(v)) + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let v = match map.next_key()? { + Some(PassphraseField::Cmd) => { + let cmd = map.next_value()?; + Passphrase::Command(cmd) + } + Some(PassphraseField::Env) => { + let name = map.next_value()?; + Passphrase::Env(name) + } + None => return Err(serde::de::Error::custom("`cmd` or `env` is required")), + }; + + if map.next_key::()?.is_some() { + println!("here"); + return Err(serde::de::Error::custom("only one field is allowed")); + } + + Ok(v) + } + } + + deserializer.deserialize_any(PassphraseVisitor) + } +} + +impl Passphrase { + pub fn compatible_with_git(&self) -> bool { + matches!(self, Passphrase::None | Passphrase::Prompt) + } + + pub fn get(&self) -> std::io::Result>> { + match self { + Passphrase::None => Ok(None), + Passphrase::Prompt => Input::::new(None, Some("passphrase")) + .value() + .map(Cow::Owned) + .map(Some), + Passphrase::Plain(password) => Ok(Some(Cow::Borrowed(password))), + Passphrase::Env(name) => std::env::var(name) + .map(Cow::Owned) + .map(Some) + .map_err(std::io::Error::other), + Passphrase::Command(cmd) => { + let output = std::process::Command::new(&cmd[0]) + .args(&cmd[1..]) + .output()?; + if output.status.success() { + let passphrase = std::str::from_utf8(&output.stdout) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + Ok(Some(Cow::Owned(passphrase.trim().to_owned()))) + } else { + Err(std::io::Error::other( + String::from_utf8(output.stderr).unwrap_or_default(), + )) + } + } + } + } +} + impl Remote { pub fn url(&self) -> &str { &self.url @@ -77,8 +313,8 @@ impl Remote { self.branch.as_deref() } - pub fn ssh_key(&self) -> Option<&Path> { - self.ssh_key.as_deref() + pub fn certificate(&self) -> Option<&Certificate> { + self.certificate.as_ref() } } @@ -93,7 +329,10 @@ pub mod tests { remote: Remote { url: String::from("https://github.com/MaaAssistantArknights/MaaResource.git"), branch: Some(String::from("main")), - ssh_key: None, + certificate: Some(Certificate::SshKey { + path: PathBuf::from("~/.ssh/id_ed25519"), + passphrase: Passphrase::Plain(String::from("password")), + }), }, } } @@ -107,13 +346,13 @@ pub mod tests { remote: Remote { url: default_url(), branch: None, - ssh_key: None, + certificate: None, } }); } mod serde { - use serde_test::{assert_de_tokens, Token}; + use serde_test::{assert_de_tokens, assert_de_tokens_error, Token}; use super::*; @@ -147,17 +386,92 @@ pub mod tests { assert_de_tokens( &Remote { - url: String::from("http://gitee.com/MaaMirror/Resource.git"), + url: String::from("http://git.com/MaaMirror/Resource.git"), branch: Some(String::from("main")), - ssh_key: Some(PathBuf::from("~/.ssh/id_ed25519")), + certificate: None, }, &[ Token::Map { len: Some(3) }, Token::Str("url"), - Token::Str("http://gitee.com/MaaMirror/Resource.git"), + Token::Str("http://git.com/MaaMirror/Resource.git"), Token::Str("branch"), Token::Some, Token::Str("main"), + Token::MapEnd, + ], + ); + + assert_de_tokens( + &Remote { + certificate: Some(Certificate::SshAgent), + ..Default::default() + }, + &[ + Token::Map { len: Some(1) }, + Token::Str("use_ssh_agent"), + Token::Bool(true), + Token::MapEnd, + ], + ); + + assert_de_tokens( + &Remote { + certificate: None, + ..Default::default() + }, + &[ + Token::Map { len: Some(1) }, + Token::Str("use_ssh_agent"), + Token::Bool(false), + Token::MapEnd, + ], + ); + + assert_de_tokens( + &Remote { + certificate: Some(Certificate::SshKey { + path: PathBuf::from("~/.ssh/id_ed25519"), + passphrase: Passphrase::None, + }), + ..Default::default() + }, + &[ + Token::Map { len: Some(1) }, + Token::Str("ssh_key"), + Token::Some, + Token::Str("~/.ssh/id_ed25519"), + Token::MapEnd, + ], + ); + + assert_de_tokens( + &Remote { + certificate: Some(Certificate::SshKey { + path: PathBuf::from("~/.ssh/id_ed25519"), + passphrase: Passphrase::Plain(String::from("password")), + }), + ..Default::default() + }, + &[ + Token::Map { len: Some(2) }, + Token::Str("ssh_key"), + Token::Some, + Token::Str("~/.ssh/id_ed25519"), + Token::Str("passphrase"), + Token::Str("password"), + Token::MapEnd, + ], + ); + + assert_de_tokens( + &Remote { + certificate: Some(Certificate::SshAgent), + ..Default::default() + }, + &[ + Token::Map { len: Some(2) }, + Token::Str("use_ssh_agent"), + Token::Bool(true), Token::Str("ssh_key"), Token::Some, Token::Str("~/.ssh/id_ed25519"), @@ -166,6 +480,66 @@ pub mod tests { ); } + #[test] + fn passphrase() { + assert_de_tokens(&Passphrase::Prompt, &[Token::Bool(true)]); + + assert_de_tokens(&Passphrase::None, &[Token::Bool(false)]); + + assert_de_tokens(&Passphrase::Plain(String::from("password")), &[Token::Str( + "password", + )]); + + assert_de_tokens(&Passphrase::Env(String::from("SSH_PASSPHRASE")), &[ + Token::Map { len: Some(1) }, + Token::Str("env"), + Token::Str("SSH_PASSPHRASE"), + Token::MapEnd, + ]); + + assert_de_tokens( + &Passphrase::Command(vec![String::from("get"), String::from("passphrase")]), + &[ + Token::Map { len: Some(1) }, + Token::Str("cmd"), + Token::Seq { len: Some(2) }, + Token::Str("get"), + Token::Str("passphrase"), + Token::SeqEnd, + Token::MapEnd, + ], + ); + + assert_de_tokens_error::( + &[Token::Map { len: Some(1) }, Token::Str("foo")], + "unknown field `foo`, expected `cmd` or `env`", + ); + + assert_de_tokens_error::( + &[ + Token::Map { len: Some(2) }, + Token::Str("cmd"), + Token::Seq { len: Some(2) }, + Token::Str("get"), + Token::Str("passphrase"), + Token::SeqEnd, + Token::Str("env"), + ], + "only one field is allowed", + ); + + assert_de_tokens_error::( + &[Token::Map { len: Some(0) }, Token::MapEnd], + "`cmd` or `env` is required", + ); + + assert_de_tokens_error::( + &[Token::I64(0)], + "invalid type: integer `0`, \ + expected a valid passphrase, which must be a bool, string, or map", + ); + } + #[test] fn config() { assert_de_tokens(&Config::default(), &[ @@ -180,7 +554,10 @@ pub mod tests { remote: Remote { url: String::from("git@github.com:MaaAssistantArknights/MaaResource.git"), branch: Some(String::from("main")), - ssh_key: Some(PathBuf::from("~/.ssh/id_ed25519")), + certificate: Some(Certificate::SshKey { + path: PathBuf::from("~/.ssh/id_ed25519"), + passphrase: Passphrase::Plain(String::from("password")), + }), }, }, &[ @@ -199,6 +576,8 @@ pub mod tests { Token::Str("ssh_key"), Token::Some, Token::Str("~/.ssh/id_ed25519"), + Token::Str("passphrase"), + Token::Str("password"), Token::MapEnd, Token::MapEnd, ], @@ -212,11 +591,11 @@ pub mod tests { assert_eq!( Remote { - url: String::from("http://gitee.com/MaaMirror/Resource.git"), + url: String::from("http://git.com/MaaMirror/Resource.git"), ..Default::default() } .url(), - "http://gitee.com/MaaMirror/Resource.git" + "http://git.com/MaaMirror/Resource.git" ); } @@ -236,17 +615,51 @@ pub mod tests { } #[test] - fn ssh_key() { - assert_eq!(Remote::default().ssh_key(), None); + fn certificate() { + assert_eq!(Remote::default().certificate(), None); assert_eq!( Remote { - ssh_key: Some(PathBuf::from("~/.ssh/id_ed25519")), + certificate: Some(Certificate::SshAgent), ..Default::default() } - .ssh_key() - .unwrap(), - Path::new("~/.ssh/id_ed25519") + .certificate(), + Some(&Certificate::SshAgent) + ); + } + + #[test] + fn passphrase() { + assert!(!Passphrase::Plain(String::from("password")).compatible_with_git()); + assert!(Passphrase::Prompt.compatible_with_git()); + + assert_eq!(Passphrase::None.get().unwrap(), None); + + assert_eq!( + Passphrase::Plain(String::from("password")).get().unwrap(), + Some(Cow::Borrowed("password")) + ); + + assert!(Passphrase::Env(String::from("MMA_TEST_SSH_PASSPHRASE")) + .get() + .is_err()); + + std::env::set_var("MMA_TEST_SSH_PASSPHRASE", "password"); + assert_eq!( + Passphrase::Env(String::from("MMA_TEST_SSH_PASSPHRASE")) + .get() + .unwrap() + .unwrap(), + "password" + ); + std::env::remove_var("MMA_TEST_SSH_PASSPHRASE"); + + assert_eq!( + Passphrase::Command(vec![String::from("echo"), String::from("password")]) + .get() + .unwrap() + .unwrap(), + "password" ); } } diff --git a/maa-cli/src/installer/resource.rs b/maa-cli/src/installer/resource.rs index b203a129..414f8324 100644 --- a/maa-cli/src/installer/resource.rs +++ b/maa-cli/src/installer/resource.rs @@ -37,7 +37,7 @@ pub fn update(is_auto: bool) -> Result<()> { let backend = config.backend(); let url = config.remote().url(); let branch = config.remote().branch(); - let ssh_key = config.remote().ssh_key().map(dirs::expand_tilde); + let cert = config.remote().certificate(); let dest = dirs::hot_update(); // check if git is available when using git backend @@ -65,23 +65,23 @@ pub fn update(is_auto: bool) -> Result<()> { }; // check if ssh key is available - if url.starts_with("git@") && ssh_key.is_none() { - bail!("SSH key is required for git repository with ssh url"); + if url.starts_with("git@") && cert.is_none() { + bail!("A Certificate is required to clone a repository using SSH"); } if dest.exists() { debug!("Fetching resource repository..."); match backend { - GitBackend::Git => git::pull(dest, branch, ssh_key.as_deref())?, + GitBackend::Git => git::pull(dest, branch, cert)?, #[cfg(feature = "git2")] - GitBackend::Libgit2 => git2::pull(dest, branch, ssh_key.as_deref())?, + GitBackend::Libgit2 => git2::pull(dest, branch, cert)?, } } else { debug!("Cloning resource repository..."); match backend { - GitBackend::Git => git::clone(url, branch, dest, ssh_key.as_deref())?, + GitBackend::Git => git::clone(url, branch, dest, cert)?, #[cfg(feature = "git2")] - GitBackend::Libgit2 => git2::clone(url, branch, dest, ssh_key.as_deref())?, + GitBackend::Libgit2 => git2::clone(url, branch, dest, cert)?, } } @@ -89,17 +89,40 @@ pub fn update(is_auto: bool) -> Result<()> { } mod git { - use std::path::Path; + use std::{path::Path, process::Command}; - use anyhow::{Context, Result}; + use anyhow::{bail, Context, Result}; use super::StatusExt; + use crate::config::cli::resource::Certificate; + + fn setup_cert(cmd: &mut Command, cert: Option<&Certificate>) -> Result<()> { + match cert { + Some(Certificate::SshKey { path, passphrase }) => { + if !passphrase.compatible_with_git() { + bail!( + "Pass passphrase to git is not supported, + you will also need to provide the passphrase to the terminal. + please use git2 backend or use ssh-agent to authenticate" + ); + } + + cmd.env( + "GIT_SSH_COMMAND", + format!("ssh -i {}", path.to_str().context("Invalid path")?), + ); + } + Some(Certificate::SshAgent) | None => {} // git uses ssh-agent by default + } + + Ok(()) + } pub fn clone( url: &str, branch: Option<&str>, dest: &Path, - ssh_key: Option<&Path>, + cert: Option<&Certificate>, ) -> Result<()> { let mut cmd = std::process::Command::new("git"); @@ -114,12 +137,7 @@ mod git { cmd.args(["--branch", branch]); } - if let Some(ssh_key) = ssh_key { - cmd.env( - "GIT_SSH_COMMAND", - format!("ssh -i {}", ssh_key.to_str().context("Invalid path")?), - ); - } + setup_cert(&mut cmd, cert)?; cmd.status() .check() @@ -128,7 +146,7 @@ mod git { Ok(()) } - pub fn pull(repo: &Path, branch: Option<&str>, ssh_key: Option<&Path>) -> Result<()> { + pub fn pull(repo: &Path, branch: Option<&str>, cert: Option<&Certificate>) -> Result<()> { let mut cmd = std::process::Command::new("git"); cmd.args(["pull", "origin"]); @@ -139,12 +157,7 @@ mod git { cmd.arg("--ff-only"); - if let Some(ssh_key) = ssh_key { - cmd.env( - "GIT_SSH_COMMAND", - format!("ssh -i {}", ssh_key.to_str().context("Invalid path")?), - ); - } + setup_cert(&mut cmd, cert)?; cmd.current_dir(repo) .status() @@ -163,11 +176,25 @@ mod git2 { use git2::{build::RepoBuilder, Repository}; use log::debug; + use crate::config::cli::resource::Certificate; + + fn create_fetch_options(cert: &Certificate) -> git2::FetchOptions { + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.credentials(|_, username, _| { + username + .map(|username| cert.fetch(username)) + .unwrap_or(Err(git2::Error::from_str("No username provided"))) + }); + let mut fetch_options = git2::FetchOptions::new(); + fetch_options.remote_callbacks(callbacks); + fetch_options + } + pub fn clone( url: &str, branch: Option<&str>, dest: &Path, - ssh_key: Option<&Path>, + cert: Option<&Certificate>, ) -> Result<()> { let mut builder = RepoBuilder::new(); @@ -175,15 +202,8 @@ mod git2 { builder.branch(branch); } - if let Some(ssh_key) = ssh_key { - let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.credentials(|_, username_from_url, _| { - git2::Cred::ssh_key(username_from_url.unwrap(), None, ssh_key, None) - }); - - let mut fetch_options = git2::FetchOptions::new(); - fetch_options.remote_callbacks(callbacks); - + if let Some(cert) = cert { + let fetch_options = create_fetch_options(cert); builder.fetch_options(fetch_options); } @@ -194,22 +214,12 @@ mod git2 { Ok(()) } - pub fn pull(repo: &Path, branch: Option<&str>, ssh_key: Option<&Path>) -> Result<()> { + pub fn pull(repo: &Path, branch: Option<&str>, cert: Option<&Certificate>) -> Result<()> { let repo = Repository::open(repo).context("Failed to open resource repository")?; let branch = branch.unwrap_or("main"); - let mut fetch_options = ssh_key.map(|ssh_key| { - let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.credentials(|_, username_from_url, _| { - git2::Cred::ssh_key(username_from_url.unwrap(), None, ssh_key, None) - }); - - let mut fetch_options = git2::FetchOptions::new(); - fetch_options.remote_callbacks(callbacks); - - fetch_options - }); + let mut fetch_options = cert.map(create_fetch_options); repo.find_remote("origin") .context("Failed to find remote 'origin'")?