Skip to main content

kftray_commons/utils/
github.rs

1use std::path::Path;
2
3use log::{
4    error,
5    info,
6    warn,
7};
8use sqlx::SqlitePool;
9
10use crate::db::get_db_pool;
11use crate::utils::db_mode::{
12    DatabaseManager,
13    DatabaseMode,
14};
15
16pub struct GitHubConfig {
17    pub repo_url: String,
18    pub config_paths: Vec<String>,
19    pub use_system_credentials: bool,
20    pub github_token: Option<String>,
21    pub flush_existing: bool,
22}
23
24pub type GitHubResult<T> = Result<T, String>;
25
26pub struct GitHubRepository;
27
28impl GitHubRepository {
29    pub async fn import_configs(config: GitHubConfig, mode: DatabaseMode) -> GitHubResult<()> {
30        if config.config_paths.is_empty() {
31            return Err("At least one config path must be provided".to_string());
32        }
33
34        let config_content = Self::clone_and_read_config(
35            &config.repo_url,
36            &config.config_paths,
37            config.use_system_credentials,
38            config.github_token,
39        )?;
40
41        Self::process_config_content(&config_content, config.flush_existing, mode).await
42    }
43
44    fn clone_and_read_config(
45        repo_url: &str, config_paths: &[String], use_system_credentials: bool,
46        github_token: Option<String>,
47    ) -> GitHubResult<String> {
48        use git2::{
49            CertificateCheckStatus,
50            Cred,
51            FetchOptions,
52            RemoteCallbacks,
53            build::RepoBuilder,
54        };
55        use tempfile::TempDir;
56
57        let temp_dir = TempDir::new().map_err(|e| format!("Failed to create temp dir: {e}"))?;
58
59        let mut callbacks = RemoteCallbacks::new();
60
61        if use_system_credentials || github_token.is_some() {
62            let token = github_token.clone();
63            let attempts = std::sync::atomic::AtomicUsize::new(0);
64
65            callbacks.credentials(move |url, username_from_url, allowed_types| {
66                let current_attempt = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
67                if current_attempt >= 3 {
68                    return Err(git2::Error::from_str(
69                        "Authentication failed after 3 attempts",
70                    ));
71                }
72
73                info!(
74                    "Auth attempt {} - URL: {}, Username: {:?}",
75                    current_attempt + 1,
76                    url,
77                    username_from_url
78                );
79
80                let is_https_url = url.starts_with("https://");
81
82                if is_https_url
83                    && allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT)
84                    && let Some(token) = &token
85                {
86                    info!("Using token authentication for HTTPS");
87                    return Cred::userpass_plaintext("git", token);
88                }
89
90                if use_system_credentials {
91                    let username = username_from_url.unwrap_or("git");
92                    Self::get_system_credentials(url, username)
93                } else {
94                    Err(git2::Error::from_str("No authentication method configured"))
95                }
96            });
97        }
98
99        callbacks.certificate_check(|_cert, _hostname| Ok(CertificateCheckStatus::CertificateOk));
100
101        let mut fetch_opts = FetchOptions::new();
102        fetch_opts.remote_callbacks(callbacks);
103
104        let mut builder = RepoBuilder::new();
105        builder.fetch_options(fetch_opts);
106
107        info!("Attempting to clone repository: {repo_url}");
108
109        match builder.clone(repo_url, temp_dir.path()) {
110            Ok(_) => {
111                info!("Successfully cloned repository");
112                Self::read_config_files(temp_dir.path(), config_paths)
113            }
114            Err(e) => {
115                warn!("Repository clone failed: {e}, trying fallback with system git command");
116                Self::try_clone_with_system_git(repo_url, temp_dir.path(), config_paths)
117            }
118        }
119    }
120
121    fn get_system_credentials(url: &str, username: &str) -> Result<git2::Cred, git2::Error> {
122        use git2::Cred;
123
124        if (url.starts_with("git@") || url.starts_with("ssh://"))
125            && let Ok(cred) = Cred::ssh_key_from_agent(username)
126        {
127            info!("Successfully authenticated with SSH agent");
128            return Ok(cred);
129        }
130
131        if let Ok(config) = git2::Config::open_default()
132            && let Ok(cred) = Cred::credential_helper(&config, url, Some(username))
133        {
134            info!("Successfully retrieved credentials from OS credential store");
135            return Ok(cred);
136        }
137
138        Err(git2::Error::from_str("No valid credentials found"))
139    }
140
141    fn try_clone_with_system_git(
142        repo_url: &str, path: &Path, config_paths: &[String],
143    ) -> GitHubResult<String> {
144        use std::process::Command;
145
146        let output = Command::new("git")
147            .arg("clone")
148            .arg("--depth=1")
149            .arg("--single-branch")
150            .arg("--no-tags")
151            .arg("--filter=blob:none")
152            .arg("--recurse-submodules=no")
153            .arg(repo_url)
154            .arg(path)
155            .output()
156            .map_err(|e| format!("Failed to execute git command: {e}"))?;
157
158        if output.status.success() {
159            info!("Successfully cloned repository using system git");
160            Self::read_config_files(path, config_paths)
161        } else {
162            let error_msg = String::from_utf8_lossy(&output.stderr);
163            error!("System git clone failed: {error_msg}");
164            Err(format!(
165                "Failed to clone repository. Please check your credentials and repository URL. Error: {error_msg}"
166            ))
167        }
168    }
169
170    fn read_config_files(temp_dir: &Path, config_paths: &[String]) -> GitHubResult<String> {
171        let canonical_root = temp_dir
172            .canonicalize()
173            .map_err(|e| format!("Failed to resolve clone directory: {e}"))?;
174
175        let mut merged_configs: Vec<serde_json::Value> = Vec::new();
176
177        for config_path in config_paths {
178            let full_path = Self::resolve_config_path(&canonical_root, config_path)?;
179
180            let content = std::fs::read_to_string(&full_path).map_err(|e| {
181                format!("Failed to read config file at {}: {e}", full_path.display())
182            })?;
183
184            let value: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
185                format!(
186                    "Failed to parse config file at {}: {e}",
187                    full_path.display()
188                )
189            })?;
190
191            match value {
192                serde_json::Value::Array(items) => merged_configs.extend(items),
193                other => merged_configs.push(other),
194            }
195        }
196
197        serde_json::to_string(&merged_configs)
198            .map_err(|e| format!("Failed to merge config files: {e}"))
199    }
200
201    fn resolve_config_path(
202        canonical_root: &Path, config_path: &str,
203    ) -> GitHubResult<std::path::PathBuf> {
204        use std::path::Component;
205
206        if config_path.trim().is_empty() {
207            return Err("Config path must not be empty".to_string());
208        }
209
210        let candidate = Path::new(config_path);
211
212        if candidate.is_absolute()
213            || candidate
214                .components()
215                .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_)))
216        {
217            return Err(format!("Invalid config path: {config_path}"));
218        }
219
220        let joined = canonical_root.join(candidate);
221
222        let canonical_file = joined
223            .canonicalize()
224            .map_err(|e| format!("Failed to read config file at {}: {e}", joined.display()))?;
225
226        if !canonical_file.starts_with(canonical_root) {
227            return Err(format!(
228                "Config path escapes repository directory: {config_path}"
229            ));
230        }
231
232        Ok(canonical_file)
233    }
234
235    async fn process_config_content(
236        config_content: &str, flush_existing: bool, mode: DatabaseMode,
237    ) -> GitHubResult<()> {
238        if flush_existing && mode == DatabaseMode::File {
239            info!("Flushing existing configurations before import");
240            clear_existing_configs_with_mode(mode).await?;
241        }
242
243        let context = DatabaseManager::get_context(mode).await?;
244
245        info!("Importing configurations using incremental merge");
246
247        crate::utils::config::import_configs_with_pool_and_mode(
248            config_content.to_string(),
249            &context.pool,
250            mode,
251        )
252        .await
253        .map_err(|e| format!("Failed to import configs: {e}"))?;
254
255        info!("Configuration import completed successfully");
256        Ok(())
257    }
258}
259
260/// Clear existing configurations from database
261async fn clear_existing_configs_with_pool(pool: &SqlitePool) -> Result<(), sqlx::Error> {
262    let mut conn = pool.acquire().await?;
263    sqlx::query("DELETE FROM configs")
264        .execute(&mut *conn)
265        .await?;
266    Ok(())
267}
268
269pub async fn clear_existing_configs() -> Result<(), sqlx::Error> {
270    let pool = get_db_pool()
271        .await
272        .map_err(|e| sqlx::Error::Configuration(format!("DB Pool error: {e}").into()))?;
273    clear_existing_configs_with_pool(&pool).await
274}
275
276pub async fn clear_existing_configs_with_mode(mode: DatabaseMode) -> Result<(), String> {
277    use crate::utils::db_mode::DatabaseManager;
278
279    let context = DatabaseManager::get_context(mode).await?;
280    clear_existing_configs_with_pool(&context.pool)
281        .await
282        .map_err(|e| e.to_string())
283}
284
285pub fn build_github_api_url(repo_url: &str, config_path: &str) -> Result<String, String> {
286    let base_api_url = "https://api.github.com/repos";
287
288    if !repo_url.contains("github.com") {
289        return Err("URL must be a GitHub repository URL".to_string());
290    }
291
292    let repo_url_without_query = repo_url.split('?').next().unwrap_or(repo_url);
293
294    let relevant_part = repo_url_without_query
295        .trim_start_matches("https://")
296        .trim_start_matches("http://")
297        .trim_start_matches("github.com/");
298
299    let url_parts: Vec<&str> = relevant_part
300        .split('/')
301        .filter(|&x| !x.is_empty())
302        .collect();
303
304    if url_parts.len() < 2 {
305        return Err("Invalid GitHub repository URL format after parsing".to_string());
306    }
307
308    let owner = url_parts[0];
309    let repo = url_parts[1];
310
311    Ok(format!(
312        "{base_api_url}/{owner}/{repo}/contents/{config_path}"
313    ))
314}
315
316#[cfg(test)]
317mod tests {
318    use sqlx::SqlitePool;
319
320    use super::*;
321    use crate::db::create_db_table;
322    use crate::models::config_model::Config;
323    use crate::utils::config::insert_config_with_pool;
324
325    async fn setup_test_db() -> SqlitePool {
326        let pool = SqlitePool::connect("sqlite::memory:")
327            .await
328            .expect("Failed to connect to in-memory database");
329        create_db_table(&pool)
330            .await
331            .expect("Failed to create tables");
332        crate::utils::migration::migrate_configs(Some(&pool))
333            .await
334            .expect("Failed to run migrations");
335        pool
336    }
337
338    #[tokio::test]
339    async fn test_clear_existing_configs() {
340        let pool = setup_test_db().await;
341
342        insert_config_with_pool(Config::default(), &pool)
343            .await
344            .unwrap();
345        insert_config_with_pool(Config::default(), &pool)
346            .await
347            .unwrap();
348
349        let configs_before = crate::utils::config::read_configs_with_pool(&pool)
350            .await
351            .unwrap();
352        assert_eq!(configs_before.len(), 2);
353
354        clear_existing_configs_with_pool(&pool).await.unwrap();
355
356        let configs_after = crate::utils::config::read_configs_with_pool(&pool)
357            .await
358            .unwrap();
359        assert!(configs_after.is_empty());
360    }
361
362    #[tokio::test]
363    async fn test_clear_existing_configs_public_function() {
364        let pool = setup_test_db().await;
365
366        insert_config_with_pool(Config::default(), &pool)
367            .await
368            .unwrap();
369
370        let configs_before = crate::utils::config::read_configs_with_pool(&pool)
371            .await
372            .unwrap();
373        assert_eq!(configs_before.len(), 1);
374
375        let result = clear_existing_configs_with_pool(&pool).await;
376        assert!(result.is_ok());
377
378        let configs_after = crate::utils::config::read_configs_with_pool(&pool)
379            .await
380            .unwrap();
381        assert!(configs_after.is_empty());
382    }
383
384    #[tokio::test]
385    async fn test_clear_existing_configs_error_handling() {
386        let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
387        pool.close().await;
388
389        let result = clear_existing_configs_with_pool(&pool).await;
390        assert!(result.is_err());
391    }
392
393    #[test]
394    fn test_read_config_files_merges_multiple_arrays() {
395        let temp_dir = tempfile::TempDir::new().unwrap();
396
397        std::fs::write(
398            temp_dir.path().join("configs-a.json"),
399            r#"[{"id": 1}, {"id": 2}]"#,
400        )
401        .unwrap();
402        std::fs::write(temp_dir.path().join("configs-b.json"), r#"[{"id": 3}]"#).unwrap();
403
404        let merged = GitHubRepository::read_config_files(
405            temp_dir.path(),
406            &["configs-a.json".to_string(), "configs-b.json".to_string()],
407        )
408        .unwrap();
409
410        let parsed: serde_json::Value = serde_json::from_str(&merged).unwrap();
411        assert_eq!(parsed.as_array().unwrap().len(), 3);
412    }
413
414    #[test]
415    fn test_read_config_files_merges_single_objects() {
416        let temp_dir = tempfile::TempDir::new().unwrap();
417
418        std::fs::write(temp_dir.path().join("a.json"), r#"{"id": 1}"#).unwrap();
419        std::fs::write(temp_dir.path().join("b.json"), r#"{"id": 2}"#).unwrap();
420
421        let merged = GitHubRepository::read_config_files(
422            temp_dir.path(),
423            &["a.json".to_string(), "b.json".to_string()],
424        )
425        .unwrap();
426
427        let parsed: serde_json::Value = serde_json::from_str(&merged).unwrap();
428        assert_eq!(parsed.as_array().unwrap().len(), 2);
429    }
430
431    #[test]
432    fn test_read_config_files_missing_file_errors() {
433        let temp_dir = tempfile::TempDir::new().unwrap();
434
435        let result =
436            GitHubRepository::read_config_files(temp_dir.path(), &["missing.json".to_string()]);
437
438        assert!(result.is_err());
439        assert!(result.unwrap_err().contains("Failed to read config file"));
440    }
441
442    #[test]
443    fn test_read_config_files_rejects_parent_dir_traversal() {
444        let temp_dir = tempfile::TempDir::new().unwrap();
445
446        let result =
447            GitHubRepository::read_config_files(temp_dir.path(), &["../config.json".to_string()]);
448
449        assert!(result.is_err());
450        assert!(result.unwrap_err().contains("Invalid config path"));
451    }
452
453    #[test]
454    fn test_read_config_files_rejects_absolute_path() {
455        let temp_dir = tempfile::TempDir::new().unwrap();
456
457        let absolute_path = if cfg!(windows) {
458            "C:\\config.json".to_string()
459        } else {
460            "/etc/passwd".to_string()
461        };
462
463        let result = GitHubRepository::read_config_files(temp_dir.path(), &[absolute_path]);
464
465        assert!(result.is_err());
466        assert!(result.unwrap_err().contains("Invalid config path"));
467    }
468
469    #[cfg(unix)]
470    #[test]
471    fn test_read_config_files_rejects_symlink_outside_repo() {
472        let temp_dir = tempfile::TempDir::new().unwrap();
473        let outside_dir = tempfile::TempDir::new().unwrap();
474
475        let outside_file = outside_dir.path().join("secret.json");
476        std::fs::write(&outside_file, r#"{"id": "secret"}"#).unwrap();
477
478        let link_path = temp_dir.path().join("config.json");
479        std::os::unix::fs::symlink(&outside_file, &link_path).unwrap();
480
481        let result =
482            GitHubRepository::read_config_files(temp_dir.path(), &["config.json".to_string()]);
483
484        assert!(result.is_err());
485        assert!(result.unwrap_err().contains("escapes repository directory"));
486    }
487
488    #[test]
489    fn test_build_github_api_url_edge_cases() {
490        let url1 =
491            build_github_api_url("https://github.com///owner///repo///", "config.json").unwrap();
492        assert_eq!(
493            url1,
494            "https://api.github.com/repos/owner/repo/contents/config.json"
495        );
496
497        let url2 =
498            build_github_api_url("https://github.com/owner/repo?ref=main", "config.json").unwrap();
499        assert_eq!(
500            url2,
501            "https://api.github.com/repos/owner/repo/contents/config.json"
502        );
503
504        let url3 = build_github_api_url("https://github.com/owner%20name/repo-name", "config.json")
505            .unwrap();
506        assert_eq!(
507            url3,
508            "https://api.github.com/repos/owner%20name/repo-name/contents/config.json"
509        );
510    }
511
512    #[test]
513    fn test_build_github_api_url_valid() {
514        let url =
515            build_github_api_url("https://github.com/owner/repo", "path/to/config.json").unwrap();
516        assert_eq!(
517            url,
518            "https://api.github.com/repos/owner/repo/contents/path/to/config.json"
519        );
520
521        let url_no_https = build_github_api_url("github.com/owner/repo", "config.json").unwrap();
522        assert_eq!(
523            url_no_https,
524            "https://api.github.com/repos/owner/repo/contents/config.json"
525        );
526
527        let url_trailing_slash =
528            build_github_api_url("https://github.com/owner/repo/", "file").unwrap();
529        assert_eq!(
530            url_trailing_slash,
531            "https://api.github.com/repos/owner/repo/contents/file"
532        );
533    }
534
535    #[test]
536    fn test_build_github_api_url_with_http_prefix() {
537        let url = build_github_api_url("http://github.com/owner/repo", "config.json").unwrap();
538        assert_eq!(
539            url,
540            "https://api.github.com/repos/owner/repo/contents/config.json"
541        );
542    }
543
544    #[test]
545    fn test_build_github_api_url_with_complex_paths() {
546        let url = build_github_api_url(
547            "https://github.com/owner/repo/tree/main/some/folder",
548            "config.json",
549        )
550        .unwrap();
551        assert_eq!(
552            url,
553            "https://api.github.com/repos/owner/repo/contents/config.json"
554        );
555    }
556
557    #[test]
558    fn test_build_github_api_url_invalid() {
559        // Test completely invalid URL format
560        let result_invalid_url = build_github_api_url("invalid-url", "path/to/config.json");
561        assert!(result_invalid_url.is_err());
562        // Should fail the github.com check first
563        assert!(
564            result_invalid_url
565                .unwrap_err()
566                .contains("URL must be a GitHub repository URL")
567        );
568
569        // Test non-GitHub URL (should fail the github.com check)
570        let result_not_github =
571            build_github_api_url("https://gitlab.com/owner/repo", "config.json");
572        assert!(result_not_github.is_err());
573        assert!(
574            result_not_github
575                .unwrap_err()
576                .contains("URL must be a GitHub repository URL")
577        );
578
579        // Test URL missing repo part (should fail length check after parsing)
580        let result_too_short = build_github_api_url("github.com/owner", "config.json");
581        assert!(result_too_short.is_err());
582        assert!(
583            result_too_short
584                .unwrap_err()
585                .contains("Invalid GitHub repository URL format after parsing")
586        );
587
588        // Test empty URL (should fail github.com check)
589        let result_empty = build_github_api_url("", "config.json");
590        assert!(result_empty.is_err());
591        assert!(
592            result_empty
593                .unwrap_err()
594                .contains("URL must be a GitHub repository URL")
595        );
596    }
597}