carp_cli/commands/
pull.rs

1use crate::api::ApiClient;
2use crate::config::ConfigManager;
3use crate::utils::error::{CarpError, CarpResult};
4use colored::*;
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9/// Execute the pull command
10pub async fn execute(agent: String, output: Option<String>, force: bool, verbose: bool) -> CarpResult<()> {
11    let (name, version) = parse_agent_spec(&agent)?;
12    
13    if verbose {
14        println!("Pulling agent '{}'{}...", name, 
15                version.map(|v| format!(" version {}", v)).unwrap_or_default());
16    }
17    
18    let config = ConfigManager::load()?;
19    let client = ApiClient::new(&config)?;
20    
21    // Get download information
22    let download_info = client.get_agent_download(&name, version).await?;
23    
24    if verbose {
25        println!("Found {} v{} ({} bytes)", 
26                download_info.name, 
27                download_info.version,
28                download_info.size);
29    }
30    
31    // Determine output directory
32    let output_dir = determine_output_dir(&name, output, &config)?;
33    
34    // Check if directory exists and handle force flag
35    if output_dir.exists() && !force {
36        return Err(CarpError::FileSystem(format!(
37            "Directory '{}' already exists. Use --force to overwrite.",
38            output_dir.display()
39        )));
40    }
41    
42    if output_dir.exists() && force {
43        if verbose {
44            println!("Removing existing directory...");
45        }
46        fs::remove_dir_all(&output_dir)?;
47    }
48    
49    // Download the agent
50    println!("Downloading {}...", download_info.name.blue().bold());
51    let content = client.download_agent(&download_info.download_url).await?;
52    
53    // Verify checksum if available
54    if !download_info.checksum.is_empty() {
55        if verbose {
56            println!("Verifying checksum...");
57        }
58        verify_checksum(&content, &download_info.checksum)?;
59    }
60    
61    // Extract the agent
62    if verbose {
63        println!("Extracting to {}...", output_dir.display());
64    }
65    extract_agent(&content, &output_dir)?;
66    
67    println!("{} Successfully pulled {} v{} to {}", 
68            "✓".green().bold(),
69            download_info.name.blue().bold(), 
70            download_info.version,
71            output_dir.display().to_string().cyan());
72    
73    // Show usage instructions
74    println!("\nTo use this agent:");
75    println!("  cd {}", output_dir.display());
76    println!("  # Follow the README.md for specific usage instructions");
77    
78    Ok(())
79}
80
81/// Parse agent specification (name or name@version)
82fn parse_agent_spec(spec: &str) -> CarpResult<(String, Option<&str>)> {
83    if let Some(at_pos) = spec.find('@') {
84        let name = &spec[..at_pos];
85        let version = &spec[at_pos + 1..];
86        
87        if name.is_empty() || version.is_empty() {
88            return Err(CarpError::InvalidAgent(
89                "Invalid agent specification. Use 'name' or 'name@version'.".to_string()
90            ));
91        }
92        
93        Ok((name.to_string(), Some(version)))
94    } else {
95        Ok((spec.to_string(), None))
96    }
97}
98
99/// Determine the output directory for the agent
100fn determine_output_dir(name: &str, output: Option<String>, config: &crate::config::Config) -> CarpResult<PathBuf> {
101    if let Some(output_path) = output {
102        return Ok(PathBuf::from(output_path));
103    }
104    
105    if let Some(default_dir) = &config.default_output_dir {
106        return Ok(PathBuf::from(default_dir).join(name));
107    }
108    
109    // Default to current directory
110    Ok(PathBuf::from(name))
111}
112
113/// Verify the checksum of downloaded content
114fn verify_checksum(content: &[u8], expected: &str) -> CarpResult<()> {
115    use std::collections::hash_map::DefaultHasher;
116    use std::hash::{Hash, Hasher};
117    
118    let mut hasher = DefaultHasher::new();
119    content.hash(&mut hasher);
120    let computed = format!("{:x}", hasher.finish());
121    
122    if computed != expected {
123        return Err(CarpError::Network(
124            "Checksum verification failed. The downloaded file may be corrupted.".to_string()
125        ));
126    }
127    
128    Ok(())
129}
130
131/// Extract agent content to the specified directory
132fn extract_agent(content: &[u8], output_dir: &Path) -> CarpResult<()> {
133    use std::io::Cursor;
134    
135    // Create output directory
136    fs::create_dir_all(output_dir)?;
137    
138    // For now, assume the content is a ZIP file
139    let reader = Cursor::new(content);
140    let mut archive = zip::ZipArchive::new(reader)
141        .map_err(|e| CarpError::FileSystem(format!("Failed to read ZIP archive: {}", e)))?;
142    
143    for i in 0..archive.len() {
144        let mut file = archive.by_index(i)
145            .map_err(|e| CarpError::FileSystem(format!("Failed to read ZIP entry: {}", e)))?;
146        
147        // Security: Validate file path to prevent directory traversal attacks
148        let file_name = file.name();
149        if file_name.contains("..") || file_name.starts_with('/') || file_name.contains('\0') {
150            return Err(CarpError::FileSystem(format!(
151                "Unsafe file path in archive: {}", file_name
152            )));
153        }
154        
155        let file_path = output_dir.join(file_name);
156        
157        // Additional security: Ensure the resolved path is still within output_dir
158        let canonical_output = output_dir.canonicalize()?;
159        let canonical_file = file_path.canonicalize().unwrap_or_else(|_| {
160            file_path.parent().unwrap_or(output_dir).join(
161                file_path.file_name().unwrap_or_default()
162            )
163        });
164        
165        if !canonical_file.starts_with(&canonical_output) {
166            return Err(CarpError::FileSystem(format!(
167                "File path outside target directory: {}", file_name
168            )));
169        }
170        
171        if file.is_dir() {
172            fs::create_dir_all(&file_path)?;
173        } else {
174            if let Some(parent) = file_path.parent() {
175                fs::create_dir_all(parent)?;
176            }
177            
178            let mut output_file = fs::File::create(&file_path)?;
179            std::io::copy(&mut file, &mut output_file)?;
180            
181            // Set safe permissions on extracted files
182            #[cfg(unix)]
183            {
184                use std::os::unix::fs::PermissionsExt;
185                let mut perms = output_file.metadata()?.permissions();
186                perms.set_mode(0o644); // Owner read/write, group/other read
187                fs::set_permissions(&file_path, perms)?;
188            }
189        }
190    }
191    
192    Ok(())
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    
199    #[test]
200    fn test_parse_agent_spec() {
201        let (name, version) = parse_agent_spec("test-agent").unwrap();
202        assert_eq!(name, "test-agent");
203        assert!(version.is_none());
204        
205        let (name, version) = parse_agent_spec("test-agent@1.0.0").unwrap();
206        assert_eq!(name, "test-agent");
207        assert_eq!(version, Some("1.0.0"));
208        
209        assert!(parse_agent_spec("@1.0.0").is_err());
210        assert!(parse_agent_spec("test-agent@").is_err());
211    }
212}