use std::path::Path;
use std::sync::LazyLock;
use regex::Regex;
use serde::Deserialize;
use crate::diagnostics::{
Diagnostic, Severity, P001, P002, P003, P004, P005, P006, P007, P008, P009, P010, P011,
};
static KEBAB_CASE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$").expect("kebab-case regex"));
static SEMVER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[0-9]+\.[0-9]+\.[0-9]+$").expect("semver regex"));
static CREDENTIAL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)(api[_-]?key|token|secret|password|credential)\s*[:=]\s*["'][^"']+["']"#)
.expect("credential regex")
});
const RECOMMENDED_FIELDS: &[(&str, &str)] = &[
("author", "Add an author field for attribution"),
("homepage", "Add a homepage URL for documentation"),
("license", "Add a license field for legal clarity"),
];
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum AuthorField {
Simple(String),
Detailed {
name: String,
url: Option<String>,
},
}
#[derive(Debug, Deserialize)]
pub struct PluginManifest {
pub name: Option<String>,
pub description: Option<String>,
pub version: Option<String>,
pub author: Option<AuthorField>,
pub homepage: Option<String>,
pub repository: Option<String>,
pub license: Option<String>,
pub keywords: Option<Vec<String>>,
pub commands: Option<String>,
pub agents: Option<String>,
pub skills: Option<String>,
pub hooks: Option<String>,
#[serde(rename = "mcpServers")]
pub mcp_servers: Option<serde_json::Value>,
#[serde(rename = "outputStyles")]
pub output_styles: Option<String>,
#[serde(rename = "lspServers")]
pub lsp_servers: Option<String>,
}
impl PluginManifest {
fn path_overrides(&self) -> Vec<(&'static str, &str)> {
let string_fields: [(&str, &Option<String>); 6] = [
("commands", &self.commands),
("agents", &self.agents),
("skills", &self.skills),
("hooks", &self.hooks),
("outputStyles", &self.output_styles),
("lspServers", &self.lsp_servers),
];
let mut result: Vec<(&'static str, &str)> = string_fields
.into_iter()
.filter_map(|(name, val)| val.as_deref().map(|v| (name, v)))
.collect();
if let Some(serde_json::Value::String(s)) = &self.mcp_servers {
result.push(("mcpServers", s.as_str()));
}
result
}
}
fn contains_path_traversal(path_str: &str) -> bool {
Path::new(path_str)
.components()
.any(|c| c == std::path::Component::ParentDir)
}
#[must_use]
pub fn validate_manifest(path: &Path) -> Vec<Diagnostic> {
let mut diags = Vec::new();
let content = match crate::parser::read_file_checked(path) {
Ok(c) => c,
Err(e) => {
diags.push(Diagnostic::new(
Severity::Error,
P001,
format!("cannot read plugin.json: {e}"),
));
return diags;
}
};
let raw: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
diags.push(Diagnostic::new(
Severity::Error,
P001,
format!("invalid JSON syntax: {e}"),
));
return diags;
}
};
let manifest: PluginManifest = match serde_json::from_value(raw.clone()) {
Ok(m) => m,
Err(e) => {
diags.push(Diagnostic::new(
Severity::Error,
P001,
format!("invalid manifest structure: {e}"),
));
return diags;
}
};
let name = match &manifest.name {
Some(n) if !n.is_empty() => n.as_str(),
Some(_) => {
diags.push(
Diagnostic::new(Severity::Error, P002, "`name` must not be empty")
.with_field("name"),
);
""
}
None => {
diags.push(
Diagnostic::new(Severity::Error, P002, "missing required field `name`")
.with_field("name"),
);
""
}
};
if !name.is_empty() && !KEBAB_CASE_RE.is_match(name) {
diags.push(
Diagnostic::new(
Severity::Error,
P003,
format!("`name` is not valid kebab-case: \"{name}\""),
)
.with_field("name")
.with_suggestion("Use lowercase letters, digits, and hyphens (e.g., \"my-plugin\")"),
);
}
if let Some(version) = &manifest.version {
if !SEMVER_RE.is_match(version) {
diags.push(
Diagnostic::new(
Severity::Warning,
P004,
format!("`version` is not valid semver: \"{version}\""),
)
.with_field("version")
.with_suggestion("Use x.y.z format (e.g., \"1.0.0\")"),
);
}
}
match &manifest.description {
Some(d) if d.trim().is_empty() => {
diags.push(
Diagnostic::new(Severity::Warning, P005, "`description` is empty")
.with_field("description"),
);
}
None => {
diags.push(
Diagnostic::new(Severity::Warning, P005, "missing `description` field")
.with_field("description"),
);
}
Some(_) => {}
}
let plugin_dir = path.parent().unwrap_or(Path::new("."));
for (field, value) in manifest.path_overrides() {
if value.starts_with('/') || Path::new(value).is_absolute() {
diags.push(
Diagnostic::new(
Severity::Error,
P006,
format!("`{field}` uses absolute path: \"{value}\""),
)
.with_field(field)
.with_suggestion("Use a relative path (e.g., \"./my-commands\")"),
);
continue;
}
if contains_path_traversal(value) {
diags.push(
Diagnostic::new(
Severity::Error,
P011,
format!("`{field}` contains path traversal: \"{value}\""),
)
.with_field(field)
.with_suggestion(
"Remove `..` components — paths must stay within the plugin directory",
),
);
continue;
}
let resolved = plugin_dir.join(value);
if !resolved.exists() {
diags.push(
Diagnostic::new(
Severity::Error,
P007,
format!("`{field}` path does not exist: \"{value}\""),
)
.with_field(field),
);
}
}
scan_credentials(&raw, &mut diags, &mut Vec::new());
if let Some(obj) = raw.get("mcpServers").and_then(|v| v.as_object()) {
for (server_name, config) in obj {
if let Some(url) = config.get("url").and_then(|u| u.as_str()) {
let url_lower = url.to_ascii_lowercase();
if url_lower.starts_with("http://") || url_lower.starts_with("ws://") {
diags.push(
Diagnostic::new(
Severity::Warning,
P009,
format!("MCP server \"{server_name}\" uses insecure URL: \"{url}\""),
)
.with_field("mcpServers")
.with_suggestion("Use HTTPS or WSS for secure communication"),
);
}
}
}
}
for (field, suggestion) in RECOMMENDED_FIELDS {
if raw.get(field).is_none() {
diags.push(
Diagnostic::new(
Severity::Info,
P010,
format!("missing recommended field `{field}`"),
)
.with_field(field)
.with_suggestion(*suggestion),
);
}
}
diags
}
fn scan_credentials(
value: &serde_json::Value,
diags: &mut Vec<Diagnostic>,
path: &mut Vec<String>,
) {
match value {
serde_json::Value::String(s) => {
if CREDENTIAL_RE.is_match(s) {
let location = if path.is_empty() {
String::new()
} else {
format!(" at `{}`", path.join(""))
};
diags.push(
Diagnostic::new(
Severity::Error,
P008,
format!("possible hardcoded credential detected{location}"),
)
.with_suggestion(
"Use environment variables or a secrets manager instead of inline credentials",
),
);
}
}
serde_json::Value::Object(map) => {
for (key, v) in map {
let segment = if path.is_empty() {
key.clone()
} else {
format!(".{key}")
};
path.push(segment);
scan_credentials(v, diags, path);
path.pop();
}
}
serde_json::Value::Array(arr) => {
for (idx, v) in arr.iter().enumerate() {
path.push(format!("[{idx}]"));
scan_credentials(v, diags, path);
path.pop();
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write_manifest(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempdir().unwrap();
let path = dir.path().join("plugin.json");
fs::write(&path, content).unwrap();
(dir, path)
}
#[test]
fn valid_manifest_no_errors() {
let (_dir, path) = write_manifest(
r#"{
"name": "my-plugin",
"description": "A test plugin",
"version": "1.0.0",
"author": { "name": "Test", "url": "https://example.com" },
"homepage": "https://example.com",
"license": "MIT"
}"#,
);
let diags = validate_manifest(&path);
let errors: Vec<_> = diags.iter().filter(|d| d.is_error()).collect();
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
}
#[test]
fn invalid_json_p001() {
let (_dir, path) = write_manifest("{ not json }");
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P001));
}
#[test]
fn missing_name_p002() {
let (_dir, path) = write_manifest(r#"{ "description": "test" }"#);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P002));
}
#[test]
fn empty_name_p002() {
let (_dir, path) = write_manifest(r#"{ "name": "" }"#);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P002));
}
#[test]
fn invalid_name_p003() {
let (_dir, path) = write_manifest(r#"{ "name": "My Plugin" }"#);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P003));
}
#[test]
fn uppercase_name_p003() {
let (_dir, path) = write_manifest(r#"{ "name": "MyPlugin" }"#);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P003));
}
#[test]
fn valid_kebab_name_no_p003() {
let (_dir, path) = write_manifest(
r#"{ "name": "my-plugin", "description": "test", "author": "x", "homepage": "x", "license": "MIT" }"#,
);
let diags = validate_manifest(&path);
assert!(!diags.iter().any(|d| d.code == P003));
}
#[test]
fn invalid_version_p004() {
let (_dir, path) = write_manifest(r#"{ "name": "test", "version": "1.0" }"#);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P004));
}
#[test]
fn valid_version_no_p004() {
let (_dir, path) = write_manifest(r#"{ "name": "test", "version": "1.2.3" }"#);
let diags = validate_manifest(&path);
assert!(!diags.iter().any(|d| d.code == P004));
}
#[test]
fn missing_description_p005() {
let (_dir, path) = write_manifest(r#"{ "name": "test" }"#);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P005));
}
#[test]
fn empty_description_p005() {
let (_dir, path) = write_manifest(r#"{ "name": "test", "description": " " }"#);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P005));
}
#[test]
fn absolute_path_p006() {
let (_dir, path) = write_manifest(r#"{ "name": "test", "commands": "/usr/local/cmds" }"#);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P006));
}
#[test]
fn nonexistent_path_p007() {
let (_dir, path) = write_manifest(r#"{ "name": "test", "commands": "./nonexistent-dir" }"#);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P007));
}
#[test]
fn existing_path_no_p007() {
let dir = tempdir().unwrap();
let cmds_dir = dir.path().join("my-commands");
fs::create_dir(&cmds_dir).unwrap();
let path = dir.path().join("plugin.json");
fs::write(&path, r#"{ "name": "test", "commands": "./my-commands" }"#).unwrap();
let diags = validate_manifest(&path);
assert!(!diags.iter().any(|d| d.code == P007));
}
#[test]
fn credential_detection_p008() {
let (_dir, path) = write_manifest(
r#"{ "name": "test", "config": { "value": "api_key: 'sk-1234abcd'" } }"#,
);
let diags = validate_manifest(&path);
let p008 = diags.iter().find(|d| d.code == P008);
assert!(p008.is_some());
assert!(
p008.unwrap().message.contains("config.value"),
"P008 should include JSON path: {}",
p008.unwrap().message
);
}
#[test]
fn no_credential_false_positive() {
let (_dir, path) =
write_manifest(r#"{ "name": "test", "description": "Uses API key rotation" }"#);
let diags = validate_manifest(&path);
assert!(!diags.iter().any(|d| d.code == P008));
}
#[test]
fn insecure_mcp_url_p009() {
let (_dir, path) = write_manifest(
r#"{ "name": "test", "mcpServers": { "local": { "url": "http://localhost:3000" } } }"#,
);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P009));
}
#[test]
fn insecure_ws_url_p009() {
let (_dir, path) = write_manifest(
r#"{ "name": "test", "mcpServers": { "ws-server": { "url": "ws://localhost:8080" } } }"#,
);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P009));
}
#[test]
fn insecure_url_case_insensitive_p009() {
let (_dir, path) = write_manifest(
r#"{ "name": "test", "mcpServers": { "mixed": { "url": "HTTP://localhost:3000" } } }"#,
);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P009));
}
#[test]
fn secure_mcp_url_no_p009() {
let (_dir, path) = write_manifest(
r#"{ "name": "test", "mcpServers": { "remote": { "url": "https://api.example.com" } } }"#,
);
let diags = validate_manifest(&path);
assert!(!diags.iter().any(|d| d.code == P009));
}
#[test]
fn missing_recommended_fields_p010() {
let (_dir, path) = write_manifest(r#"{ "name": "test" }"#);
let diags = validate_manifest(&path);
let p010s: Vec<_> = diags.iter().filter(|d| d.code == P010).collect();
assert_eq!(p010s.len(), 3); }
#[test]
fn all_recommended_fields_no_p010() {
let (_dir, path) = write_manifest(
r#"{ "name": "test", "author": "x", "homepage": "x", "license": "MIT" }"#,
);
let diags = validate_manifest(&path);
assert!(!diags.iter().any(|d| d.code == P010));
}
#[test]
fn nonexistent_file_returns_p001() {
let diags = validate_manifest(Path::new("/nonexistent/plugin.json"));
assert!(diags.iter().any(|d| d.code == P001));
}
#[test]
fn author_simple_string_accepted() {
let (_dir, path) = write_manifest(
r#"{ "name": "test", "author": "Jane Doe", "homepage": "x", "license": "MIT" }"#,
);
let diags = validate_manifest(&path);
assert!(!diags
.iter()
.any(|d| d.code == P010 && d.field == Some("author")));
}
#[test]
fn author_detailed_accepted() {
let (_dir, path) = write_manifest(
r#"{ "name": "test", "author": { "name": "Jane", "url": "https://x.com" }, "homepage": "x", "license": "MIT" }"#,
);
let diags = validate_manifest(&path);
assert!(!diags
.iter()
.any(|d| d.code == P010 && d.field == Some("author")));
}
#[test]
fn path_traversal_p011() {
let (_dir, path) = write_manifest(r#"{ "name": "test", "commands": "../outside/cmds" }"#);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P011));
}
#[test]
fn embedded_traversal_p011() {
let (_dir, path) = write_manifest(r#"{ "name": "test", "agents": "sub/../agents" }"#);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P011));
}
#[test]
fn dot_slash_no_traversal() {
let dir = tempdir().unwrap();
let cmds = dir.path().join("my-commands");
fs::create_dir(&cmds).unwrap();
let path = dir.path().join("plugin.json");
fs::write(&path, r#"{ "name": "test", "commands": "./my-commands" }"#).unwrap();
let diags = validate_manifest(&path);
assert!(!diags.iter().any(|d| d.code == P011));
}
#[test]
fn traversal_skips_existence_check() {
let (_dir, path) = write_manifest(r#"{ "name": "test", "commands": "../escape" }"#);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P011));
assert!(
!diags.iter().any(|d| d.code == P007),
"traversal should skip P007"
);
}
#[test]
fn absolute_path_still_p006() {
let (_dir, path) = write_manifest(r#"{ "name": "test", "commands": "/usr/local/cmds" }"#);
let diags = validate_manifest(&path);
assert!(diags.iter().any(|d| d.code == P006));
assert!(!diags.iter().any(|d| d.code == P011));
}
}