From 0289dd4684dcd5666b465adff4bbc66b7a123385 Mon Sep 17 00:00:00 2001 From: SidneyZhang Date: Thu, 19 Mar 2026 16:34:45 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=8D=87=E7=BA=A7=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E8=87=B3=200.1.10=20=E5=B9=B6=E6=9B=B4=E6=96=B0=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E7=8E=AF=E4=B8=8E=E5=8A=A0=E5=AF=86=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 2 +- README.md | 4 +- readme_zh.md | 3 +- src/commands/config.rs | 89 ++++++- tests/config_export_import_tests.rs | 359 ++++++++++++++++++++++++++++ 5 files changed, 445 insertions(+), 12 deletions(-) create mode 100644 tests/config_export_import_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 503fc9b..9912496 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "quicommit" -version = "0.1.9" +version = "0.1.10" edition = "2024" authors = ["Sidney Zhang "] description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)" diff --git a/README.md b/README.md index 9651590..ea4740f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ A powerful AI-powered Git assistant for generating conventional commits, tags, a ![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) ![License](https://img.shields.io/badge/license-MIT-blue.svg) +![Crates.io Version](https://img.shields.io/crates/v/quicommit) + ## Features @@ -18,7 +20,7 @@ A powerful AI-powered Git assistant for generating conventional commits, tags, a - **Profile Management**: Manage multiple Git identities with SSH keys and GPG signing support - **Smart Tagging**: Semantic version bumping with AI-generated release notes - **Changelog Generation**: Automatic changelog generation in Keep a Changelog format -- **Security**: Encrypt sensitive data +- **Security**: Use system keyring to store API keys securely - **Interactive UI**: Beautiful CLI with previews and confirmations ## Installation diff --git a/readme_zh.md b/readme_zh.md index e057d0a..2be1261 100644 --- a/readme_zh.md +++ b/readme_zh.md @@ -10,6 +10,7 @@ ![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) ![License](https://img.shields.io/badge/license-MIT-blue.svg) +![Crates.io Version](https://img.shields.io/crates/v/quicommit) ## 主要功能 @@ -18,7 +19,7 @@ - **多配置管理**:为不同场景管理多个Git身份,支持SSH密钥和GPG签名配置 - **智能标签管理**:基于语义版本自动检测升级,AI生成标签信息 - **变更日志生成**:自动生成Keep a Changelog格式的变更日志 -- **安全保护**:加密存储敏感数据 +- **安全保护**:使用系统密钥环进行安全存储 - **交互式界面**:美观的CLI界面,支持预览和确认 ## 安装 diff --git a/src/commands/config.rs b/src/commands/config.rs index 525f77a..a54dbd0 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,12 +1,13 @@ use anyhow::{bail, Result}; use clap::{Parser, Subcommand}; use colored::Colorize; -use dialoguer::{Confirm, Input, Select}; +use dialoguer::{Confirm, Input, Select, Password}; use std::path::PathBuf; use crate::config::{Language, manager::ConfigManager}; use crate::config::CommitFormat; use crate::utils::keyring::{get_supported_providers, get_default_model, get_default_base_url, provider_needs_api_key}; +use crate::utils::crypto::{encrypt, decrypt}; /// Mask API key with asterisks for security fn mask_api_key(key: Option<&str>) -> String { @@ -130,6 +131,10 @@ enum ConfigSubcommand { /// Output file (defaults to stdout) #[arg(short, long)] output: Option, + + /// Password for encryption (will prompt if not provided) + #[arg(short = 'p', long)] + password: Option, }, /// Import configuration @@ -137,6 +142,10 @@ enum ConfigSubcommand { /// Input file #[arg(short, long)] file: String, + + /// Password for decryption (will prompt if file is encrypted) + #[arg(short = 'p', long)] + password: Option, }, /// List available LLM models @@ -172,8 +181,8 @@ impl ConfigCommand { Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => self.set_keep_types_english(*keep, &config_path).await, Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => self.set_keep_changelog_types_english(*keep, &config_path).await, Some(ConfigSubcommand::Reset { force }) => self.reset(*force, &config_path).await, - Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref(), &config_path).await, - Some(ConfigSubcommand::Import { file }) => self.import_config(file, &config_path).await, + Some(ConfigSubcommand::Export { output, password }) => self.export_config(output.as_deref(), password.as_deref(), &config_path).await, + Some(ConfigSubcommand::Import { file, password }) => self.import_config(file, password.as_deref(), &config_path).await, Some(ConfigSubcommand::ListModels) => self.list_models(&config_path).await, Some(ConfigSubcommand::TestLlm) => self.test_llm(&config_path).await, Some(ConfigSubcommand::Path) => self.show_path(&config_path).await, @@ -737,28 +746,90 @@ impl ConfigCommand { Ok(()) } - async fn export_config(&self, output: Option<&str>, config_path: &Option) -> Result<()> { + async fn export_config(&self, output: Option<&str>, password: Option<&str>, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; let toml = manager.export()?; + let export_content = if let Some(path) = output { + let pwd = if let Some(p) = password { + p.to_string() + } else { + let confirm = Confirm::new() + .with_prompt("Encrypt the exported configuration?") + .default(true) + .interact()?; + + if confirm { + let pwd1 = Password::new() + .with_prompt("Enter encryption password") + .interact()?; + let pwd2 = Password::new() + .with_prompt("Confirm encryption password") + .interact()?; + + if pwd1 != pwd2 { + bail!("Passwords do not match"); + } + pwd1 + } else { + String::new() + } + }; + + if pwd.is_empty() { + toml + } else { + let encrypted = encrypt(toml.as_bytes(), &pwd)?; + format!("ENCRYPTED:{}", encrypted) + } + } else { + toml + }; + match output { Some(path) => { - std::fs::write(path, &toml)?; - println!("{} Configuration exported to {}", "✓".green(), path); + std::fs::write(path, &export_content)?; + if export_content.starts_with("ENCRYPTED:") { + println!("{} Configuration encrypted and exported to {}", "✓".green(), path); + } else { + println!("{} Configuration exported to {}", "✓".green(), path); + } } None => { - println!("{}", toml); + println!("{}", export_content); } } Ok(()) } - async fn import_config(&self, file: &str, config_path: &Option) -> Result<()> { + async fn import_config(&self, file: &str, password: Option<&str>, config_path: &Option) -> Result<()> { let content = std::fs::read_to_string(file)?; + let config_content = if content.starts_with("ENCRYPTED:") { + let encrypted_data = content.strip_prefix("ENCRYPTED:").unwrap(); + + let pwd = if let Some(p) = password { + p.to_string() + } else { + Password::new() + .with_prompt("Enter decryption password") + .interact()? + }; + + match decrypt(encrypted_data, &pwd) { + Ok(decrypted) => String::from_utf8(decrypted) + .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in decrypted content: {}", e))?, + Err(e) => { + bail!("Failed to decrypt configuration: {}. Please check your password.", e); + } + } + } else { + content + }; + let mut manager = self.get_manager(config_path)?; - manager.import(&content)?; + manager.import(&config_content)?; manager.save()?; println!("{} Configuration imported from {}", "✓".green(), file); diff --git a/tests/config_export_import_tests.rs b/tests/config_export_import_tests.rs new file mode 100644 index 0000000..3d04420 --- /dev/null +++ b/tests/config_export_import_tests.rs @@ -0,0 +1,359 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +fn create_git_repo(dir: &PathBuf) -> std::process::Output { + std::process::Command::new("git") + .args(&["init"]) + .current_dir(dir) + .output() + .expect("Failed to init git repo") +} + +fn configure_git_user(dir: &PathBuf) { + std::process::Command::new("git") + .args(&["config", "user.name", "Test User"]) + .current_dir(dir) + .output() + .expect("Failed to configure git user name"); + + std::process::Command::new("git") + .args(&["config", "user.email", "test@example.com"]) + .current_dir(dir) + .output() + .expect("Failed to configure git user email"); +} + +fn setup_git_repo(dir: &PathBuf) { + create_git_repo(dir); + configure_git_user(dir); +} + +fn init_quicommit(config_path: &PathBuf) { + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + cmd.assert().success(); +} + +mod config_export { + use super::*; + + #[test] + fn test_export_to_stdout() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + init_quicommit(&config_path); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["config", "export", "--config", config_path.to_str().unwrap()]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("version")) + .stdout(predicate::str::contains("[llm]")); + } + + #[test] + fn test_export_to_file() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let export_path = temp_dir.path().join("exported.toml"); + init_quicommit(&config_path); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "export", + "--config", config_path.to_str().unwrap(), + "--output", export_path.to_str().unwrap(), + "--password", "" + ]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("Configuration exported")); + + assert!(export_path.exists(), "Export file should be created"); + + let content = fs::read_to_string(&export_path).unwrap(); + assert!(content.contains("version"), "Export should contain version"); + assert!(content.contains("[llm]"), "Export should contain LLM config"); + } + + #[test] + fn test_export_encrypted() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let export_path = temp_dir.path().join("encrypted.toml"); + init_quicommit(&config_path); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "export", + "--config", config_path.to_str().unwrap(), + "--output", export_path.to_str().unwrap(), + "--password", "test_password_123" + ]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("encrypted and exported")); + + assert!(export_path.exists(), "Export file should be created"); + + let content = fs::read_to_string(&export_path).unwrap(); + assert!(content.starts_with("ENCRYPTED:"), "Encrypted file should start with ENCRYPTED:"); + assert!(!content.contains("[llm]"), "Encrypted content should not be readable"); + } +} + +mod config_import { + use super::*; + + #[test] + fn test_import_plain_config() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let import_path = temp_dir.path().join("import.toml"); + + let plain_config = r#" +version = "1" + +[llm] +provider = "openai" +model = "gpt-4" +max_tokens = 1000 +temperature = 0.7 +timeout = 60 +api_key_storage = "keyring" + +[commit] +format = "conventional" +auto_generate = true +allow_empty = false +gpg_sign = false +max_subject_length = 100 +require_scope = false +require_body = false +body_required_types = ["feat", "fix"] + +[tag] +version_prefix = "v" +auto_generate = true +gpg_sign = false +include_changelog = true + +[changelog] +path = "CHANGELOG.md" +auto_generate = true +format = "keep-a-changelog" +include_hashes = false +include_authors = false +group_by_type = true + +[theme] +colors = true +icons = true +date_format = "%Y-%m-%d" + +[language] +output_language = "en" +keep_types_english = true +keep_changelog_types_english = true +"#; + fs::write(&import_path, plain_config).unwrap(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "import", + "--config", config_path.to_str().unwrap(), + "--file", import_path.to_str().unwrap() + ]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("Configuration imported")); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["config", "get", "llm.provider", "--config", config_path.to_str().unwrap()]); + cmd.assert() + .success() + .stdout(predicate::str::contains("openai")); + } + + #[test] + fn test_import_encrypted_config() { + let temp_dir = TempDir::new().unwrap(); + let config_path1 = temp_dir.path().join("config1.toml"); + let config_path2 = temp_dir.path().join("config2.toml"); + let export_path = temp_dir.path().join("encrypted.toml"); + + init_quicommit(&config_path1); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "set", "llm.provider", "anthropic", + "--config", config_path1.to_str().unwrap() + ]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "export", + "--config", config_path1.to_str().unwrap(), + "--output", export_path.to_str().unwrap(), + "--password", "secure_password" + ]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "import", + "--config", config_path2.to_str().unwrap(), + "--file", export_path.to_str().unwrap(), + "--password", "secure_password" + ]); + cmd.assert() + .success() + .stdout(predicate::str::contains("Configuration imported")); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["config", "get", "llm.provider", "--config", config_path2.to_str().unwrap()]); + cmd.assert() + .success() + .stdout(predicate::str::contains("anthropic")); + } + + #[test] + fn test_import_encrypted_wrong_password() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let export_path = temp_dir.path().join("encrypted.toml"); + + init_quicommit(&config_path); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "export", + "--config", config_path.to_str().unwrap(), + "--output", export_path.to_str().unwrap(), + "--password", "correct_password" + ]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "import", + "--config", config_path.to_str().unwrap(), + "--file", export_path.to_str().unwrap(), + "--password", "wrong_password" + ]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("Failed to decrypt")); + } +} + +mod config_export_import_roundtrip { + use super::*; + + #[test] + fn test_roundtrip_plain() { + let temp_dir = TempDir::new().unwrap(); + let config_path1 = temp_dir.path().join("config1.toml"); + let config_path2 = temp_dir.path().join("config2.toml"); + let export_path = temp_dir.path().join("export.toml"); + + init_quicommit(&config_path1); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "set", "llm.model", "gpt-4-turbo", + "--config", config_path1.to_str().unwrap() + ]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "export", + "--config", config_path1.to_str().unwrap(), + "--output", export_path.to_str().unwrap(), + "--password", "" + ]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "import", + "--config", config_path2.to_str().unwrap(), + "--file", export_path.to_str().unwrap() + ]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["config", "get", "llm.model", "--config", config_path2.to_str().unwrap()]); + cmd.assert() + .success() + .stdout(predicate::str::contains("gpt-4-turbo")); + } + + #[test] + fn test_roundtrip_encrypted() { + let temp_dir = TempDir::new().unwrap(); + let config_path1 = temp_dir.path().join("config1.toml"); + let config_path2 = temp_dir.path().join("config2.toml"); + let export_path = temp_dir.path().join("encrypted.toml"); + let password = "my_secure_password_123"; + + init_quicommit(&config_path1); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "set", "llm.provider", "deepseek", + "--config", config_path1.to_str().unwrap() + ]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "set", "llm.model", "deepseek-chat", + "--config", config_path1.to_str().unwrap() + ]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "export", + "--config", config_path1.to_str().unwrap(), + "--output", export_path.to_str().unwrap(), + "--password", password + ]); + cmd.assert().success(); + + let exported_content = fs::read_to_string(&export_path).unwrap(); + assert!(exported_content.starts_with("ENCRYPTED:")); + assert!(!exported_content.contains("deepseek")); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&[ + "config", "import", + "--config", config_path2.to_str().unwrap(), + "--file", export_path.to_str().unwrap(), + "--password", password + ]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["config", "get", "llm.provider", "--config", config_path2.to_str().unwrap()]); + cmd.assert() + .success() + .stdout(predicate::str::contains("deepseek")); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["config", "get", "llm.model", "--config", config_path2.to_str().unwrap()]); + cmd.assert() + .success() + .stdout(predicate::str::contains("deepseek-chat")); + } +}