1use crate::cache::lock::CacheLock;
25use crate::config::GlobalConfig;
26use crate::core::AgpmError;
27use crate::git::{GitRepo, parse_git_url};
28use crate::manifest::Manifest;
29use crate::utils::fs::ensure_dir;
30use crate::utils::security::validate_path_security;
31use anyhow::{Context, Result};
32use futures::future::join_all;
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::path::{Path, PathBuf};
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Source {
50 pub name: String,
52 pub url: String,
54 pub description: Option<String>,
56 pub enabled: bool,
58 #[serde(skip)]
60 pub local_path: Option<PathBuf>,
61}
62
63impl Source {
64 #[must_use]
71 pub const fn new(name: String, url: String) -> Self {
72 Self {
73 name,
74 url,
75 description: None,
76 enabled: true,
77 local_path: None,
78 }
79 }
80
81 #[must_use]
87 pub fn with_description(mut self, desc: String) -> Self {
88 self.description = Some(desc);
89 self
90 }
91
92 #[must_use]
101 pub fn cache_dir(&self, base_dir: &Path) -> PathBuf {
102 let (owner, repo) =
103 parse_git_url(&self.url).unwrap_or(("unknown".to_string(), self.name.clone()));
104 base_dir.join("sources").join(format!("{owner}_{repo}"))
105 }
106}
107
108#[derive(Debug, Clone)]
119pub struct SourceManager {
120 sources: HashMap<String, Source>,
122 cache_dir: PathBuf,
124}
125
126fn is_local_filesystem_path(url: &str) -> bool {
128 if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
130 return true;
131 }
132
133 #[cfg(windows)]
135 {
136 if url.len() >= 3 {
138 let chars: Vec<char> = url.chars().collect();
139 if chars.len() >= 3
140 && chars[0].is_ascii_alphabetic()
141 && chars[1] == ':'
142 && (chars[2] == '\\' || chars[2] == '/')
143 {
144 return true;
145 }
146 }
147 if url.starts_with("\\\\") {
149 return true;
150 }
151 }
152
153 false
154}
155
156impl SourceManager {
157 pub fn new() -> Result<Self> {
163 let cache_dir = crate::config::get_cache_dir()?;
164 Ok(Self {
165 sources: HashMap::new(),
166 cache_dir,
167 })
168 }
169
170 #[must_use]
176 pub fn new_with_cache(cache_dir: PathBuf) -> Self {
177 Self {
178 sources: HashMap::new(),
179 cache_dir,
180 }
181 }
182
183 pub fn from_manifest(manifest: &Manifest) -> Result<Self> {
198 let cache_dir = crate::config::get_cache_dir()?;
199 let mut manager = Self::new_with_cache(cache_dir);
200
201 for (name, url) in &manifest.sources {
203 let source = Source::new(name.clone(), url.clone());
204 manager.sources.insert(name.clone(), source);
205 }
206
207 Ok(manager)
208 }
209
210 pub async fn from_manifest_with_global(manifest: &Manifest) -> Result<Self> {
223 let cache_dir = crate::config::get_cache_dir()?;
224 let mut manager = Self::new_with_cache(cache_dir);
225
226 let global_config = GlobalConfig::load().await.unwrap_or_default();
228 let merged_sources = global_config.merge_sources(&manifest.sources);
229
230 for (name, url) in &merged_sources {
232 let source = Source::new(name.clone(), url.clone());
233 manager.sources.insert(name.clone(), source);
234 }
235
236 Ok(manager)
237 }
238
239 #[must_use]
246 pub fn from_manifest_with_cache(manifest: &Manifest, cache_dir: PathBuf) -> Self {
247 let mut manager = Self::new_with_cache(cache_dir);
248
249 for (name, url) in &manifest.sources {
251 let source = Source::new(name.clone(), url.clone());
252 manager.sources.insert(name.clone(), source);
253 }
254
255 manager
256 }
257
258 pub fn add(&mut self, source: Source) -> Result<()> {
268 if self.sources.contains_key(&source.name) {
269 return Err(AgpmError::ConfigError {
270 message: format!("Source '{}' already exists", source.name),
271 }
272 .into());
273 }
274
275 self.sources.insert(source.name.clone(), source);
276 Ok(())
277 }
278
279 pub async fn remove(&mut self, name: &str) -> Result<()> {
289 if !self.sources.contains_key(name) {
290 return Err(AgpmError::SourceNotFound {
291 name: name.to_string(),
292 }
293 .into());
294 }
295
296 self.sources.remove(name);
297
298 let source_cache = self.cache_dir.join("sources").join(name);
299 if source_cache.exists() {
300 tokio::fs::remove_dir_all(&source_cache)
301 .await
302 .context("Failed to remove source cache")?;
303 }
304
305 Ok(())
306 }
307
308 #[must_use]
314 pub fn get(&self, name: &str) -> Option<&Source> {
315 self.sources.get(name)
316 }
317
318 pub fn get_mut(&mut self, name: &str) -> Option<&mut Source> {
324 self.sources.get_mut(name)
325 }
326
327 #[must_use]
329 pub fn list(&self) -> Vec<&Source> {
330 self.sources.values().collect()
331 }
332
333 #[must_use]
335 pub fn list_enabled(&self) -> Vec<&Source> {
336 self.sources.values().filter(|s| s.enabled).collect()
337 }
338
339 #[must_use]
345 pub fn get_source_url(&self, name: &str) -> Option<String> {
346 self.sources.get(name).map(|s| s.url.clone())
347 }
348
349 pub async fn sync(&mut self, name: &str) -> Result<GitRepo> {
362 let source = self.sources.get(name).ok_or_else(|| AgpmError::SourceNotFound {
363 name: name.to_string(),
364 })?;
365
366 if !source.enabled {
367 return Err(AgpmError::ConfigError {
368 message: format!("Source '{name}' is disabled"),
369 }
370 .into());
371 }
372
373 let cache_path = source.cache_dir(&self.cache_dir);
374 ensure_dir(cache_path.parent().unwrap())?;
375
376 let url = source.url.clone();
378
379 let is_local_path = is_local_filesystem_path(&url);
381 let is_file_url = url.starts_with("file://");
382
383 let _lock = CacheLock::acquire(&self.cache_dir, name).await?;
386
387 let repo = if is_local_path {
388 let resolved_path = crate::utils::platform::resolve_path(&url)?;
391
392 validate_path_security(&resolved_path, true)?;
394
395 let canonical_path = crate::utils::safe_canonicalize(&resolved_path)
396 .map_err(|_| anyhow::anyhow!("Local path is not accessible or does not exist"))?;
397
398 GitRepo::new(canonical_path)
401 } else if is_file_url {
402 let path_str = url.strip_prefix("file://").unwrap();
404
405 #[cfg(windows)]
407 let path_str = path_str.replace('/', "\\");
408 #[cfg(not(windows))]
409 let path_str = path_str.to_string();
410
411 let abs_path = PathBuf::from(path_str);
412
413 if !abs_path.exists() {
415 return Err(anyhow::anyhow!(
416 "Local repository path does not exist or is not accessible: {}",
417 abs_path.display()
418 ));
419 }
420
421 if !crate::git::is_git_repository(&abs_path) {
423 return Err(anyhow::anyhow!(
424 "Specified path is not a git repository. file:// URLs must point to valid git repositories."
425 ));
426 }
427
428 if cache_path.exists() {
429 let repo = GitRepo::new(&cache_path);
430 if repo.is_git_repo() {
431 repo.fetch(Some(&url)).await?;
433 repo
434 } else {
435 tokio::fs::remove_dir_all(&cache_path)
436 .await
437 .context("Failed to remove invalid cache directory")?;
438 GitRepo::clone(&url, &cache_path).await?
439 }
440 } else {
441 GitRepo::clone(&url, &cache_path).await?
442 }
443 } else if cache_path.exists() {
444 let repo = GitRepo::new(&cache_path);
445 if repo.is_git_repo() {
446 repo.fetch(Some(&url)).await?;
448 repo
449 } else {
450 tokio::fs::remove_dir_all(&cache_path)
451 .await
452 .context("Failed to remove invalid cache directory")?;
453 GitRepo::clone(&url, &cache_path).await?
454 }
455 } else {
456 GitRepo::clone(&url, &cache_path).await?
457 };
458
459 if let Some(source) = self.sources.get_mut(name) {
460 source.local_path = Some(cache_path);
461 }
462
463 Ok(repo)
464 }
465
466 pub async fn sync_by_url(&self, url: &str) -> Result<GitRepo> {
478 let (owner, repo_name) =
480 parse_git_url(url).unwrap_or(("direct".to_string(), "repo".to_string()));
481 let cache_path = self.cache_dir.join("sources").join(format!("{owner}_{repo_name}"));
482 ensure_dir(cache_path.parent().unwrap())?;
483
484 let is_local_path = is_local_filesystem_path(url);
486 let is_file_url = url.starts_with("file://");
487
488 if is_local_path {
490 let resolved_path = crate::utils::platform::resolve_path(url)?;
492
493 validate_path_security(&resolved_path, true)?;
495
496 let canonical_path = crate::utils::safe_canonicalize(&resolved_path)
497 .map_err(|_| anyhow::anyhow!("Local path is not accessible or does not exist"))?;
498
499 return Ok(GitRepo::new(canonical_path));
502 }
503
504 if is_file_url {
506 let path_str = url.strip_prefix("file://").unwrap();
507
508 #[cfg(windows)]
510 let path_str = path_str.replace('/', "\\");
511 #[cfg(not(windows))]
512 let path_str = path_str.to_string();
513
514 let abs_path = PathBuf::from(path_str);
515
516 if !abs_path.exists() {
517 return Err(anyhow::anyhow!(
518 "Local repository path does not exist or is not accessible: {}",
519 abs_path.display()
520 ));
521 }
522
523 if !crate::git::is_git_repository(&abs_path) {
525 return Err(anyhow::anyhow!(
526 "Specified path is not a git repository. file:// URLs must point to valid git repositories."
527 ));
528 }
529 }
530
531 let lock_name = format!("{owner}_{repo_name}");
534 let _lock = CacheLock::acquire(&self.cache_dir, &lock_name).await?;
535
536 let authenticated_url = url.to_string();
538
539 let repo = if cache_path.exists() {
540 let repo = GitRepo::new(&cache_path);
541 if repo.is_git_repo() {
542 repo.fetch(Some(&authenticated_url)).await?;
545 repo
546 } else {
547 tokio::fs::remove_dir_all(&cache_path)
548 .await
549 .context("Failed to remove invalid cache directory")?;
550 GitRepo::clone(&authenticated_url, &cache_path).await?
551 }
552 } else {
553 GitRepo::clone(&authenticated_url, &cache_path).await?
554 };
555
556 Ok(repo)
557 }
558
559 pub async fn sync_all(&mut self) -> Result<()> {
565 let enabled_sources: Vec<String> =
566 self.list_enabled().iter().map(|s| s.name.clone()).collect();
567
568 for name in enabled_sources {
569 self.sync(&name).await?;
570 }
571
572 Ok(())
573 }
574
575 pub async fn sync_multiple_by_url(&self, urls: &[String]) -> Result<Vec<GitRepo>> {
579 if urls.is_empty() {
580 return Ok(Vec::new());
581 }
582
583 let futures: Vec<_> =
585 urls.iter().map(|url| async move { self.sync_by_url(url).await }).collect();
586
587 let results = join_all(futures).await;
589
590 results.into_iter().collect()
592 }
593
594 pub fn enable(&mut self, name: &str) -> Result<()> {
604 let source = self.sources.get_mut(name).ok_or_else(|| AgpmError::SourceNotFound {
605 name: name.to_string(),
606 })?;
607
608 source.enabled = true;
609 Ok(())
610 }
611
612 pub fn disable(&mut self, name: &str) -> Result<()> {
622 let source = self.sources.get_mut(name).ok_or_else(|| AgpmError::SourceNotFound {
623 name: name.to_string(),
624 })?;
625
626 source.enabled = false;
627 Ok(())
628 }
629
630 pub fn get_cached_path(&self, url: &str) -> Result<PathBuf> {
640 let source = self.sources.values().find(|s| s.url == url).ok_or_else(|| {
642 AgpmError::SourceNotFound {
643 name: url.to_string(),
644 }
645 })?;
646
647 Ok(source.cache_dir(&self.cache_dir))
648 }
649
650 pub fn get_cached_path_by_name(&self, name: &str) -> Result<PathBuf> {
660 let source = self.sources.get(name).ok_or_else(|| AgpmError::SourceNotFound {
661 name: name.to_string(),
662 })?;
663
664 Ok(source.cache_dir(&self.cache_dir))
665 }
666
667 pub async fn verify_all(&self) -> Result<()> {
675 let enabled_sources: Vec<&Source> = self.list_enabled();
676
677 if enabled_sources.is_empty() {
678 return Ok(());
679 }
680
681 for source in enabled_sources {
682 self.verify_source(&source.url).await?;
684 }
685
686 Ok(())
687 }
688
689 async fn verify_source(&self, url: &str) -> Result<()> {
699 if url.starts_with("file://") {
701 let path = url.strip_prefix("file://").unwrap();
702 if std::path::Path::new(path).exists() {
703 return Ok(());
704 }
705 return Err(anyhow::anyhow!("Local path does not exist: {path}"));
706 }
707
708 match crate::git::GitRepo::verify_url(url).await {
711 Ok(()) => Ok(()),
712 Err(e) => Err(anyhow::anyhow!("Source not accessible: {e}")),
713 }
714 }
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720 use crate::test_utils::TestGit;
721 use tempfile::TempDir;
722
723 #[test]
724 fn test_source_creation() {
725 let source =
726 Source::new("test".to_string(), "https://github.com/user/repo.git".to_string())
727 .with_description("Test source".to_string());
728
729 assert_eq!(source.name, "test");
730 assert_eq!(source.url, "https://github.com/user/repo.git");
731 assert_eq!(source.description, Some("Test source".to_string()));
732 assert!(source.enabled);
733 }
734
735 #[tokio::test]
736 async fn test_source_manager_add_remove() {
737 let temp_dir = TempDir::new().unwrap();
738 let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
739
740 let source =
741 Source::new("test".to_string(), "https://github.com/user/repo.git".to_string());
742
743 manager.add(source.clone()).unwrap();
744 assert!(manager.get("test").is_some());
745
746 let result = manager.add(source);
747 assert!(result.is_err());
748
749 manager.remove("test").await.unwrap();
750 assert!(manager.get("test").is_none());
751
752 let result = manager.remove("test").await;
753 assert!(result.is_err());
754 }
755
756 #[test]
757 fn test_source_enable_disable() {
758 let temp_dir = TempDir::new().unwrap();
759 let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
760
761 let source =
762 Source::new("test".to_string(), "https://github.com/user/repo.git".to_string());
763
764 manager.add(source).unwrap();
765 assert!(manager.get("test").unwrap().enabled);
766
767 manager.disable("test").unwrap();
768 assert!(!manager.get("test").unwrap().enabled);
769
770 manager.enable("test").unwrap();
771 assert!(manager.get("test").unwrap().enabled);
772 }
773
774 #[test]
775 fn test_list_enabled() {
776 let temp_dir = TempDir::new().unwrap();
777 let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
778
779 manager.add(Source::new("source1".to_string(), "url1".to_string())).unwrap();
780 manager.add(Source::new("source2".to_string(), "url2".to_string())).unwrap();
781 manager.add(Source::new("source3".to_string(), "url3".to_string())).unwrap();
782
783 assert_eq!(manager.list_enabled().len(), 3);
784
785 manager.disable("source2").unwrap();
786 assert_eq!(manager.list_enabled().len(), 2);
787 }
788
789 #[test]
790 fn test_source_cache_dir() {
791 let temp_dir = TempDir::new().unwrap();
792 let base_dir = temp_dir.path();
793
794 let source =
795 Source::new("test".to_string(), "https://github.com/user/repo.git".to_string());
796
797 let cache_dir = source.cache_dir(base_dir);
798 assert!(cache_dir.to_string_lossy().contains("sources"));
799 assert!(cache_dir.to_string_lossy().contains("user_repo"));
800 }
801
802 #[test]
803 fn test_source_cache_dir_invalid_url() {
804 let temp_dir = TempDir::new().unwrap();
805 let base_dir = temp_dir.path();
806
807 let source = Source::new("test".to_string(), "not-a-valid-url".to_string());
808
809 let cache_dir = source.cache_dir(base_dir);
810 assert!(cache_dir.to_string_lossy().contains("sources"));
811 assert!(cache_dir.to_string_lossy().contains("unknown_test"));
812 }
813
814 #[test]
815 fn test_from_manifest() {
816 let mut manifest = Manifest::new();
817 manifest.add_source(
818 "official".to_string(),
819 "https://github.com/example-org/agpm-official.git".to_string(),
820 );
821 manifest.add_source(
822 "community".to_string(),
823 "https://github.com/example-org/agpm-community.git".to_string(),
824 );
825
826 let temp_dir = TempDir::new().unwrap();
827 let manager =
828 SourceManager::from_manifest_with_cache(&manifest, temp_dir.path().to_path_buf());
829
830 assert_eq!(manager.list().len(), 2);
831 assert!(manager.get("official").is_some());
832 assert!(manager.get("community").is_some());
833 }
834
835 #[test]
836 fn test_source_manager_list() {
837 let temp_dir = TempDir::new().unwrap();
838 let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
839
840 assert_eq!(manager.list().len(), 0);
841
842 manager.add(Source::new("source1".to_string(), "url1".to_string())).unwrap();
843 manager.add(Source::new("source2".to_string(), "url2".to_string())).unwrap();
844
845 assert_eq!(manager.list().len(), 2);
846 }
847
848 #[test]
849 fn test_source_manager_get_mut() {
850 let temp_dir = TempDir::new().unwrap();
851 let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
852
853 manager.add(Source::new("test".to_string(), "url".to_string())).unwrap();
854
855 if let Some(source) = manager.get_mut("test") {
856 source.description = Some("Updated description".to_string());
857 }
858
859 assert_eq!(
860 manager.get("test").unwrap().description,
861 Some("Updated description".to_string())
862 );
863 }
864
865 #[test]
866 fn test_source_manager_enable_disable_errors() {
867 let temp_dir = TempDir::new().unwrap();
868 let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
869
870 let result = manager.enable("nonexistent");
871 assert!(result.is_err());
872
873 let result = manager.disable("nonexistent");
874 assert!(result.is_err());
875 }
876
877 #[tokio::test]
878 async fn test_source_manager_sync_disabled() {
879 let temp_dir = TempDir::new().unwrap();
880 let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
881
882 let source =
883 Source::new("test".to_string(), "https://github.com/user/repo.git".to_string());
884 manager.add(source).unwrap();
885 manager.disable("test").unwrap();
886
887 let result = manager.sync("test").await;
888 assert!(result.is_err());
889 }
890
891 #[tokio::test]
892 async fn test_source_manager_sync_nonexistent() {
893 let temp_dir = TempDir::new().unwrap();
894 let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
895
896 let result = manager.sync("nonexistent").await;
897 assert!(result.is_err());
898 }
899
900 #[tokio::test]
901 async fn test_source_manager_sync_local_repo() -> anyhow::Result<()> {
902 let temp_dir = TempDir::new().unwrap();
903 let cache_dir = temp_dir.path().join("cache");
904 let repo_dir = temp_dir.path().join("repo");
905
906 std::fs::create_dir(&repo_dir).unwrap();
908 let git = TestGit::new(&repo_dir);
909 git.init()?;
910 git.config_user()?;
911 std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
912 git.add_all()?;
913 git.commit("Initial commit")?;
914
915 let mut manager = SourceManager::new_with_cache(cache_dir.clone());
916 let source = Source::new("test".to_string(), format!("file://{}", repo_dir.display()));
917 manager.add(source).unwrap();
918
919 let repo = manager.sync("test").await?;
921 assert!(repo.is_git_repo());
922
923 manager.sync("test").await?;
925 Ok(())
926 }
927
928 #[tokio::test]
929 async fn test_source_manager_sync_all() -> anyhow::Result<()> {
930 let temp_dir = TempDir::new().unwrap();
931 let cache_dir = temp_dir.path().join("cache");
932
933 let repo1_dir = temp_dir.path().join("repo1");
935 let repo2_dir = temp_dir.path().join("repo2");
936
937 for repo_dir in &[&repo1_dir, &repo2_dir] {
938 std::fs::create_dir(repo_dir).unwrap();
939 let git = TestGit::new(repo_dir);
940 git.init()?;
941 git.config_user()?;
942 std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
943 git.add_all()?;
944 git.commit("Initial commit")?;
945 }
946
947 let mut manager = SourceManager::new_with_cache(cache_dir.clone());
948
949 manager
950 .add(Source::new("repo1".to_string(), format!("file://{}", repo1_dir.display())))
951 .unwrap();
952
953 manager
954 .add(Source::new("repo2".to_string(), format!("file://{}", repo2_dir.display())))
955 .unwrap();
956
957 manager.sync_all().await?;
959
960 let source1_cache = manager.get("repo1").unwrap().cache_dir(&cache_dir);
962 let source2_cache = manager.get("repo2").unwrap().cache_dir(&cache_dir);
963 assert!(source1_cache.exists());
964 assert!(source2_cache.exists());
965 Ok(())
966 }
967
968 #[tokio::test]
971 async fn test_sync_non_existent_local_path() {
972 let temp_dir = TempDir::new().unwrap();
973 let cache_dir = temp_dir.path().join("cache");
974 let mut manager = SourceManager::new_with_cache(cache_dir);
975
976 let source = Source::new("test".to_string(), "/non/existent/path".to_string());
977 manager.add(source).unwrap();
978
979 let result = manager.sync("test").await;
980 assert!(result.is_err());
981 assert!(result.unwrap_err().to_string().contains("does not exist"));
982 }
983
984 #[tokio::test]
985 async fn test_sync_non_git_directory() -> anyhow::Result<()> {
986 let temp_dir = TempDir::new().unwrap();
987 let cache_dir = temp_dir.path().join("cache");
988 let non_git_dir = temp_dir.path().join("not_git");
989 std::fs::create_dir(&non_git_dir).unwrap();
990
991 let mut manager = SourceManager::new_with_cache(cache_dir);
992 let source = Source::new("test".to_string(), non_git_dir.to_str().unwrap().to_string());
993 manager.add(source).unwrap();
994
995 let result = manager.sync("test").await;
997 if let Err(ref e) = result {
998 eprintln!("Test failed with error: {e}");
999 eprintln!("Path was: {non_git_dir:?}");
1000 }
1001 let repo = result.map_err(|e| anyhow::anyhow!("Failed to sync: {e:?}"))?;
1002 assert_eq!(repo.path(), crate::utils::safe_canonicalize(&non_git_dir).unwrap());
1004 Ok(())
1005 }
1006
1007 #[tokio::test]
1008 async fn test_sync_invalid_cache_directory() -> anyhow::Result<()> {
1009 let temp_dir = TempDir::new().unwrap();
1010 let cache_dir = temp_dir.path().join("cache");
1011 let repo_dir = temp_dir.path().join("repo");
1012
1013 std::fs::create_dir(&repo_dir).unwrap();
1015 let git = TestGit::new(&repo_dir);
1016 git.init()?;
1017 git.config_user()?;
1018 std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
1019 git.add_all()?;
1020 git.commit("Initial")?;
1021
1022 let mut manager = SourceManager::new_with_cache(cache_dir.clone());
1023 let source = Source::new("test".to_string(), format!("file://{}", repo_dir.display()));
1024 manager.add(source).unwrap();
1025
1026 let source_cache_dir = manager.get("test").unwrap().cache_dir(&cache_dir);
1028 std::fs::create_dir_all(&source_cache_dir).unwrap();
1029 std::fs::write(source_cache_dir.join("file.txt"), "not a git repo").unwrap();
1030
1031 let _repo = manager.sync("test").await?;
1033 assert!(crate::git::is_git_repository(&source_cache_dir));
1034 Ok(())
1035 }
1036
1037 #[tokio::test]
1038 async fn test_sync_by_url_invalid_url() {
1039 let temp_dir = TempDir::new().unwrap();
1040 let cache_dir = temp_dir.path().join("cache");
1041 let manager = SourceManager::new_with_cache(cache_dir);
1042
1043 let result = manager.sync_by_url("not-a-valid-url").await;
1044 assert!(result.is_err());
1045 }
1046
1047 #[tokio::test]
1048 async fn test_sync_multiple_by_url_empty() -> anyhow::Result<()> {
1049 let temp_dir = TempDir::new().unwrap();
1050 let cache_dir = temp_dir.path().join("cache");
1051 let manager = SourceManager::new_with_cache(cache_dir);
1052
1053 let result = manager.sync_multiple_by_url(&[]).await?;
1054 assert_eq!(result.len(), 0);
1055 Ok(())
1056 }
1057
1058 #[tokio::test]
1059 async fn test_sync_multiple_by_url_with_failures() {
1060 let temp_dir = TempDir::new().unwrap();
1061 let cache_dir = temp_dir.path().join("cache");
1062 let repo_dir = temp_dir.path().join("repo");
1063
1064 std::fs::create_dir(&repo_dir).unwrap();
1066 let git = TestGit::new(&repo_dir);
1067 git.init().unwrap();
1068 git.config_user().unwrap();
1069 std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
1070 git.add_all().unwrap();
1071 git.commit("Initial").unwrap();
1072
1073 let manager = SourceManager::new_with_cache(cache_dir);
1074
1075 let urls = vec![format!("file://{}", repo_dir.display()), "invalid-url".to_string()];
1076
1077 let result = manager.sync_multiple_by_url(&urls).await;
1079 assert!(result.is_err());
1080 }
1081
1082 #[tokio::test]
1083 async fn test_get_cached_path_not_found() {
1084 let temp_dir = TempDir::new().unwrap();
1085 let cache_dir = temp_dir.path().join("cache");
1086 let manager = SourceManager::new_with_cache(cache_dir);
1087
1088 let result = manager.get_cached_path("https://unknown/url.git");
1089 assert!(result.is_err());
1090 }
1092
1093 #[tokio::test]
1094 async fn test_get_cached_path_by_name_not_found() {
1095 let temp_dir = TempDir::new().unwrap();
1096 let cache_dir = temp_dir.path().join("cache");
1097 let manager = SourceManager::new_with_cache(cache_dir);
1098
1099 let result = manager.get_cached_path_by_name("nonexistent");
1100 assert!(result.is_err());
1101 }
1103
1104 #[tokio::test]
1105 async fn test_verify_all_no_sources() -> anyhow::Result<()> {
1106 let temp_dir = TempDir::new().unwrap();
1107 let cache_dir = temp_dir.path().join("cache");
1108 let manager = SourceManager::new_with_cache(cache_dir);
1109
1110 manager.verify_all().await?;
1111 Ok(())
1112 }
1113
1114 #[tokio::test]
1115 async fn test_verify_all_with_disabled_sources() -> anyhow::Result<()> {
1116 let temp_dir = TempDir::new().unwrap();
1117 let cache_dir = temp_dir.path().join("cache");
1118 let mut manager = SourceManager::new_with_cache(cache_dir);
1119
1120 let source =
1122 Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
1123 manager.add(source).unwrap();
1124 manager.disable("test").unwrap();
1125
1126 manager.verify_all().await?;
1128 Ok(())
1129 }
1130
1131 #[tokio::test]
1132 async fn test_verify_source_file_url_not_exist() {
1133 let temp_dir = TempDir::new().unwrap();
1134 let cache_dir = temp_dir.path().join("cache");
1135 let manager = SourceManager::new_with_cache(cache_dir);
1136
1137 let result = manager.verify_source("file:///non/existent/path").await;
1138 assert!(result.is_err());
1139 assert!(result.unwrap_err().to_string().contains("does not exist"));
1140 }
1141
1142 #[tokio::test]
1143 async fn test_verify_source_invalid_remote() {
1144 let temp_dir = TempDir::new().unwrap();
1145 let cache_dir = temp_dir.path().join("cache");
1146 let manager = SourceManager::new_with_cache(cache_dir);
1147
1148 let result = manager.verify_source("https://invalid-host-9999.test/repo.git").await;
1149 assert!(result.is_err());
1150 assert!(result.unwrap_err().to_string().contains("not accessible"));
1151 }
1152
1153 #[tokio::test]
1154 async fn test_remove_with_cache_cleanup() {
1155 let temp_dir = TempDir::new().unwrap();
1156 let cache_dir = temp_dir.path().join("cache");
1157 let mut manager = SourceManager::new_with_cache(cache_dir.clone());
1158
1159 let source =
1160 Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
1161 manager.add(source).unwrap();
1162
1163 let source_cache = cache_dir.join("sources").join("test");
1165 std::fs::create_dir_all(&source_cache).unwrap();
1166 std::fs::write(source_cache.join("file.txt"), "cached").unwrap();
1167 assert!(source_cache.exists());
1168
1169 manager.remove("test").await.unwrap();
1171 assert!(!source_cache.exists());
1172 }
1173
1174 #[tokio::test]
1175 async fn test_get_source_url() {
1176 let temp_dir = TempDir::new().unwrap();
1177 let cache_dir = temp_dir.path().join("cache");
1178 let mut manager = SourceManager::new_with_cache(cache_dir);
1179
1180 let source =
1181 Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
1182 manager.add(source).unwrap();
1183
1184 let url = manager.get_source_url("test");
1185 assert_eq!(url, Some("https://github.com/test/repo.git".to_string()));
1186
1187 let url = manager.get_source_url("nonexistent");
1188 assert_eq!(url, None);
1189 }
1190
1191 #[test]
1192 fn test_source_with_description() {
1193 let source =
1194 Source::new("test".to_string(), "https://github.com/test/repo.git".to_string())
1195 .with_description("Test description".to_string());
1196
1197 assert_eq!(source.description, Some("Test description".to_string()));
1198 }
1199
1200 #[tokio::test]
1201 async fn test_sync_with_progress() -> anyhow::Result<()> {
1202 let temp_dir = TempDir::new().unwrap();
1203 let cache_dir = temp_dir.path().join("cache");
1204 let repo_dir = temp_dir.path().join("repo");
1205
1206 std::fs::create_dir(&repo_dir).unwrap();
1208 let git = TestGit::new(&repo_dir);
1209 git.init()?;
1210 git.config_user()?;
1211 std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
1212 git.add_all()?;
1213 git.commit("Initial")?;
1214
1215 let mut manager = SourceManager::new_with_cache(cache_dir);
1216 let source = Source::new("test".to_string(), format!("file://{}", repo_dir.display()));
1217 manager.add(source).unwrap();
1218
1219 manager.sync("test").await?;
1220 Ok(())
1221 }
1222
1223 #[tokio::test]
1224 async fn test_from_manifest_with_global() -> anyhow::Result<()> {
1225 let manifest = Manifest::new();
1226 SourceManager::from_manifest_with_global(&manifest).await?;
1227 Ok(())
1228 }
1229
1230 #[test]
1231 fn test_new_source_manager() {
1232 let result = SourceManager::new();
1233 if let Ok(manager) = result {
1235 assert!(manager.sources.is_empty());
1236 }
1237 }
1238
1239 #[tokio::test]
1240 async fn test_sync_local_path_directory() -> anyhow::Result<()> {
1241 let temp_dir = TempDir::new().unwrap();
1243 let cache_dir = temp_dir.path().join("cache");
1244 let local_dir = temp_dir.path().join("local_deps");
1245
1246 std::fs::create_dir(&local_dir).unwrap();
1248 std::fs::write(local_dir.join("agent.md"), "# Test Agent").unwrap();
1249 std::fs::write(local_dir.join("snippet.md"), "# Test Snippet").unwrap();
1250
1251 let mut manager = SourceManager::new_with_cache(cache_dir.clone());
1252
1253 let source = Source::new("local".to_string(), local_dir.to_string_lossy().to_string());
1255 manager.add(source).unwrap();
1256
1257 let repo = manager.sync("local").await?;
1259 assert_eq!(repo.path(), crate::utils::safe_canonicalize(&local_dir).unwrap());
1262 Ok(())
1263 }
1264
1265 #[tokio::test]
1266 async fn test_sync_by_url_local_path() -> anyhow::Result<()> {
1267 let temp_dir = TempDir::new().unwrap();
1268 let cache_dir = temp_dir.path().join("cache");
1269 let local_dir = temp_dir.path().join("local_deps");
1270
1271 std::fs::create_dir(&local_dir).unwrap();
1273 std::fs::write(local_dir.join("test.md"), "# Test Resource").unwrap();
1274
1275 let manager = SourceManager::new_with_cache(cache_dir);
1276
1277 let repo = manager.sync_by_url(&local_dir.to_string_lossy()).await?;
1279 assert_eq!(repo.path(), crate::utils::safe_canonicalize(&local_dir).unwrap());
1280
1281 Ok(())
1284 }
1285
1286 #[tokio::test]
1287 async fn test_sync_local_path_not_exist() {
1288 let temp_dir = TempDir::new().unwrap();
1289 let cache_dir = temp_dir.path().join("cache");
1290 let manager = SourceManager::new_with_cache(cache_dir);
1291
1292 let result = manager.sync_by_url("/non/existent/path").await;
1294 assert!(result.is_err());
1295 assert!(result.unwrap_err().to_string().contains("does not exist"));
1296 }
1297
1298 #[tokio::test]
1299 async fn test_file_url_requires_git() {
1300 let temp_dir = TempDir::new().unwrap();
1302 let cache_dir = temp_dir.path().join("cache");
1303 let plain_dir = temp_dir.path().join("plain_dir");
1304
1305 std::fs::create_dir(&plain_dir).unwrap();
1307 std::fs::write(plain_dir.join("test.md"), "# Test").unwrap();
1308
1309 let manager = SourceManager::new_with_cache(cache_dir);
1310
1311 let file_url = format!("file://{}", plain_dir.display());
1313 let result = manager.sync_by_url(&file_url).await;
1314 assert!(result.is_err());
1315 assert!(result.unwrap_err().to_string().contains("not a git repository"));
1316 }
1317
1318 #[tokio::test]
1319 async fn test_path_traversal_attack_prevention() {
1320 let temp_dir = TempDir::new().unwrap();
1322 let cache_dir = temp_dir.path().join("cache");
1323
1324 let manager = SourceManager::new_with_cache(cache_dir.clone());
1325
1326 let blacklisted_paths = vec!["/etc/passwd", "/System/Library", "/private/etc/hosts"];
1328
1329 for malicious_path in blacklisted_paths {
1330 if !std::path::Path::new(malicious_path).exists() {
1332 continue;
1333 }
1334
1335 let result = manager.sync_by_url(malicious_path).await;
1336 assert!(result.is_err(), "Blacklisted path not detected for: {malicious_path}");
1337 let err_msg = result.unwrap_err().to_string();
1338 assert!(
1339 err_msg.contains("Security error") || err_msg.contains("not allowed"),
1340 "Expected security error for blacklisted path: {malicious_path}, got: {err_msg}"
1341 );
1342 }
1343
1344 let safe_dir = temp_dir.path().join("safe_dir");
1346 std::fs::create_dir(&safe_dir).unwrap();
1347
1348 let result = manager.sync_by_url(&safe_dir.to_string_lossy()).await;
1349 assert!(result.is_ok(), "Safe path was incorrectly blocked: {result:?}");
1350 }
1351
1352 #[cfg(unix)]
1353 #[tokio::test]
1354 async fn test_symlink_attack_prevention() {
1355 let temp_dir = TempDir::new().unwrap();
1357 let cache_dir = temp_dir.path().join("cache");
1358 let project_dir = temp_dir.path().join("project");
1359 let deps_dir = project_dir.join("deps");
1360 let sensitive_dir = temp_dir.path().join("sensitive");
1361
1362 std::fs::create_dir(&project_dir).unwrap();
1364 std::fs::create_dir(&deps_dir).unwrap();
1365 std::fs::create_dir(&sensitive_dir).unwrap();
1366 std::fs::write(sensitive_dir.join("secret.txt"), "secret data").unwrap();
1367
1368 use std::os::unix::fs::symlink;
1370 let symlink_path = deps_dir.join("malicious_link");
1371 symlink(&sensitive_dir, &symlink_path).unwrap();
1372
1373 let manager = SourceManager::new_with_cache(cache_dir);
1374
1375 let result = manager.sync_by_url(symlink_path.to_str().unwrap()).await;
1377 assert!(result.is_err());
1378 let err_msg = result.unwrap_err().to_string();
1379 assert!(
1380 err_msg.contains("Symlinks are not allowed") || err_msg.contains("Security error"),
1381 "Expected symlink error, got: {err_msg}"
1382 );
1383 }
1384
1385 #[tokio::test]
1386 async fn test_absolute_path_restriction() {
1387 let temp_dir = TempDir::new().unwrap();
1389 let cache_dir = temp_dir.path().join("cache");
1390
1391 let manager = SourceManager::new_with_cache(cache_dir);
1392
1393 let safe_dir = temp_dir.path().join("project");
1396 std::fs::create_dir(&safe_dir).unwrap();
1397 std::fs::write(safe_dir.join("file.txt"), "content").unwrap();
1398
1399 let result = manager.sync_by_url(&safe_dir.to_string_lossy()).await;
1400
1401 assert!(result.is_ok(), "Safe temp path was incorrectly blocked: {result:?}");
1403 }
1404
1405 #[test]
1406 fn test_error_message_sanitization() {
1407 let error_msg = "Local path is not accessible or does not exist";
1412 assert!(!error_msg.contains("/home"));
1413 assert!(!error_msg.contains("/Users"));
1414 assert!(!error_msg.contains("C:\\"));
1415
1416 let security_msg =
1417 "Security error: Local path must be within the project directory or AGPM cache";
1418 assert!(!security_msg.contains("{:?}"));
1419 assert!(!security_msg.contains("{}"));
1420 }
1421}