agpm_cli/source/
mod.rs

1//! Source repository management for AGPM resources.
2//!
3//! Manages Git repositories containing Claude Code resources (agents, snippets, etc.) with local
4//! caching, authentication, and cross-platform support.
5//!
6//! # Components
7//!
8//! - [`Source`] - Individual repository with metadata
9//! - [`SourceManager`] - Manages multiple sources with sync/verify operations
10//!
11//! # Configuration
12//!
13//! Sources defined in `agpm.toml` (shared) or `~/.agpm/config.toml` (user-specific with tokens).
14//! Global sources loaded first, local overrides for customization.
15//!
16//! # Features
17//!
18//! - Remote (HTTPS/SSH) and local repositories support
19//! - Efficient caching in `~/.agpm/cache/sources/{owner}_{repo}`
20//! - Transparent authentication via embedded tokens in URLs
21//! - Parallel sync operations with file-based locking
22//! - Automatic cleanup and validation of invalid caches
23
24use 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/// Git repository source containing Claude Code resources.
38///
39/// Defines repository location and metadata. Supports remote (HTTPS/SSH) and local repositories.
40///
41/// # Fields
42///
43/// - `name`: Unique identifier
44/// - `url`: Repository location (HTTPS, SSH, file://, or local path)
45/// - `description`: Optional description
46/// - `enabled`: Whether source is active
47/// - `local_path`: Runtime cache location (not serialized)
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Source {
50    /// Unique identifier for this source
51    pub name: String,
52    /// Repository URL or local path
53    pub url: String,
54    /// Optional human-readable description
55    pub description: Option<String>,
56    /// Whether this source is enabled for operations
57    pub enabled: bool,
58    /// Runtime path to cached repository (not serialized)
59    #[serde(skip)]
60    pub local_path: Option<PathBuf>,
61}
62
63impl Source {
64    /// Creates a new source with the given name and URL.
65    ///
66    /// # Arguments
67    ///
68    /// * `name` - Unique identifier for this source
69    /// * `url` - Repository URL or local path
70    #[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    /// Adds a human-readable description to this source.
82    ///
83    /// # Arguments
84    ///
85    /// * `desc` - Human-readable description of the source
86    #[must_use]
87    pub fn with_description(mut self, desc: String) -> Self {
88        self.description = Some(desc);
89        self
90    }
91
92    /// Generates the cache directory path for this source.
93    ///
94    /// Creates unique directory name as `{base_dir}/sources/{owner}_{repo}`.
95    /// Falls back to `unknown_{source_name}` for invalid URLs.
96    ///
97    /// # Arguments
98    ///
99    /// * `base_dir` - Base cache directory (typically `~/.agpm/cache`)
100    #[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/// Manages multiple source repositories with caching, synchronization, and verification.
109///
110/// Central component for handling source repositories. Provides operations for adding, removing,
111/// syncing, and verifying sources with local caching. Handles both remote repositories and local
112/// paths with authentication support via global configuration.
113///
114/// # Cache Management
115///
116/// Maintains cache in `~/.agpm/cache/sources/` with persistence between operations, offline
117/// access, and automatic validation/repair of invalid caches.
118#[derive(Debug, Clone)]
119pub struct SourceManager {
120    /// Collection of managed sources, indexed by name
121    sources: HashMap<String, Source>,
122    /// Base directory for caching repositories
123    cache_dir: PathBuf,
124}
125
126/// Helper function to detect if a URL represents a local filesystem path
127fn is_local_filesystem_path(url: &str) -> bool {
128    // Unix-style relative paths
129    if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
130        return true;
131    }
132
133    // Windows absolute paths (e.g., C:\path or C:/path)
134    #[cfg(windows)]
135    {
136        // Check for drive letter pattern: X:\ or X:/
137        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        // UNC paths (\\server\share)
148        if url.starts_with("\\\\") {
149            return true;
150        }
151    }
152
153    false
154}
155
156impl SourceManager {
157    /// Creates a new source manager with the default cache directory.
158    ///
159    /// # Errors
160    ///
161    /// Returns an error if the cache directory cannot be determined or created.
162    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    /// Creates a new source manager with a custom cache directory.
171    ///
172    /// # Arguments
173    ///
174    /// * `cache_dir` - Custom directory for caching repositories
175    #[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    /// Creates a source manager from a manifest file (without global config integration).
184    ///
185    /// Loads only sources from project manifest. Use [`from_manifest_with_global()`] for
186    /// authentication tokens and private repositories.
187    ///
188    /// # Arguments
189    ///
190    /// * `manifest` - Project manifest containing source definitions
191    ///
192    /// # Errors
193    ///
194    /// Returns an error if the cache directory cannot be determined.
195    ///
196    /// [`from_manifest_with_global()`]: SourceManager::from_manifest_with_global
197    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        // Load all sources from the manifest
202        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    /// Creates a source manager from manifest with global configuration integration.
211    ///
212    /// Recommended for production use. Merges sources from project manifest and global config
213    /// to enable authentication for private repositories.
214    ///
215    /// # Arguments
216    ///
217    /// * `manifest` - Project manifest containing source definitions
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if cache directory cannot be determined.
222    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        // Load global config and merge sources
227        let global_config = GlobalConfig::load().await.unwrap_or_default();
228        let merged_sources = global_config.merge_sources(&manifest.sources);
229
230        // Load all merged sources
231        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    /// Creates a source manager from manifest with a custom cache directory.
240    ///
241    /// # Arguments
242    ///
243    /// * `manifest` - Project manifest containing source definitions
244    /// * `cache_dir` - Custom directory for caching repositories
245    #[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        // Load all sources from the manifest
250        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    /// Adds a new source to the manager.
259    ///
260    /// # Arguments
261    ///
262    /// * `source` - The source to add to the manager
263    ///
264    /// # Errors
265    ///
266    /// Returns [`AgpmError::ConfigError`] if a source with the same name already exists.
267    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    /// Removes a source from the manager and cleans up its cache.
280    ///
281    /// # Arguments
282    ///
283    /// * `name` - Name of the source to remove
284    ///
285    /// # Errors
286    ///
287    /// Returns an error if the source does not exist or cache cannot be removed.
288    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    /// Gets a reference to a source by name.
309    ///
310    /// # Arguments
311    ///
312    /// * `name` - Name of the source to retrieve
313    #[must_use]
314    pub fn get(&self, name: &str) -> Option<&Source> {
315        self.sources.get(name)
316    }
317
318    /// Gets a mutable reference to a source by name.
319    ///
320    /// # Arguments
321    ///
322    /// * `name` - Name of the source to retrieve
323    pub fn get_mut(&mut self, name: &str) -> Option<&mut Source> {
324        self.sources.get_mut(name)
325    }
326
327    /// Returns a list of all sources managed by this manager.
328    #[must_use]
329    pub fn list(&self) -> Vec<&Source> {
330        self.sources.values().collect()
331    }
332
333    /// Returns a list of enabled sources managed by this manager.
334    #[must_use]
335    pub fn list_enabled(&self) -> Vec<&Source> {
336        self.sources.values().filter(|s| s.enabled).collect()
337    }
338
339    /// Gets the URL of a source by name.
340    ///
341    /// # Arguments
342    ///
343    /// * `name` - Name of the source to get the URL for
344    #[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    /// Synchronizes a source repository to the local cache.
350    ///
351    /// Handles cloning (first time) or fetching (subsequent) with automatic cache validation
352    /// and cleanup. Supports remote (HTTPS/SSH) and local repositories.
353    ///
354    /// # Arguments
355    ///
356    /// * `name` - Name of the source to synchronize
357    ///
358    /// # Errors
359    ///
360    /// Returns an error if source doesn't exist, is disabled, or repository is not accessible.
361    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        // Use the URL directly (auth tokens are already embedded in URLs from global config)
377        let url = source.url.clone();
378
379        // Distinguish between plain directories and git repositories
380        let is_local_path = is_local_filesystem_path(&url);
381        let is_file_url = url.starts_with("file://");
382
383        // Acquire lock for this source to prevent concurrent git operations
384        // This prevents issues like concurrent "git remote set-url" commands
385        let _lock = CacheLock::acquire(&self.cache_dir, name).await?;
386
387        let repo = if is_local_path {
388            // Local paths are treated as plain directories (not git repositories)
389            // Apply security validation for local paths
390            let resolved_path = crate::utils::platform::resolve_path(&url)?;
391
392            // Security check: Validate path against blacklist and symlinks BEFORE canonicalization
393            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            // For local paths, we just return a GitRepo pointing to the local directory
399            // No cloning or fetching needed - these are treated as plain directories
400            GitRepo::new(canonical_path)
401        } else if is_file_url {
402            // file:// URLs must point to valid git repositories
403            let path_str = url.strip_prefix("file://").unwrap();
404
405            // On Windows, convert forward slashes back to backslashes
406            #[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            // Check if the local path exists and is a git repo
414            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            // Check if it's a git repository (either regular or bare)
422            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                    // For file:// repos, fetch to get latest changes
432                    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                // Always fetch for all URLs to get latest changes
447                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    /// Synchronizes a repository by URL without adding it as a named source.
467    ///
468    /// Used for direct Git dependencies. Cache directory derived from URL structure.
469    ///
470    /// # Arguments
471    ///
472    /// * `url` - Repository URL or local path to synchronize
473    ///
474    /// # Errors
475    ///
476    /// Returns an error if repository is invalid, inaccessible, or has permission issues.
477    pub async fn sync_by_url(&self, url: &str) -> Result<GitRepo> {
478        // Generate a cache directory based on the URL
479        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        // Check URL type
485        let is_local_path = is_local_filesystem_path(url);
486        let is_file_url = url.starts_with("file://");
487
488        // Handle local paths (not git repositories, just directories)
489        if is_local_path {
490            // Apply security validation for local paths
491            let resolved_path = crate::utils::platform::resolve_path(url)?;
492
493            // Security check: Validate path against blacklist and symlinks BEFORE canonicalization
494            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            // For local paths, we just return a GitRepo pointing to the local directory
500            // No cloning or fetching needed - these are treated as plain directories
501            return Ok(GitRepo::new(canonical_path));
502        }
503
504        // For file:// URLs, verify they're git repositories
505        if is_file_url {
506            let path_str = url.strip_prefix("file://").unwrap();
507
508            // On Windows, convert forward slashes back to backslashes
509            #[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            // Check if it's a git repository (either regular or bare)
524            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        // Acquire lock for this URL-based source to prevent concurrent git operations
532        // Use a deterministic lock name based on owner and repo
533        let lock_name = format!("{owner}_{repo_name}");
534        let _lock = CacheLock::acquire(&self.cache_dir, &lock_name).await?;
535
536        // Use the URL directly (auth tokens are already embedded in URLs from global config)
537        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                // For file:// URLs, always fetch to update refs
543                // For remote URLs, also fetch
544                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    /// Synchronizes all enabled sources by fetching latest changes.
560    ///
561    /// # Errors
562    ///
563    /// Returns an error if any source fails to sync.
564    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    /// Sync multiple sources by URL in parallel.
576    ///
577    /// Executes all sync operations concurrently with file-based locking for thread safety.
578    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        // Create async tasks for each URL
584        let futures: Vec<_> =
585            urls.iter().map(|url| async move { self.sync_by_url(url).await }).collect();
586
587        // Execute all syncs in parallel and collect results
588        let results = join_all(futures).await;
589
590        // Convert Vec<Result<GitRepo>> to Result<Vec<GitRepo>>
591        results.into_iter().collect()
592    }
593
594    /// Enables a source for use in operations.
595    ///
596    /// # Arguments
597    ///
598    /// * `name` - Name of the source to enable
599    ///
600    /// # Errors
601    ///
602    /// Returns [`AgpmError::SourceNotFound`] if no source with the given name exists.
603    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    /// Disables a source to exclude it from operations.
613    ///
614    /// # Arguments
615    ///
616    /// * `name` - Name of the source to disable
617    ///
618    /// # Errors
619    ///
620    /// Returns [`AgpmError::SourceNotFound`] if no source with the given name exists.
621    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    /// Gets the cache directory path for a source by URL.
631    ///
632    /// # Arguments
633    ///
634    /// * `url` - Repository URL to look up
635    ///
636    /// # Errors
637    ///
638    /// Returns [`AgpmError::SourceNotFound`] if no source with the given URL exists.
639    pub fn get_cached_path(&self, url: &str) -> Result<PathBuf> {
640        // Try to find the source by URL
641        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    /// Gets the cache directory path for a source by name.
651    ///
652    /// # Arguments
653    ///
654    /// * `name` - Name of the source to get the cache path for
655    ///
656    /// # Errors
657    ///
658    /// Returns [`AgpmError::SourceNotFound`] if no source with the given name exists.
659    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    /// Verifies that all enabled sources are accessible.
668    ///
669    /// Performs lightweight verification without full synchronization.
670    ///
671    /// # Errors
672    ///
673    /// Returns an error if any enabled source fails verification.
674    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            // Check if source URL is reachable by attempting a quick operation
683            self.verify_source(&source.url).await?;
684        }
685
686        Ok(())
687    }
688
689    /// Verifies that a single source URL is accessible.
690    ///
691    /// # Arguments
692    ///
693    /// * `url` - Repository URL or local path to verify
694    ///
695    /// # Errors
696    ///
697    /// Returns an error if the source is not accessible.
698    async fn verify_source(&self, url: &str) -> Result<()> {
699        // For file:// URLs (used in tests), just check if the path exists
700        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        // For other URLs, try to create a GitRepo object and verify it's accessible
709        // This is a lightweight check - we don't actually clone the repo
710        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        // Create a local git repo using TestGit helper
907        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        // First sync (clone)
920        let repo = manager.sync("test").await?;
921        assert!(repo.is_git_repo());
922
923        // Second sync (fetch + pull)
924        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        // Create two local git repos using TestGit helper
934        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        // Sync all
958        manager.sync_all().await?;
959
960        // Verify both repos were cloned
961        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    // Additional error path tests
969
970    #[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        // Local paths are now treated as plain directories, so sync should succeed
996        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        // Should point to the canonicalized local directory
1003        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        // Create a valid git repo using TestGit helper
1014        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        // Create an invalid cache directory (not a git repo)
1027        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        // Sync should detect invalid cache and re-clone
1032        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        // Create one valid repo using TestGit helper
1065        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        // Should fail on invalid URL
1078        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        // Just check that it returns an error - the message format may vary
1091    }
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        // Just check that it returns an error - the message format may vary
1102    }
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        // Add but disable a source
1121        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        // Verify should skip disabled sources
1127        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        // Create cache directory
1164        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        // Remove should clean up cache
1170        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        // Create a git repo using TestGit helper
1207        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        // May fail if cache dir can't be created, but should handle gracefully
1234        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        // Test that local paths (not file:// URLs) are treated as plain directories
1242        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        // Create a plain directory with some files (not a git repo)
1247        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        // Add source with local path
1254        let source = Source::new("local".to_string(), local_dir.to_string_lossy().to_string());
1255        manager.add(source).unwrap();
1256
1257        // Sync should work with plain directory (not require git)
1258        let repo = manager.sync("local").await?;
1259        // The returned GitRepo should point to the canonicalized local directory
1260        // On macOS, /var is a symlink to /private/var, so we need to compare canonical paths
1261        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        // Create a plain directory with files
1272        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        // Test absolute path
1278        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        // Note: Relative path test removed as it's not parallel-safe and unreliable
1282        // in different test environments (cargo, nextest, RustRover)
1283        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        // Try to sync non-existent local path
1293        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        // Test that file:// URLs require valid git repositories
1301        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        // Create a plain directory (not a git repo)
1306        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        // file:// URL should fail for non-git directory
1312        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        // Test that access to blacklisted system directories is prevented
1321        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        // Test that blacklisted system paths are blocked
1327        let blacklisted_paths = vec!["/etc/passwd", "/System/Library", "/private/etc/hosts"];
1328
1329        for malicious_path in blacklisted_paths {
1330            // Skip if path doesn't exist (e.g., /System on Linux)
1331            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        // Test that normal paths in temp directories work fine
1345        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        // Test that symlink attacks are prevented
1356        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        // Create directories
1363        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        // Create a symlink pointing to sensitive directory
1369        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        // Try to access the symlink directly as a local path
1376        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        // Test that blacklisted absolute paths are blocked
1388        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        // With blacklist approach, temp directories are allowed
1394        // So this test verifies that normal development paths work
1395        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        // Temp directories should work fine with blacklist approach
1402        assert!(result.is_ok(), "Safe temp path was incorrectly blocked: {result:?}");
1403    }
1404
1405    #[test]
1406    fn test_error_message_sanitization() {
1407        // Test that error messages don't leak sensitive path information
1408        // This is a compile-time test to ensure error messages are properly sanitized
1409
1410        // Check that we're not including full paths in error messages
1411        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}