use crate::cache::lock::CacheLock;
use crate::config::GlobalConfig;
use crate::core::AgpmError;
use crate::git::{GitRepo, parse_git_url};
use crate::manifest::Manifest;
use crate::utils::fs::ensure_dir;
use crate::utils::security::validate_path_security;
use anyhow::{Context, Result};
use futures::future::join_all;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Source {
pub name: String,
pub url: String,
pub description: Option<String>,
pub enabled: bool,
#[serde(skip)]
pub local_path: Option<PathBuf>,
}
impl Source {
#[must_use]
pub const fn new(name: String, url: String) -> Self {
Self {
name,
url,
description: None,
enabled: true,
local_path: None,
}
}
#[must_use]
pub fn with_description(mut self, desc: String) -> Self {
self.description = Some(desc);
self
}
#[must_use]
pub fn cache_dir(&self, base_dir: &Path) -> PathBuf {
let (owner, repo) =
parse_git_url(&self.url).unwrap_or(("unknown".to_string(), self.name.clone()));
base_dir.join("sources").join(format!("{owner}_{repo}"))
}
}
#[derive(Debug, Clone)]
pub struct SourceManager {
sources: HashMap<String, Source>,
cache_dir: PathBuf,
}
fn is_local_filesystem_path(url: &str) -> bool {
if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
return true;
}
#[cfg(windows)]
{
if url.len() >= 3 {
let chars: Vec<char> = url.chars().collect();
if chars.len() >= 3
&& chars[0].is_ascii_alphabetic()
&& chars[1] == ':'
&& (chars[2] == '\\' || chars[2] == '/')
{
return true;
}
}
if url.starts_with("\\\\") {
return true;
}
}
false
}
impl SourceManager {
pub fn new() -> Result<Self> {
let cache_dir = crate::config::get_cache_dir()?;
Ok(Self {
sources: HashMap::new(),
cache_dir,
})
}
#[must_use]
pub fn new_with_cache(cache_dir: PathBuf) -> Self {
Self {
sources: HashMap::new(),
cache_dir,
}
}
pub fn from_manifest(manifest: &Manifest) -> Result<Self> {
let cache_dir = crate::config::get_cache_dir()?;
let mut manager = Self::new_with_cache(cache_dir);
for (name, url) in &manifest.sources {
let source = Source::new(name.clone(), url.clone());
manager.sources.insert(name.clone(), source);
}
Ok(manager)
}
pub async fn from_manifest_with_global(manifest: &Manifest) -> Result<Self> {
let cache_dir = crate::config::get_cache_dir()?;
let mut manager = Self::new_with_cache(cache_dir);
let global_config = GlobalConfig::load().await.unwrap_or_default();
let merged_sources = global_config.merge_sources(&manifest.sources);
for (name, url) in &merged_sources {
let source = Source::new(name.clone(), url.clone());
manager.sources.insert(name.clone(), source);
}
Ok(manager)
}
#[must_use]
pub fn from_manifest_with_cache(manifest: &Manifest, cache_dir: PathBuf) -> Self {
let mut manager = Self::new_with_cache(cache_dir);
for (name, url) in &manifest.sources {
let source = Source::new(name.clone(), url.clone());
manager.sources.insert(name.clone(), source);
}
manager
}
pub fn add(&mut self, source: Source) -> Result<()> {
if self.sources.contains_key(&source.name) {
return Err(AgpmError::ConfigError {
message: format!("Source '{}' already exists", source.name),
}
.into());
}
self.sources.insert(source.name.clone(), source);
Ok(())
}
pub async fn remove(&mut self, name: &str) -> Result<()> {
if !self.sources.contains_key(name) {
return Err(AgpmError::SourceNotFound {
name: name.to_string(),
}
.into());
}
self.sources.remove(name);
let source_cache = self.cache_dir.join("sources").join(name);
if source_cache.exists() {
tokio::fs::remove_dir_all(&source_cache)
.await
.context("Failed to remove source cache")?;
}
Ok(())
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&Source> {
self.sources.get(name)
}
pub fn get_mut(&mut self, name: &str) -> Option<&mut Source> {
self.sources.get_mut(name)
}
#[must_use]
pub fn list(&self) -> Vec<&Source> {
self.sources.values().collect()
}
#[must_use]
pub fn list_enabled(&self) -> Vec<&Source> {
self.sources.values().filter(|s| s.enabled).collect()
}
#[must_use]
pub fn get_source_url(&self, name: &str) -> Option<String> {
self.sources.get(name).map(|s| s.url.clone())
}
pub async fn sync(&mut self, name: &str) -> Result<GitRepo> {
let source = self.sources.get(name).ok_or_else(|| AgpmError::SourceNotFound {
name: name.to_string(),
})?;
if !source.enabled {
return Err(AgpmError::ConfigError {
message: format!("Source '{name}' is disabled"),
}
.into());
}
let cache_path = source.cache_dir(&self.cache_dir);
ensure_dir(cache_path.parent().unwrap())?;
let url = source.url.clone();
let is_local_path = is_local_filesystem_path(&url);
let is_file_url = url.starts_with("file://");
let _lock = CacheLock::acquire(&self.cache_dir, name).await?;
let repo = if is_local_path {
let resolved_path = crate::utils::platform::resolve_path(&url)?;
validate_path_security(&resolved_path, true)?;
let canonical_path = crate::utils::safe_canonicalize(&resolved_path)
.map_err(|_| anyhow::anyhow!("Local path is not accessible or does not exist"))?;
GitRepo::new(canonical_path)
} else if is_file_url {
let path_str = url.strip_prefix("file://").unwrap();
#[cfg(windows)]
let path_str = path_str.replace('/', "\\");
#[cfg(not(windows))]
let path_str = path_str.to_string();
let abs_path = PathBuf::from(path_str);
if !abs_path.exists() {
return Err(anyhow::anyhow!(
"Local repository path does not exist or is not accessible: {}",
abs_path.display()
));
}
if !crate::git::is_git_repository(&abs_path) {
return Err(anyhow::anyhow!(
"Specified path is not a git repository. file:// URLs must point to valid git repositories."
));
}
if cache_path.exists() {
let repo = GitRepo::new(&cache_path);
if repo.is_git_repo() {
repo.fetch(Some(&url)).await?;
repo
} else {
tokio::fs::remove_dir_all(&cache_path)
.await
.context("Failed to remove invalid cache directory")?;
GitRepo::clone(&url, &cache_path).await?
}
} else {
GitRepo::clone(&url, &cache_path).await?
}
} else if cache_path.exists() {
let repo = GitRepo::new(&cache_path);
if repo.is_git_repo() {
repo.fetch(Some(&url)).await?;
repo
} else {
tokio::fs::remove_dir_all(&cache_path)
.await
.context("Failed to remove invalid cache directory")?;
GitRepo::clone(&url, &cache_path).await?
}
} else {
GitRepo::clone(&url, &cache_path).await?
};
if let Some(source) = self.sources.get_mut(name) {
source.local_path = Some(cache_path);
}
Ok(repo)
}
pub async fn sync_by_url(&self, url: &str) -> Result<GitRepo> {
let (owner, repo_name) =
parse_git_url(url).unwrap_or(("direct".to_string(), "repo".to_string()));
let cache_path = self.cache_dir.join("sources").join(format!("{owner}_{repo_name}"));
ensure_dir(cache_path.parent().unwrap())?;
let is_local_path = is_local_filesystem_path(url);
let is_file_url = url.starts_with("file://");
if is_local_path {
let resolved_path = crate::utils::platform::resolve_path(url)?;
validate_path_security(&resolved_path, true)?;
let canonical_path = crate::utils::safe_canonicalize(&resolved_path)
.map_err(|_| anyhow::anyhow!("Local path is not accessible or does not exist"))?;
return Ok(GitRepo::new(canonical_path));
}
if is_file_url {
let path_str = url.strip_prefix("file://").unwrap();
#[cfg(windows)]
let path_str = path_str.replace('/', "\\");
#[cfg(not(windows))]
let path_str = path_str.to_string();
let abs_path = PathBuf::from(path_str);
if !abs_path.exists() {
return Err(anyhow::anyhow!(
"Local repository path does not exist or is not accessible: {}",
abs_path.display()
));
}
if !crate::git::is_git_repository(&abs_path) {
return Err(anyhow::anyhow!(
"Specified path is not a git repository. file:// URLs must point to valid git repositories."
));
}
}
let lock_name = format!("{owner}_{repo_name}");
let _lock = CacheLock::acquire(&self.cache_dir, &lock_name).await?;
let authenticated_url = url.to_string();
let repo = if cache_path.exists() {
let repo = GitRepo::new(&cache_path);
if repo.is_git_repo() {
repo.fetch(Some(&authenticated_url)).await?;
repo
} else {
tokio::fs::remove_dir_all(&cache_path)
.await
.context("Failed to remove invalid cache directory")?;
GitRepo::clone(&authenticated_url, &cache_path).await?
}
} else {
GitRepo::clone(&authenticated_url, &cache_path).await?
};
Ok(repo)
}
pub async fn sync_all(&mut self) -> Result<()> {
let enabled_sources: Vec<String> =
self.list_enabled().iter().map(|s| s.name.clone()).collect();
for name in enabled_sources {
self.sync(&name).await?;
}
Ok(())
}
pub async fn sync_multiple_by_url(&self, urls: &[String]) -> Result<Vec<GitRepo>> {
if urls.is_empty() {
return Ok(Vec::new());
}
let futures: Vec<_> =
urls.iter().map(|url| async move { self.sync_by_url(url).await }).collect();
let results = join_all(futures).await;
results.into_iter().collect()
}
pub fn enable(&mut self, name: &str) -> Result<()> {
let source = self.sources.get_mut(name).ok_or_else(|| AgpmError::SourceNotFound {
name: name.to_string(),
})?;
source.enabled = true;
Ok(())
}
pub fn disable(&mut self, name: &str) -> Result<()> {
let source = self.sources.get_mut(name).ok_or_else(|| AgpmError::SourceNotFound {
name: name.to_string(),
})?;
source.enabled = false;
Ok(())
}
pub fn get_cached_path(&self, url: &str) -> Result<PathBuf> {
let source = self.sources.values().find(|s| s.url == url).ok_or_else(|| {
AgpmError::SourceNotFound {
name: url.to_string(),
}
})?;
Ok(source.cache_dir(&self.cache_dir))
}
pub fn get_cached_path_by_name(&self, name: &str) -> Result<PathBuf> {
let source = self.sources.get(name).ok_or_else(|| AgpmError::SourceNotFound {
name: name.to_string(),
})?;
Ok(source.cache_dir(&self.cache_dir))
}
pub async fn verify_all(&self) -> Result<()> {
let enabled_sources: Vec<&Source> = self.list_enabled();
if enabled_sources.is_empty() {
return Ok(());
}
for source in enabled_sources {
self.verify_source(&source.url).await?;
}
Ok(())
}
async fn verify_source(&self, url: &str) -> Result<()> {
if url.starts_with("file://") {
let path = url.strip_prefix("file://").unwrap();
if std::path::Path::new(path).exists() {
return Ok(());
}
return Err(anyhow::anyhow!("Local path does not exist: {path}"));
}
match crate::git::GitRepo::verify_url(url).await {
Ok(()) => Ok(()),
Err(e) => Err(anyhow::anyhow!("Source not accessible: {e}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_source_creation() {
let source =
Source::new("test".to_string(), "https://github.com/user/repo.git".to_string())
.with_description("Test source".to_string());
assert_eq!(source.name, "test");
assert_eq!(source.url, "https://github.com/user/repo.git");
assert_eq!(source.description, Some("Test source".to_string()));
assert!(source.enabled);
}
#[tokio::test]
async fn test_source_manager_add_remove() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
let source =
Source::new("test".to_string(), "https://github.com/user/repo.git".to_string());
manager.add(source.clone()).unwrap();
assert!(manager.get("test").is_some());
let result = manager.add(source);
assert!(result.is_err());
manager.remove("test").await.unwrap();
assert!(manager.get("test").is_none());
let result = manager.remove("test").await;
assert!(result.is_err());
}
#[test]
fn test_source_enable_disable() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
let source =
Source::new("test".to_string(), "https://github.com/user/repo.git".to_string());
manager.add(source).unwrap();
assert!(manager.get("test").unwrap().enabled);
manager.disable("test").unwrap();
assert!(!manager.get("test").unwrap().enabled);
manager.enable("test").unwrap();
assert!(manager.get("test").unwrap().enabled);
}
#[test]
fn test_list_enabled() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
manager.add(Source::new("source1".to_string(), "url1".to_string())).unwrap();
manager.add(Source::new("source2".to_string(), "url2".to_string())).unwrap();
manager.add(Source::new("source3".to_string(), "url3".to_string())).unwrap();
assert_eq!(manager.list_enabled().len(), 3);
manager.disable("source2").unwrap();
assert_eq!(manager.list_enabled().len(), 2);
}
#[test]
fn test_source_cache_dir() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let source =
Source::new("test".to_string(), "https://github.com/user/repo.git".to_string());
let cache_dir = source.cache_dir(base_dir);
assert!(cache_dir.to_string_lossy().contains("sources"));
assert!(cache_dir.to_string_lossy().contains("user_repo"));
}
#[test]
fn test_source_cache_dir_invalid_url() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let source = Source::new("test".to_string(), "not-a-valid-url".to_string());
let cache_dir = source.cache_dir(base_dir);
assert!(cache_dir.to_string_lossy().contains("sources"));
assert!(cache_dir.to_string_lossy().contains("unknown_test"));
}
#[test]
fn test_from_manifest() {
let mut manifest = Manifest::new();
manifest.add_source(
"official".to_string(),
"https://github.com/example-org/agpm-official.git".to_string(),
);
manifest.add_source(
"community".to_string(),
"https://github.com/example-org/agpm-community.git".to_string(),
);
let temp_dir = TempDir::new().unwrap();
let manager =
SourceManager::from_manifest_with_cache(&manifest, temp_dir.path().to_path_buf());
assert_eq!(manager.list().len(), 2);
assert!(manager.get("official").is_some());
assert!(manager.get("community").is_some());
}
#[test]
fn test_source_manager_list() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
assert_eq!(manager.list().len(), 0);
manager.add(Source::new("source1".to_string(), "url1".to_string())).unwrap();
manager.add(Source::new("source2".to_string(), "url2".to_string())).unwrap();
assert_eq!(manager.list().len(), 2);
}
#[test]
fn test_source_manager_get_mut() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
manager.add(Source::new("test".to_string(), "url".to_string())).unwrap();
if let Some(source) = manager.get_mut("test") {
source.description = Some("Updated description".to_string());
}
assert_eq!(
manager.get("test").unwrap().description,
Some("Updated description".to_string())
);
}
#[test]
fn test_source_manager_enable_disable_errors() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
let result = manager.enable("nonexistent");
assert!(result.is_err());
let result = manager.disable("nonexistent");
assert!(result.is_err());
}
#[tokio::test]
async fn test_source_manager_sync_disabled() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
let source =
Source::new("test".to_string(), "https://github.com/user/repo.git".to_string());
manager.add(source).unwrap();
manager.disable("test").unwrap();
let result = manager.sync("test").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_source_manager_sync_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
let result = manager.sync("nonexistent").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_source_manager_sync_local_repo() {
use std::process::Command;
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let repo_dir = temp_dir.path().join("repo");
std::fs::create_dir(&repo_dir).unwrap();
Command::new("git").args(["init"]).current_dir(&repo_dir).output().unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo_dir)
.output()
.unwrap();
std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
Command::new("git").args(["add", "."]).current_dir(&repo_dir).output().unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&repo_dir)
.output()
.unwrap();
let mut manager = SourceManager::new_with_cache(cache_dir.clone());
let source = Source::new("test".to_string(), format!("file://{}", repo_dir.display()));
manager.add(source).unwrap();
let result = manager.sync("test").await;
assert!(result.is_ok());
let repo = result.unwrap();
assert!(repo.is_git_repo());
let result = manager.sync("test").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_source_manager_sync_all() {
use std::process::Command;
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let repo1_dir = temp_dir.path().join("repo1");
let repo2_dir = temp_dir.path().join("repo2");
for repo_dir in &[&repo1_dir, &repo2_dir] {
std::fs::create_dir(repo_dir).unwrap();
Command::new("git").args(["init"]).current_dir(repo_dir).output().unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(repo_dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(repo_dir)
.output()
.unwrap();
std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
Command::new("git").args(["add", "."]).current_dir(repo_dir).output().unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(repo_dir)
.output()
.unwrap();
}
let mut manager = SourceManager::new_with_cache(cache_dir.clone());
manager
.add(Source::new("repo1".to_string(), format!("file://{}", repo1_dir.display())))
.unwrap();
manager
.add(Source::new("repo2".to_string(), format!("file://{}", repo2_dir.display())))
.unwrap();
let result = manager.sync_all().await;
assert!(result.is_ok());
let source1_cache = manager.get("repo1").unwrap().cache_dir(&cache_dir);
let source2_cache = manager.get("repo2").unwrap().cache_dir(&cache_dir);
assert!(source1_cache.exists());
assert!(source2_cache.exists());
}
#[tokio::test]
async fn test_sync_non_existent_local_path() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let mut manager = SourceManager::new_with_cache(cache_dir);
let source = Source::new("test".to_string(), "/non/existent/path".to_string());
manager.add(source).unwrap();
let result = manager.sync("test").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("does not exist"));
}
#[tokio::test]
async fn test_sync_non_git_directory() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let non_git_dir = temp_dir.path().join("not_git");
std::fs::create_dir(&non_git_dir).unwrap();
let mut manager = SourceManager::new_with_cache(cache_dir);
let source = Source::new("test".to_string(), non_git_dir.to_str().unwrap().to_string());
manager.add(source).unwrap();
let result = manager.sync("test").await;
if let Err(ref e) = result {
eprintln!("Test failed with error: {e}");
eprintln!("Path was: {non_git_dir:?}");
}
assert!(result.is_ok(), "Failed to sync: {result:?}");
let repo = result.unwrap();
assert_eq!(repo.path(), crate::utils::safe_canonicalize(&non_git_dir).unwrap());
}
#[tokio::test]
async fn test_sync_invalid_cache_directory() {
use std::process::Command;
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let repo_dir = temp_dir.path().join("repo");
std::fs::create_dir(&repo_dir).unwrap();
Command::new("git").args(["init"]).current_dir(&repo_dir).output().unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo_dir)
.output()
.unwrap();
std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
Command::new("git").args(["add", "."]).current_dir(&repo_dir).output().unwrap();
Command::new("git")
.args(["commit", "-m", "Initial"])
.current_dir(&repo_dir)
.output()
.unwrap();
let mut manager = SourceManager::new_with_cache(cache_dir.clone());
let source = Source::new("test".to_string(), format!("file://{}", repo_dir.display()));
manager.add(source).unwrap();
let source_cache_dir = manager.get("test").unwrap().cache_dir(&cache_dir);
std::fs::create_dir_all(&source_cache_dir).unwrap();
std::fs::write(source_cache_dir.join("file.txt"), "not a git repo").unwrap();
let result = manager.sync("test").await;
assert!(result.is_ok());
assert!(crate::git::is_git_repository(&source_cache_dir));
}
#[tokio::test]
async fn test_sync_by_url_invalid_url() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let manager = SourceManager::new_with_cache(cache_dir);
let result = manager.sync_by_url("not-a-valid-url").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_sync_multiple_by_url_empty() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let manager = SourceManager::new_with_cache(cache_dir);
let result = manager.sync_multiple_by_url(&[]).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 0);
}
#[tokio::test]
async fn test_sync_multiple_by_url_with_failures() {
use std::process::Command;
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let repo_dir = temp_dir.path().join("repo");
std::fs::create_dir(&repo_dir).unwrap();
Command::new("git").args(["init"]).current_dir(&repo_dir).output().unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo_dir)
.output()
.unwrap();
std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
Command::new("git").args(["add", "."]).current_dir(&repo_dir).output().unwrap();
Command::new("git")
.args(["commit", "-m", "Initial"])
.current_dir(&repo_dir)
.output()
.unwrap();
let manager = SourceManager::new_with_cache(cache_dir);
let urls = vec![format!("file://{}", repo_dir.display()), "invalid-url".to_string()];
let result = manager.sync_multiple_by_url(&urls).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_cached_path_not_found() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let manager = SourceManager::new_with_cache(cache_dir);
let result = manager.get_cached_path("https://unknown/url.git");
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_cached_path_by_name_not_found() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let manager = SourceManager::new_with_cache(cache_dir);
let result = manager.get_cached_path_by_name("nonexistent");
assert!(result.is_err());
}
#[tokio::test]
async fn test_verify_all_no_sources() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let manager = SourceManager::new_with_cache(cache_dir);
let result = manager.verify_all().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_verify_all_with_disabled_sources() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let mut manager = SourceManager::new_with_cache(cache_dir);
let source =
Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
manager.add(source).unwrap();
manager.disable("test").unwrap();
let result = manager.verify_all().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_verify_source_file_url_not_exist() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let manager = SourceManager::new_with_cache(cache_dir);
let result = manager.verify_source("file:///non/existent/path").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("does not exist"));
}
#[tokio::test]
async fn test_verify_source_invalid_remote() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let manager = SourceManager::new_with_cache(cache_dir);
let result = manager.verify_source("https://invalid-host-9999.test/repo.git").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not accessible"));
}
#[tokio::test]
async fn test_remove_with_cache_cleanup() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let mut manager = SourceManager::new_with_cache(cache_dir.clone());
let source =
Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
manager.add(source).unwrap();
let source_cache = cache_dir.join("sources").join("test");
std::fs::create_dir_all(&source_cache).unwrap();
std::fs::write(source_cache.join("file.txt"), "cached").unwrap();
assert!(source_cache.exists());
manager.remove("test").await.unwrap();
assert!(!source_cache.exists());
}
#[tokio::test]
async fn test_get_source_url() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let mut manager = SourceManager::new_with_cache(cache_dir);
let source =
Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
manager.add(source).unwrap();
let url = manager.get_source_url("test");
assert_eq!(url, Some("https://github.com/test/repo.git".to_string()));
let url = manager.get_source_url("nonexistent");
assert_eq!(url, None);
}
#[test]
fn test_source_with_description() {
let source =
Source::new("test".to_string(), "https://github.com/test/repo.git".to_string())
.with_description("Test description".to_string());
assert_eq!(source.description, Some("Test description".to_string()));
}
#[tokio::test]
async fn test_sync_with_progress() {
use std::process::Command;
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let repo_dir = temp_dir.path().join("repo");
std::fs::create_dir(&repo_dir).unwrap();
Command::new("git").args(["init"]).current_dir(&repo_dir).output().unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo_dir)
.output()
.unwrap();
std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
Command::new("git").args(["add", "."]).current_dir(&repo_dir).output().unwrap();
Command::new("git")
.args(["commit", "-m", "Initial"])
.current_dir(&repo_dir)
.output()
.unwrap();
let mut manager = SourceManager::new_with_cache(cache_dir);
let source = Source::new("test".to_string(), format!("file://{}", repo_dir.display()));
manager.add(source).unwrap();
let result = manager.sync("test").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_from_manifest_with_global() {
let manifest = Manifest::new();
let result = SourceManager::from_manifest_with_global(&manifest).await;
assert!(result.is_ok());
}
#[test]
fn test_new_source_manager() {
let result = SourceManager::new();
if let Ok(manager) = result {
assert!(manager.sources.is_empty());
}
}
#[tokio::test]
async fn test_sync_local_path_directory() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let local_dir = temp_dir.path().join("local_deps");
std::fs::create_dir(&local_dir).unwrap();
std::fs::write(local_dir.join("agent.md"), "# Test Agent").unwrap();
std::fs::write(local_dir.join("snippet.md"), "# Test Snippet").unwrap();
let mut manager = SourceManager::new_with_cache(cache_dir.clone());
let source = Source::new("local".to_string(), local_dir.to_string_lossy().to_string());
manager.add(source).unwrap();
let result = manager.sync("local").await;
assert!(result.is_ok());
let repo = result.unwrap();
assert_eq!(repo.path(), crate::utils::safe_canonicalize(&local_dir).unwrap());
}
#[tokio::test]
async fn test_sync_by_url_local_path() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let local_dir = temp_dir.path().join("local_deps");
std::fs::create_dir(&local_dir).unwrap();
std::fs::write(local_dir.join("test.md"), "# Test Resource").unwrap();
let manager = SourceManager::new_with_cache(cache_dir);
let result = manager.sync_by_url(&local_dir.to_string_lossy()).await;
assert!(result.is_ok());
let repo = result.unwrap();
assert_eq!(repo.path(), crate::utils::safe_canonicalize(&local_dir).unwrap());
{
let result = manager.sync_by_url("./local_deps").await;
assert!(result.is_ok());
}
}
#[tokio::test]
async fn test_sync_local_path_not_exist() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let manager = SourceManager::new_with_cache(cache_dir);
let result = manager.sync_by_url("/non/existent/path").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("does not exist"));
}
#[tokio::test]
async fn test_file_url_requires_git() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let plain_dir = temp_dir.path().join("plain_dir");
std::fs::create_dir(&plain_dir).unwrap();
std::fs::write(plain_dir.join("test.md"), "# Test").unwrap();
let manager = SourceManager::new_with_cache(cache_dir);
let file_url = format!("file://{}", plain_dir.display());
let result = manager.sync_by_url(&file_url).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not a git repository"));
}
#[tokio::test]
async fn test_path_traversal_attack_prevention() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let manager = SourceManager::new_with_cache(cache_dir.clone());
let blacklisted_paths = vec!["/etc/passwd", "/System/Library", "/private/etc/hosts"];
for malicious_path in blacklisted_paths {
if !std::path::Path::new(malicious_path).exists() {
continue;
}
let result = manager.sync_by_url(malicious_path).await;
assert!(result.is_err(), "Blacklisted path not detected for: {malicious_path}");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Security error") || err_msg.contains("not allowed"),
"Expected security error for blacklisted path: {malicious_path}, got: {err_msg}"
);
}
let safe_dir = temp_dir.path().join("safe_dir");
std::fs::create_dir(&safe_dir).unwrap();
let result = manager.sync_by_url(&safe_dir.to_string_lossy()).await;
assert!(result.is_ok(), "Safe path was incorrectly blocked: {result:?}");
}
#[cfg(unix)]
#[tokio::test]
async fn test_symlink_attack_prevention() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let project_dir = temp_dir.path().join("project");
let deps_dir = project_dir.join("deps");
let sensitive_dir = temp_dir.path().join("sensitive");
std::fs::create_dir(&project_dir).unwrap();
std::fs::create_dir(&deps_dir).unwrap();
std::fs::create_dir(&sensitive_dir).unwrap();
std::fs::write(sensitive_dir.join("secret.txt"), "secret data").unwrap();
use std::os::unix::fs::symlink;
let symlink_path = deps_dir.join("malicious_link");
symlink(&sensitive_dir, &symlink_path).unwrap();
let manager = SourceManager::new_with_cache(cache_dir);
let result = manager.sync_by_url(symlink_path.to_str().unwrap()).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Symlinks are not allowed") || err_msg.contains("Security error"),
"Expected symlink error, got: {err_msg}"
);
}
#[tokio::test]
async fn test_absolute_path_restriction() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache");
let manager = SourceManager::new_with_cache(cache_dir);
let safe_dir = temp_dir.path().join("project");
std::fs::create_dir(&safe_dir).unwrap();
std::fs::write(safe_dir.join("file.txt"), "content").unwrap();
let result = manager.sync_by_url(&safe_dir.to_string_lossy()).await;
assert!(result.is_ok(), "Safe temp path was incorrectly blocked: {result:?}");
}
#[test]
fn test_error_message_sanitization() {
let error_msg = "Local path is not accessible or does not exist";
assert!(!error_msg.contains("/home"));
assert!(!error_msg.contains("/Users"));
assert!(!error_msg.contains("C:\\"));
let security_msg =
"Security error: Local path must be within the project directory or AGPM cache";
assert!(!security_msg.contains("{:?}"));
assert!(!security_msg.contains("{}"));
}
}