agpm_cli/manifest/manifest_validation.rs
1//! Validation operations for manifest files.
2//!
3//! This module contains validation logic for ensuring manifests are:
4//! - Structurally correct
5//! - Logically consistent
6//! - Secure (no credential leakage, path traversal, etc.)
7//! - Cross-platform compatible
8
9use crate::manifest::{Manifest, PatchData, ToolsConfig, expand_url};
10use anyhow::Result;
11use std::collections::BTreeMap;
12
13impl Manifest {
14 /// Validate the manifest structure and enforce business rules.
15 ///
16 /// This method performs comprehensive validation of the manifest to ensure
17 /// logical consistency, security best practices, and correct dependency
18 /// relationships. It's automatically called during [`Self::load`] but can
19 /// also be used independently to validate programmatically constructed manifests.
20 ///
21 /// # Validation Rules
22 ///
23 /// ## Source Validation
24 /// - All source URLs must use supported protocols (HTTPS, SSH, git://, file://)
25 /// - No plain directory paths allowed as sources (must use file:// URLs)
26 /// - No authentication tokens embedded in URLs (security check)
27 /// - Environment variable expansion is validated for syntax
28 ///
29 /// ## Dependency Validation
30 /// - All dependency paths must be non-empty
31 /// - Remote dependencies must reference existing sources
32 /// - Remote dependencies must specify version constraints
33 /// - Local dependencies cannot have version constraints
34 /// - No version conflicts between dependencies with the same name within each resource type
35 ///
36 /// ## Path Validation
37 /// - Local dependency paths are checked for proper format
38 /// - Remote dependency paths are validated as repository-relative
39 /// - Path traversal attempts are detected and rejected
40 ///
41 /// # Error Types
42 ///
43 /// Returns specific error types for different validation failures:
44 /// - [`crate::core::AgpmError::SourceNotFound`]: Referenced source doesn't exist
45 /// - [`crate::core::AgpmError::ManifestValidationError`]: General validation failures
46 /// - Context errors for specific issues with actionable suggestions
47 ///
48 /// # Examples
49 ///
50 /// ```rust,no_run
51 /// use agpm_cli::manifest::{Manifest, ResourceDependency};
52 ///
53 /// let mut manifest = Manifest::new();
54 /// manifest.add_dependency(
55 /// "local".to_string(),
56 /// ResourceDependency::Simple("../local/helper.md".to_string()),
57 /// true
58 /// );
59 /// assert!(manifest.validate().is_ok());
60 /// ```
61 ///
62 /// # Security
63 ///
64 /// Enforces: no credential leakage in URLs, no path traversal, valid URL schemes.
65 pub fn validate(&self) -> Result<()> {
66 // Validate artifact type names
67 for artifact_type in self.get_tools_config().types.keys() {
68 if artifact_type.contains('/') || artifact_type.contains('\\') {
69 return Err(crate::core::AgpmError::ManifestValidationError {
70 reason: format!(
71 "Artifact type name '{artifact_type}' cannot contain path separators ('/' or '\\\\'). \n\
72 Artifact type names must be simple identifiers without special characters."
73 ),
74 }
75 .into());
76 }
77
78 // Also check for other potentially problematic characters
79 if artifact_type.contains("..") {
80 return Err(crate::core::AgpmError::ManifestValidationError {
81 reason: format!(
82 "Artifact type name '{artifact_type}' cannot contain '..' (path traversal). \n\
83 Artifact type names must be simple identifiers."
84 ),
85 }
86 .into());
87 }
88 }
89
90 // Check that all referenced sources exist and dependencies have required fields
91 for (name, dep) in self.all_dependencies() {
92 // Check for empty path
93 if dep.get_path().is_empty() {
94 return Err(crate::core::AgpmError::ManifestValidationError {
95 reason: format!("Missing required field 'path' for dependency '{name}'"),
96 }
97 .into());
98 }
99
100 // Validate pattern safety if it's a pattern dependency
101 if dep.is_pattern() {
102 crate::pattern::validate_pattern_safety(dep.get_path()).map_err(|e| {
103 crate::core::AgpmError::ManifestValidationError {
104 reason: format!("Invalid pattern in dependency '{name}': {e}"),
105 }
106 })?;
107 }
108
109 // Check for version when source is specified (non-local dependencies)
110 if let Some(source) = dep.get_source() {
111 if !self.sources.contains_key(source) {
112 return Err(crate::core::AgpmError::SourceNotFound {
113 name: source.to_string(),
114 }
115 .into());
116 }
117
118 // Check if the source URL is a local path
119 let source_url = self.sources.get(source).unwrap();
120 let _is_local_source = source_url.starts_with('/')
121 || source_url.starts_with("./")
122 || source_url.starts_with("../");
123
124 // Git dependencies can optionally have a version (defaults to 'main' if not specified)
125 // Local path sources don't need versions
126 // We no longer require versions for Git dependencies - they'll default to 'main'
127 } else {
128 // For local path dependencies (no source), version is not allowed
129 // Skip directory check for pattern dependencies
130 if !dep.is_pattern() {
131 let path = dep.get_path();
132 let is_plain_dir =
133 path.starts_with('/') || path.starts_with("./") || path.starts_with("../");
134
135 if is_plain_dir && dep.get_version().is_some() {
136 return Err(crate::core::AgpmError::ManifestValidationError {
137 reason: format!(
138 "Version specified for plain directory dependency '{name}' with path '{path}'. \n\
139 Plain directory dependencies do not support versions. \n\
140 Remove the 'version' field or use a git source instead."
141 ),
142 }
143 .into());
144 }
145 }
146 }
147 }
148
149 // Check for version conflicts within each resource type
150 // (same dependency name with different versions in the same section)
151 // Note: Same name in different sections (e.g., agents vs commands) is allowed
152 // because they install to different directories
153 for resource_type in crate::core::ResourceType::all() {
154 if let Some(deps) = self.get_dependencies(*resource_type) {
155 let mut seen_deps: std::collections::HashMap<String, String> =
156 std::collections::HashMap::new();
157 for (name, dep) in deps {
158 if let Some(version) = dep.get_version() {
159 if let Some(existing_version) = seen_deps.get(name) {
160 if existing_version != version {
161 return Err(crate::core::AgpmError::ManifestValidationError {
162 reason: format!(
163 "Version conflict for dependency '{name}' in [{}]: found versions '{existing_version}' and '{version}'",
164 resource_type.to_plural()
165 ),
166 }
167 .into());
168 }
169 } else {
170 seen_deps.insert(name.clone(), version.to_string());
171 }
172 }
173 }
174 }
175 }
176
177 // Validate URLs in sources
178 for (name, url) in &self.sources {
179 // Expand environment variables and home directory in URL
180 let expanded_url = expand_url(url)?;
181
182 if !expanded_url.starts_with("http://")
183 && !expanded_url.starts_with("https://")
184 && !expanded_url.starts_with("git@")
185 && !expanded_url.starts_with("file://")
186 // Plain directory paths not allowed as sources
187 && !expanded_url.starts_with('/')
188 && !expanded_url.starts_with("./")
189 && !expanded_url.starts_with("../")
190 {
191 return Err(crate::core::AgpmError::ManifestValidationError {
192 reason: format!("Source '{name}' has invalid URL: '{url}'. Must be HTTP(S), SSH (git@...), or file:// URL"),
193 }
194 .into());
195 }
196
197 // Check if plain directory path is used as a source
198 if expanded_url.starts_with('/')
199 || expanded_url.starts_with("./")
200 || expanded_url.starts_with("../")
201 {
202 return Err(crate::core::AgpmError::ManifestValidationError {
203 reason: format!(
204 "Plain directory path '{url}' cannot be used as source '{name}'. \n\
205 Sources must be git repositories. Use one of:\n\
206 - Remote URL: https://github.com/owner/repo.git\n\
207 - Local git repo: file:///absolute/path/to/repo\n\
208 - Or use direct path dependencies without a source"
209 ),
210 }
211 .into());
212 }
213 }
214
215 // Check for case-insensitive conflicts within each resource type
216 // This ensures manifests are portable across different filesystems
217 // Even though Linux supports case-sensitive files, we reject conflicts
218 // to ensure the manifest works on Windows and macOS too
219 // Note: Same name in different sections (e.g., agents vs commands) is allowed
220 // because they install to different directories
221 for resource_type in crate::core::ResourceType::all() {
222 if let Some(deps) = self.get_dependencies(*resource_type) {
223 let mut normalized_names: std::collections::HashSet<String> =
224 std::collections::HashSet::new();
225
226 for name in deps.keys() {
227 let normalized = name.to_lowercase();
228 if !normalized_names.insert(normalized.clone()) {
229 // Find the original conflicting name within this resource type
230 for other_name in deps.keys() {
231 if other_name != name && other_name.to_lowercase() == normalized {
232 return Err(crate::core::AgpmError::ManifestValidationError {
233 reason: format!(
234 "Case conflict in [{}]: '{name}' and '{other_name}' would map to the same file on case-insensitive filesystems. To ensure portability across platforms, resource names must be case-insensitively unique.",
235 resource_type.to_plural()
236 ),
237 }
238 .into());
239 }
240 }
241 }
242 }
243 }
244 }
245
246 // Validate artifact types and resource type support
247 for resource_type in crate::core::ResourceType::all() {
248 if let Some(deps) = self.get_dependencies(*resource_type) {
249 for (name, dep) in deps {
250 // Get tool from dependency (defaults based on resource type)
251 let tool_string = dep
252 .get_tool()
253 .map(|s| s.to_string())
254 .unwrap_or_else(|| self.get_default_tool(*resource_type));
255 let tool = tool_string.as_str();
256
257 // Check if tool is configured
258 if self.get_tool_config(tool).is_none() {
259 return Err(crate::core::AgpmError::ManifestValidationError {
260 reason: format!(
261 "Unknown tool '{tool}' for dependency '{name}'.\n\
262 Available types: {}\n\
263 Configure custom types in [tools] section or use a standard type.",
264 self.get_tools_config()
265 .types
266 .keys()
267 .map(|s| format!("'{s}'"))
268 .collect::<Vec<_>>()
269 .join(", ")
270 ),
271 }
272 .into());
273 }
274
275 // Check if resource type is supported by this tool
276 if !self.is_resource_supported(tool, *resource_type) {
277 let artifact_config = self.get_tool_config(tool).unwrap();
278 let resource_plural = resource_type.to_plural();
279
280 // Check if this is a malformed configuration (resource exists but not properly configured)
281 let is_malformed = artifact_config.resources.contains_key(resource_plural);
282
283 let supported_types: Vec<String> = artifact_config
284 .resources
285 .iter()
286 .filter(|(_, res_config)| {
287 res_config.path.is_some() || res_config.merge_target.is_some()
288 })
289 .map(|(s, _)| s.to_string())
290 .collect();
291
292 // Build resource-type-specific suggestions
293 let mut suggestions = Vec::new();
294
295 if is_malformed {
296 // Resource type exists but is malformed
297 suggestions.push(format!(
298 "Resource type '{}' is configured for tool '{}' but missing required 'path' or 'merge_target' field",
299 resource_plural, tool
300 ));
301
302 // Provide specific fix suggestions based on resource type
303 match resource_type {
304 crate::core::ResourceType::Hook => {
305 suggestions.push("For hooks, add: merge_target = '.claude/settings.local.json'".to_string());
306 }
307 crate::core::ResourceType::McpServer => {
308 suggestions.push(
309 "For MCP servers, add: merge_target = '.mcp.json'"
310 .to_string(),
311 );
312 }
313 _ => {
314 suggestions.push(format!(
315 "For {}, add: path = '{}'",
316 resource_plural, resource_plural
317 ));
318 }
319 }
320 } else {
321 // Resource type not supported at all
322 match resource_type {
323 crate::core::ResourceType::Snippet => {
324 suggestions.push("Snippets work best with the 'agpm' tool (shared infrastructure)".to_string());
325 suggestions.push(
326 "Add tool='agpm' to this dependency to use shared snippets"
327 .to_string(),
328 );
329 }
330 _ => {
331 // Find which tool types DO support this resource type
332 let default_config = ToolsConfig::default();
333 let tools_config =
334 self.tools.as_ref().unwrap_or(&default_config);
335 let supporting_types: Vec<String> = tools_config
336 .types
337 .iter()
338 .filter(|(_, config)| {
339 config.resources.contains_key(resource_plural)
340 && config
341 .resources
342 .get(resource_plural)
343 .map(|res| {
344 res.path.is_some()
345 || res.merge_target.is_some()
346 })
347 .unwrap_or(false)
348 })
349 .map(|(type_name, _)| format!("'{}'", type_name))
350 .collect();
351
352 if !supporting_types.is_empty() {
353 suggestions.push(format!(
354 "This resource type is supported by tools: {}",
355 supporting_types.join(", ")
356 ));
357 }
358 }
359 }
360 }
361
362 let mut reason = if is_malformed {
363 format!(
364 "Resource type '{}' is improperly configured for tool '{}' for dependency '{}'.\n\n",
365 resource_plural, tool, name
366 )
367 } else {
368 format!(
369 "Resource type '{}' is not supported by tool '{}' for dependency '{}'.\n\n",
370 resource_plural, tool, name
371 )
372 };
373
374 reason.push_str(&format!(
375 "Tool '{}' properly supports: {}\n\n",
376 tool,
377 supported_types.join(", ")
378 ));
379
380 if !suggestions.is_empty() {
381 reason.push_str("💡 Suggestions:\n");
382 for suggestion in &suggestions {
383 reason.push_str(&format!(" • {}\n", suggestion));
384 }
385 reason.push('\n');
386 }
387
388 reason.push_str(
389 "You can fix this by:\n\
390 1. Changing the 'tool' field to a supported tool\n\
391 2. Using a different resource type\n\
392 3. Removing this dependency from your manifest",
393 );
394
395 return Err(crate::core::AgpmError::ManifestValidationError {
396 reason,
397 }
398 .into());
399 }
400 }
401 }
402 }
403
404 // Validate patches reference valid aliases
405 self.validate_patches()?;
406
407 Ok(())
408 }
409
410 /// Validate that patches reference valid manifest aliases.
411 ///
412 /// This method checks that all patch aliases correspond to actual dependencies
413 /// defined in the manifest. Patches for non-existent aliases are rejected.
414 ///
415 /// # Errors
416 ///
417 /// Returns an error if a patch references an alias that doesn't exist in the manifest.
418 fn validate_patches(&self) -> Result<()> {
419 use crate::core::ResourceType;
420
421 // Helper to check if an alias exists for a resource type
422 let check_patch_aliases = |resource_type: ResourceType,
423 patches: &BTreeMap<String, PatchData>|
424 -> Result<()> {
425 let deps = self.get_dependencies(resource_type);
426
427 for alias in patches.keys() {
428 // Check if this alias exists in the manifest
429 let exists = if let Some(deps) = deps {
430 deps.contains_key(alias)
431 } else {
432 false
433 };
434
435 if !exists {
436 return Err(crate::core::AgpmError::ManifestValidationError {
437 reason: format!(
438 "Patch references unknown alias '{alias}' in [patch.{}] section.\n\
439 The alias must be defined in [{}] section of agpm.toml.\n\
440 To patch a transitive dependency, first add it explicitly to your manifest.",
441 resource_type.to_plural(),
442 resource_type.to_plural()
443 ),
444 }
445 .into());
446 }
447 }
448 Ok(())
449 };
450
451 // Validate patches for each resource type
452 check_patch_aliases(ResourceType::Agent, &self.patches.agents)?;
453 check_patch_aliases(ResourceType::Snippet, &self.patches.snippets)?;
454 check_patch_aliases(ResourceType::Command, &self.patches.commands)?;
455 check_patch_aliases(ResourceType::Script, &self.patches.scripts)?;
456 check_patch_aliases(ResourceType::McpServer, &self.patches.mcp_servers)?;
457 check_patch_aliases(ResourceType::Hook, &self.patches.hooks)?;
458 check_patch_aliases(ResourceType::Skill, &self.patches.skills)?;
459
460 Ok(())
461 }
462}