Skip to main content

orion_accessor/addr/
git.rs

1use crate::prelude::*;
2use getset::{Getters, Setters, WithSetters};
3use home::home_dir;
4
5///
6/// 支持通过SSH和HTTPS协议访问Git仓库
7///
8/// # Token认证示例
9///
10
11#[derive(Clone, Debug, Serialize, Deserialize, Default, Getters, Setters, WithSetters)]
12#[getset(get = "pub", set = "pub")]
13#[serde(rename = "git")]
14pub struct GitRepository {
15    repo: String,
16    /// 额外资源标识,例如特定子模块或配置文件
17    #[serde(rename = "res", skip_serializing_if = "Option::is_none")]
18    resource: Option<String>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    tag: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    branch: Option<String>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    rev: Option<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    path: Option<String>,
27    // 新增:SSH私钥路径
28    #[serde(skip_serializing_if = "Option::is_none")]
29    ssh_key: Option<String>,
30    // 新增:SSH密钥密码
31    #[serde(skip_serializing_if = "Option::is_none")]
32    ssh_passphrase: Option<String>,
33    // 新增:Token认证(用于HTTPS协议)
34    #[serde(skip_serializing_if = "Option::is_none")]
35    token: Option<String>,
36    // 新增:用户名(用于Token认证)
37    #[serde(skip_serializing_if = "Option::is_none")]
38    username: Option<String>,
39}
40
41impl PartialEq for GitRepository {
42    fn eq(&self, other: &Self) -> bool {
43        self.repo == other.repo
44    }
45}
46impl EnvEvalable<GitRepository> for GitRepository {
47    fn env_eval(self, dict: &EnvDict) -> GitRepository {
48        Self {
49            repo: self.repo.env_eval(dict),
50            resource: self.resource.env_eval(dict),
51            tag: self.tag.env_eval(dict),
52            branch: self.branch.env_eval(dict),
53            rev: self.rev.env_eval(dict),
54            path: self.path.env_eval(dict),
55            ssh_key: self.ssh_key.env_eval(dict),
56            ssh_passphrase: self.ssh_passphrase.env_eval(dict),
57            token: self.token.env_eval(dict),
58            username: self.username.env_eval(dict),
59        }
60    }
61}
62
63impl GitRepository {
64    fn set_option_field(
65        mut self,
66        slot: impl FnOnce(&mut Self) -> &mut Option<String>,
67        value: Option<String>,
68    ) -> Self {
69        *slot(&mut self) = value;
70        self
71    }
72
73    pub fn from<S: Into<String>>(repo: S) -> Self {
74        Self {
75            repo: repo.into(),
76            ..Default::default()
77        }
78    }
79    pub fn with_tag<S: Into<String>>(self, tag: S) -> Self {
80        self.with_opt_tag(Some(tag.into()))
81    }
82    pub fn with_opt_tag(self, tag: Option<String>) -> Self {
83        self.set_option_field(|repo| &mut repo.tag, tag)
84    }
85    pub fn with_branch<S: Into<String>>(self, branch: S) -> Self {
86        self.with_opt_branch(Some(branch.into()))
87    }
88    pub fn with_opt_branch(self, branch: Option<String>) -> Self {
89        self.set_option_field(|repo| &mut repo.branch, branch)
90    }
91    pub fn with_rev<S: Into<String>>(mut self, rev: S) -> Self {
92        self.rev = Some(rev.into());
93        self
94    }
95    pub fn with_path<S: Into<String>>(mut self, path: S) -> Self {
96        self.path = Some(path.into());
97        self
98    }
99    // 新增:设置SSH私钥
100    pub fn with_ssh_key<S: Into<String>>(mut self, ssh_key: S) -> Self {
101        self.ssh_key = Some(ssh_key.into());
102        self
103    }
104    // 新增:设置SSH密钥密码
105    pub fn with_ssh_passphrase<S: Into<String>>(mut self, ssh_passphrase: S) -> Self {
106        self.ssh_passphrase = Some(ssh_passphrase.into());
107        self
108    }
109    // 新增:设置Token认证
110    pub fn with_token<S: Into<String>>(self, token: S) -> Self {
111        self.with_opt_token(Some(token.into()))
112    }
113    // 新增:设置用户名(用于Token认证)
114    pub fn with_username<S: Into<String>>(self, username: S) -> Self {
115        self.with_opt_username(Some(username.into()))
116    }
117    // 新增:设置Token认证(可选)
118    pub fn with_opt_token(self, token: Option<String>) -> Self {
119        self.set_option_field(|repo| &mut repo.token, token)
120    }
121    // 新增:设置用户名(可选)
122    pub fn with_opt_username(self, username: Option<String>) -> Self {
123        self.set_option_field(|repo| &mut repo.username, username)
124    }
125
126    /// 为GitHub设置Token认证(便捷方法)
127    /// GitHub使用用户名+Token作为密码的方式
128    pub fn with_github_token<S: Into<String>>(self, token: S) -> Self {
129        let token = token.into();
130        self.with_opt_username(Some("git".to_string()))
131            .with_opt_token(Some(token))
132    }
133
134    /// 为GitLab设置Token认证(便捷方法)
135    /// GitLab可以使用"oauth2"作为用户名,Token作为密码
136    pub fn with_gitlab_token<S: Into<String>>(self, token: S) -> Self {
137        let token = token.into();
138        self.with_opt_username(Some("oauth2".to_string()))
139            .with_opt_token(Some(token))
140    }
141
142    /// 为Gitea设置Token认证(便捷方法)
143    /// Gitea可以使用Token作为密码
144    pub fn with_gitea_token<S: Into<String>>(self, token: S) -> Self {
145        let token = token.into();
146        self.with_opt_username(Some("git".to_string()))
147            .with_opt_token(Some(token))
148    }
149
150    /// 从环境变量读取Token认证
151    ///
152    /// # Arguments
153    /// * `env_var` - 环境变量名,例如 "GITHUB_TOKEN"
154    pub fn with_env_token(self, env_var: &str) -> Self {
155        match std::env::var(env_var) {
156            Ok(token) => self.with_opt_token(Some(token)),
157            Err(_) => self,
158        }
159    }
160
161    /// 从环境变量读取GitHub Token认证
162    pub fn with_github_env_token(self) -> Self {
163        self.with_env_token("GITHUB_TOKEN")
164    }
165
166    /// 从环境变量读取GitLab Token认证
167    pub fn with_gitlab_env_token(self) -> Self {
168        match std::env::var("GITLAB_TOKEN") {
169            Ok(token) => self
170                .with_opt_username(Some("oauth2".to_string()))
171                .with_opt_token(Some(token)),
172            Err(_) => self,
173        }
174    }
175
176    /// 从环境变量读取Gitea Token认证
177    pub fn with_gitea_env_token(self) -> Self {
178        self.with_env_token("GITEA_TOKEN")
179    }
180
181    /// 从~/.git-credentials文件读取token
182    pub fn with_git_credentials(mut self) -> Self {
183        if let Some(credentials) = Self::read_git_credentials() {
184            for (url, username, token) in credentials {
185                if self.repo.starts_with(&url) {
186                    self = self
187                        .with_opt_username(Some(username))
188                        .with_opt_token(Some(token));
189                    break;
190                }
191            }
192        }
193        self
194    }
195    /// 读取~/.git-credentials文件
196    pub fn read_git_credentials() -> Option<Vec<(String, String, String)>> {
197        let home = home_dir()?;
198        let credentials_path = home.join(".git-credentials");
199        Self::read_git_credentials_at(&credentials_path)
200    }
201
202    fn read_git_credentials_at(path: &Path) -> Option<Vec<(String, String, String)>> {
203        use std::fs;
204        use std::io::{BufRead, BufReader};
205
206        if !path.exists() {
207            return None;
208        }
209
210        let file = fs::File::open(path).ok()?;
211        let reader = BufReader::new(file);
212        let mut credentials = Vec::new();
213
214        for line in reader.lines().map_while(Result::ok) {
215            let line = line.trim();
216            if line.is_empty() || line.starts_with('#') {
217                continue;
218            }
219
220            if let Ok(url) = url::Url::parse(line) {
221                let host = url.host_str()?;
222                let scheme = url.scheme();
223                let path = url.path();
224
225                let base_url = format!("{scheme}://{host}{path}");
226
227                let username = url.username();
228                if !username.is_empty()
229                    && let Some(password) = url.password()
230                {
231                    credentials.push((base_url, username.to_string(), password.to_string()));
232                }
233            }
234        }
235
236        if credentials.is_empty() {
237            None
238        } else {
239            Some(credentials)
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use std::io::Write;
248    use tempfile::NamedTempFile;
249
250    #[test]
251    fn test_git_repository_from() {
252        let repo = GitRepository::from("https://github.com/user/repo.git");
253        assert_eq!(repo.repo(), "https://github.com/user/repo.git");
254        assert!(repo.tag().is_none());
255        assert!(repo.branch().is_none());
256        assert!(repo.token().is_none());
257    }
258
259    #[test]
260    fn test_git_repository_with_tag() {
261        let repo = GitRepository::from("https://github.com/user/repo.git").with_tag("v1.0.0");
262        assert_eq!(repo.tag().as_ref(), Some(&"v1.0.0".to_string()));
263    }
264
265    #[test]
266    fn test_git_repository_with_opt_tag() {
267        let repo1 = GitRepository::from("https://github.com/user/repo.git")
268            .with_opt_tag(Some("v1.0.0".to_string()));
269        assert_eq!(repo1.tag().as_ref(), Some(&"v1.0.0".to_string()));
270
271        let repo2 = GitRepository::from("https://github.com/user/repo.git").with_opt_tag(None);
272        assert!(repo2.tag().is_none());
273    }
274
275    #[test]
276    fn test_git_repository_with_branch() {
277        let repo = GitRepository::from("https://github.com/user/repo.git").with_branch("main");
278        assert_eq!(repo.branch().as_ref(), Some(&"main".to_string()));
279    }
280
281    #[test]
282    fn test_git_repository_with_opt_branch() {
283        let repo1 = GitRepository::from("https://github.com/user/repo.git")
284            .with_opt_branch(Some("main".to_string()));
285        assert_eq!(repo1.branch().as_ref(), Some(&"main".to_string()));
286
287        let repo2 = GitRepository::from("https://github.com/user/repo.git").with_opt_branch(None);
288        assert!(repo2.branch().is_none());
289    }
290
291    #[test]
292    fn test_git_repository_with_rev() {
293        let repo = GitRepository::from("https://github.com/user/repo.git").with_rev("abc123");
294        assert_eq!(repo.rev().as_ref(), Some(&"abc123".to_string()));
295    }
296
297    #[test]
298    fn test_git_repository_with_path() {
299        let repo = GitRepository::from("https://github.com/user/repo.git").with_path("subdir");
300        assert_eq!(repo.path().as_ref(), Some(&"subdir".to_string()));
301    }
302
303    #[test]
304    fn test_git_repository_with_ssh_key() {
305        let repo = GitRepository::from("git@github.com:user/repo.git").with_ssh_key("/path/to/key");
306        assert_eq!(repo.ssh_key().as_ref(), Some(&"/path/to/key".to_string()));
307    }
308
309    #[test]
310    fn test_git_repository_with_ssh_passphrase() {
311        let repo =
312            GitRepository::from("git@github.com:user/repo.git").with_ssh_passphrase("secret");
313        assert_eq!(repo.ssh_passphrase().as_ref(), Some(&"secret".to_string()));
314    }
315
316    #[test]
317    fn test_git_repository_with_token() {
318        let repo = GitRepository::from("https://github.com/user/repo.git").with_token("token123");
319        assert_eq!(repo.token().as_ref(), Some(&"token123".to_string()));
320    }
321
322    #[test]
323    fn test_git_repository_with_username() {
324        let repo = GitRepository::from("https://github.com/user/repo.git").with_username("user");
325        assert_eq!(repo.username().as_ref(), Some(&"user".to_string()));
326    }
327
328    #[test]
329    fn test_git_repository_with_opt_token() {
330        let repo1 = GitRepository::from("https://github.com/user/repo.git")
331            .with_opt_token(Some("token123".to_string()));
332        assert_eq!(repo1.token().as_ref(), Some(&"token123".to_string()));
333
334        let repo2 = GitRepository::from("https://github.com/user/repo.git").with_opt_token(None);
335        assert!(repo2.token().is_none());
336    }
337
338    #[test]
339    fn test_git_repository_with_opt_username() {
340        let repo1 = GitRepository::from("https://github.com/user/repo.git")
341            .with_opt_username(Some("user".to_string()));
342        assert_eq!(repo1.username().as_ref(), Some(&"user".to_string()));
343
344        let repo2 = GitRepository::from("https://github.com/user/repo.git").with_opt_username(None);
345        assert!(repo2.username().is_none());
346    }
347
348    #[test]
349    fn test_git_repository_with_github_token() {
350        let repo =
351            GitRepository::from("https://github.com/user/repo.git").with_github_token("ghp_token");
352        assert_eq!(repo.username().as_ref(), Some(&"git".to_string()));
353        assert_eq!(repo.token().as_ref(), Some(&"ghp_token".to_string()));
354    }
355
356    #[test]
357    fn test_git_repository_with_gitlab_token() {
358        let repo = GitRepository::from("https://gitlab.com/user/repo.git")
359            .with_gitlab_token("glpat_token");
360        assert_eq!(repo.username().as_ref(), Some(&"oauth2".to_string()));
361        assert_eq!(repo.token().as_ref(), Some(&"glpat_token".to_string()));
362    }
363
364    #[test]
365    fn test_git_repository_with_gitea_token() {
366        let repo =
367            GitRepository::from("https://gitea.com/user/repo.git").with_gitea_token("gitea_token");
368        assert_eq!(repo.username().as_ref(), Some(&"git".to_string()));
369        assert_eq!(repo.token().as_ref(), Some(&"gitea_token".to_string()));
370    }
371
372    #[test]
373    fn test_read_git_credentials_valid_file() {
374        let mut temp_file = NamedTempFile::new().unwrap();
375        writeln!(temp_file, "https://user:token@github.com").unwrap();
376        writeln!(temp_file, "https://oauth2:token@gitlab.com/user/repo.git").unwrap();
377        writeln!(temp_file, "# This is a comment").unwrap();
378        // 移除空字符串的writeln!调用
379        temp_file.flush().unwrap();
380
381        let credentials = GitRepository::read_git_credentials_from_path(temp_file.path());
382        assert!(credentials.is_some());
383        let creds = credentials.unwrap();
384        assert_eq!(creds.len(), 2);
385
386        assert_eq!(creds[0].0, "https://github.com/");
387        assert_eq!(creds[0].1, "user");
388        assert_eq!(creds[0].2, "token");
389
390        assert_eq!(creds[1].0, "https://gitlab.com/user/repo.git");
391        assert_eq!(creds[1].1, "oauth2");
392        assert_eq!(creds[1].2, "token");
393    }
394
395    #[test]
396    fn test_read_git_credentials_empty_file() {
397        let temp_file = NamedTempFile::new().unwrap();
398        let credentials = GitRepository::read_git_credentials_from_path(temp_file.path());
399        assert!(credentials.is_none());
400    }
401
402    #[test]
403    fn test_read_git_credentials_invalid_url() {
404        let mut temp_file = NamedTempFile::new().unwrap();
405        writeln!(temp_file, "invalid-url").unwrap();
406        temp_file.flush().unwrap();
407
408        let credentials = GitRepository::read_git_credentials_from_path(temp_file.path());
409        assert!(credentials.is_none());
410    }
411
412    #[test]
413    fn test_git_repository_with_git_credentials() {
414        let mut temp_file = NamedTempFile::new().unwrap();
415        writeln!(temp_file, "https://user:token@github.com/user/repo.git").unwrap();
416        temp_file.flush().unwrap();
417
418        let repo = GitRepository::from("https://github.com/user/repo.git")
419            .with_git_credentials_from_path(temp_file.path());
420
421        assert_eq!(repo.username().as_ref(), Some(&"user".to_string()));
422        assert_eq!(repo.token().as_ref(), Some(&"token".to_string()));
423    }
424
425    #[test]
426    fn test_git_repository_with_git_credentials_no_match() {
427        let mut temp_file = NamedTempFile::new().unwrap();
428        writeln!(temp_file, "https://user:token@gitlab.com/user/repo.git").unwrap();
429        temp_file.flush().unwrap();
430
431        let repo = GitRepository::from("https://github.com/user/repo.git")
432            .with_git_credentials_from_path(temp_file.path());
433
434        assert!(repo.username().is_none());
435        assert!(repo.token().is_none());
436    }
437
438    // Helper methods for testing
439    impl GitRepository {
440        fn read_git_credentials_from_path(
441            path: &std::path::Path,
442        ) -> Option<Vec<(String, String, String)>> {
443            Self::read_git_credentials_at(path)
444        }
445
446        fn with_git_credentials_from_path(mut self, path: &std::path::Path) -> Self {
447            if let Some(credentials) = Self::read_git_credentials_at(path) {
448                for (url, username, token) in credentials {
449                    if self.repo.starts_with(&url) {
450                        self.username = Some(username);
451                        self.token = Some(token);
452                        break;
453                    }
454                }
455            }
456            self
457        }
458    }
459}