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
260async 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 let result_invalid_url = build_github_api_url("invalid-url", "path/to/config.json");
561 assert!(result_invalid_url.is_err());
562 assert!(
564 result_invalid_url
565 .unwrap_err()
566 .contains("URL must be a GitHub repository URL")
567 );
568
569 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 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 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}