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}