1use crate::error::{AuditError, Result};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::fs;
11use std::path::Path;
12
13pub const PINNING_FILENAME: &str = ".cc-audit-pins.json";
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ToolPins {
19 pub version: String,
21 pub created_at: String,
23 pub updated_at: String,
25 pub tools: HashMap<String, PinnedTool>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PinnedTool {
32 pub hash: String,
34 pub source: String,
36 pub pinned_at: String,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub version: Option<String>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct PinVerifyResult {
46 pub modified: Vec<PinMismatch>,
48 pub added: Vec<String>,
50 pub removed: Vec<String>,
52 pub has_changes: bool,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct PinMismatch {
59 pub name: String,
61 pub pinned_hash: String,
63 pub current_hash: String,
65 pub source: String,
67}
68
69impl ToolPins {
70 pub fn from_mcp_config(path: &Path) -> Result<Self> {
72 let content = fs::read_to_string(path).map_err(|e| AuditError::ReadError {
73 path: path.display().to_string(),
74 source: e,
75 })?;
76
77 Self::from_mcp_content(&content, path)
78 }
79
80 pub fn from_mcp_content(content: &str, path: &Path) -> Result<Self> {
82 let config: serde_json::Value =
83 serde_json::from_str(content).map_err(|e| AuditError::ParseError {
84 path: path.display().to_string(),
85 message: e.to_string(),
86 })?;
87
88 let mut tools = HashMap::new();
89 let now = chrono::Utc::now().to_rfc3339();
90
91 if let Some(mcp_servers) = config.get("mcpServers").and_then(|v| v.as_object()) {
93 for (name, server_config) in mcp_servers {
94 let pinned_tool = Self::create_pinned_tool(name, server_config, &now);
95 tools.insert(name.clone(), pinned_tool);
96 }
97 }
98
99 Ok(Self {
100 version: "1".to_string(),
101 created_at: now.clone(),
102 updated_at: now,
103 tools,
104 })
105 }
106
107 fn create_pinned_tool(_name: &str, config: &serde_json::Value, timestamp: &str) -> PinnedTool {
109 let config_str = serde_json::to_string(config).unwrap_or_default();
111 let hash = Self::compute_hash(&config_str);
112
113 let source = Self::extract_source(config);
115
116 let version = Self::extract_version(&source);
118
119 PinnedTool {
120 hash,
121 source,
122 pinned_at: timestamp.to_string(),
123 version,
124 }
125 }
126
127 fn compute_hash(content: &str) -> String {
129 let mut hasher = Sha256::new();
130 hasher.update(content.as_bytes());
131 format!("sha256:{:x}", hasher.finalize())
132 }
133
134 fn extract_source(config: &serde_json::Value) -> String {
136 let command = config
137 .get("command")
138 .and_then(|v| v.as_str())
139 .unwrap_or_default();
140
141 let args = config
142 .get("args")
143 .and_then(|v| v.as_array())
144 .map(|arr| {
145 arr.iter()
146 .filter_map(|v| v.as_str())
147 .collect::<Vec<_>>()
148 .join(" ")
149 })
150 .unwrap_or_default();
151
152 if !command.is_empty() && !args.is_empty() {
153 format!("{} {}", command, args)
154 } else if !command.is_empty() {
155 command.to_string()
156 } else if !args.is_empty() {
157 args
158 } else {
159 config
160 .get("url")
161 .and_then(|v| v.as_str())
162 .unwrap_or("unknown")
163 .to_string()
164 }
165 }
166
167 fn extract_version(source: &str) -> Option<String> {
169 let patterns = [
172 regex::Regex::new(r"@[\w-]+/[\w-]+@([\d.]+[\w.-]*)").ok()?,
174 regex::Regex::new(r"[\w-]+@([\d.]+[\w.-]*)").ok()?,
176 regex::Regex::new(r"[\w-]+:([\w][\w.-]*)").ok()?,
178 ];
179
180 for pattern in &patterns {
181 if let Some(caps) = pattern.captures(source)
182 && let Some(version) = caps.get(1)
183 {
184 return Some(version.as_str().to_string());
185 }
186 }
187
188 None
189 }
190
191 pub fn save(&self, dir: &Path) -> Result<()> {
193 let pin_path = if dir.is_file() {
194 dir.parent()
195 .unwrap_or(Path::new("."))
196 .join(PINNING_FILENAME)
197 } else {
198 dir.join(PINNING_FILENAME)
199 };
200
201 self.save_to_file(&pin_path)
202 }
203
204 pub fn save_to_file(&self, path: &Path) -> Result<()> {
206 let json = serde_json::to_string_pretty(self).map_err(|e| AuditError::ParseError {
207 path: path.display().to_string(),
208 message: e.to_string(),
209 })?;
210
211 fs::write(path, json).map_err(|e| AuditError::ReadError {
212 path: path.display().to_string(),
213 source: e,
214 })?;
215
216 Ok(())
217 }
218
219 pub fn load(dir: &Path) -> Result<Self> {
221 let pin_path = if dir.is_file() {
222 dir.parent()
223 .unwrap_or(Path::new("."))
224 .join(PINNING_FILENAME)
225 } else {
226 dir.join(PINNING_FILENAME)
227 };
228
229 Self::load_from_file(&pin_path)
230 }
231
232 pub fn load_from_file(path: &Path) -> Result<Self> {
234 if !path.exists() {
235 return Err(AuditError::FileNotFound(path.display().to_string()));
236 }
237
238 let content = fs::read_to_string(path).map_err(|e| AuditError::ReadError {
239 path: path.display().to_string(),
240 source: e,
241 })?;
242
243 serde_json::from_str(&content).map_err(|e| AuditError::ParseError {
244 path: path.display().to_string(),
245 message: e.to_string(),
246 })
247 }
248
249 pub fn exists(dir: &Path) -> bool {
251 let pin_path = if dir.is_file() {
252 dir.parent()
253 .unwrap_or(Path::new("."))
254 .join(PINNING_FILENAME)
255 } else {
256 dir.join(PINNING_FILENAME)
257 };
258
259 pin_path.exists()
260 }
261
262 pub fn verify(&self, mcp_path: &Path) -> Result<PinVerifyResult> {
264 let current = Self::from_mcp_config(mcp_path)?;
265
266 let mut modified = Vec::new();
267 let mut added = Vec::new();
268 let mut removed = Vec::new();
269
270 for (name, pinned) in &self.tools {
272 match current.tools.get(name) {
273 Some(current_tool) => {
274 if pinned.hash != current_tool.hash {
275 modified.push(PinMismatch {
276 name: name.clone(),
277 pinned_hash: pinned.hash.clone(),
278 current_hash: current_tool.hash.clone(),
279 source: current_tool.source.clone(),
280 });
281 }
282 }
283 None => {
284 removed.push(name.clone());
285 }
286 }
287 }
288
289 for name in current.tools.keys() {
291 if !self.tools.contains_key(name) {
292 added.push(name.clone());
293 }
294 }
295
296 let has_changes = !modified.is_empty() || !added.is_empty() || !removed.is_empty();
297
298 Ok(PinVerifyResult {
299 modified,
300 added,
301 removed,
302 has_changes,
303 })
304 }
305
306 pub fn update(&mut self, mcp_path: &Path) -> Result<()> {
308 let current = Self::from_mcp_config(mcp_path)?;
309
310 self.tools = current.tools;
311 self.updated_at = chrono::Utc::now().to_rfc3339();
312
313 Ok(())
314 }
315}
316
317impl PinVerifyResult {
318 pub fn format_terminal(&self) -> String {
320 use colored::Colorize;
321
322 let mut output = String::new();
323
324 if !self.has_changes {
325 output.push_str(
326 &"✅ All MCP tool pins verified. No changes detected.\n"
327 .green()
328 .to_string(),
329 );
330 return output;
331 }
332
333 output.push_str(&format!(
334 "{}\n\n",
335 "━━━ MCP TOOL PIN MISMATCH (Potential Rug Pull) ━━━"
336 .red()
337 .bold()
338 ));
339
340 if !self.modified.is_empty() {
341 output.push_str(&format!("{}\n", "Modified tools:".yellow().bold()));
342 for mismatch in &self.modified {
343 output.push_str(&format!(" {} {}\n", "~".yellow(), mismatch.name));
344 output.push_str(&format!(" Source: {}\n", mismatch.source));
345 let pinned_display = if mismatch.pinned_hash.len() > 23 {
346 &mismatch.pinned_hash[..23]
347 } else {
348 &mismatch.pinned_hash
349 };
350 let current_display = if mismatch.current_hash.len() > 23 {
351 &mismatch.current_hash[..23]
352 } else {
353 &mismatch.current_hash
354 };
355 output.push_str(&format!(" Pinned: {}...\n", pinned_display));
356 output.push_str(&format!(" Current: {}...\n", current_display));
357 }
358 output.push('\n');
359 }
360
361 if !self.added.is_empty() {
362 output.push_str(&format!("{}\n", "Added tools:".green().bold()));
363 for name in &self.added {
364 output.push_str(&format!(" {} {}\n", "+".green(), name));
365 }
366 output.push('\n');
367 }
368
369 if !self.removed.is_empty() {
370 output.push_str(&format!("{}\n", "Removed tools:".red().bold()));
371 for name in &self.removed {
372 output.push_str(&format!(" {} {}\n", "-".red(), name));
373 }
374 output.push('\n');
375 }
376
377 output.push_str(&format!(
378 "Summary: {} modified, {} added, {} removed\n",
379 self.modified.len(),
380 self.added.len(),
381 self.removed.len()
382 ));
383
384 output.push_str(&format!(
385 "\n{}\n",
386 "Run 'cc-audit pin --update' to accept these changes."
387 .cyan()
388 .dimmed()
389 ));
390
391 output
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use tempfile::TempDir;
399
400 fn create_test_mcp_config() -> &'static str {
401 r#"{
402 "mcpServers": {
403 "github": {
404 "command": "npx",
405 "args": ["-y", "@anthropic/mcp-server-github"]
406 },
407 "filesystem": {
408 "command": "npx",
409 "args": ["-y", "@anthropic/mcp-server-filesystem", "/path"]
410 }
411 }
412 }"#
413 }
414
415 #[test]
416 fn test_create_pins_from_config() {
417 let temp_dir = TempDir::new().unwrap();
418 let config_path = temp_dir.path().join("mcp.json");
419 fs::write(&config_path, create_test_mcp_config()).unwrap();
420
421 let pins = ToolPins::from_mcp_config(&config_path).unwrap();
422
423 assert_eq!(pins.version, "1");
424 assert_eq!(pins.tools.len(), 2);
425 assert!(pins.tools.contains_key("github"));
426 assert!(pins.tools.contains_key("filesystem"));
427 }
428
429 #[test]
430 fn test_pinned_tool_hash() {
431 let temp_dir = TempDir::new().unwrap();
432 let config_path = temp_dir.path().join("mcp.json");
433 fs::write(&config_path, create_test_mcp_config()).unwrap();
434
435 let pins = ToolPins::from_mcp_config(&config_path).unwrap();
436 let github = pins.tools.get("github").unwrap();
437
438 assert!(github.hash.starts_with("sha256:"));
439 assert!(github.source.contains("@anthropic/mcp-server-github"));
440 }
441
442 #[test]
443 fn test_save_and_load_pins() {
444 let temp_dir = TempDir::new().unwrap();
445 let config_path = temp_dir.path().join("mcp.json");
446 fs::write(&config_path, create_test_mcp_config()).unwrap();
447
448 let pins = ToolPins::from_mcp_config(&config_path).unwrap();
449 pins.save(temp_dir.path()).unwrap();
450
451 let loaded = ToolPins::load(temp_dir.path()).unwrap();
452 assert_eq!(pins.tools.len(), loaded.tools.len());
453 }
454
455 #[test]
456 fn test_verify_no_changes() {
457 let temp_dir = TempDir::new().unwrap();
458 let config_path = temp_dir.path().join("mcp.json");
459 fs::write(&config_path, create_test_mcp_config()).unwrap();
460
461 let pins = ToolPins::from_mcp_config(&config_path).unwrap();
462 let result = pins.verify(&config_path).unwrap();
463
464 assert!(!result.has_changes);
465 assert!(result.modified.is_empty());
466 assert!(result.added.is_empty());
467 assert!(result.removed.is_empty());
468 }
469
470 #[test]
471 fn test_verify_modified_tool() {
472 let temp_dir = TempDir::new().unwrap();
473 let config_path = temp_dir.path().join("mcp.json");
474 fs::write(&config_path, create_test_mcp_config()).unwrap();
475
476 let pins = ToolPins::from_mcp_config(&config_path).unwrap();
477
478 let modified_config = r#"{
480 "mcpServers": {
481 "github": {
482 "command": "npx",
483 "args": ["-y", "@evil/mcp-server-github"]
484 },
485 "filesystem": {
486 "command": "npx",
487 "args": ["-y", "@anthropic/mcp-server-filesystem", "/path"]
488 }
489 }
490 }"#;
491 fs::write(&config_path, modified_config).unwrap();
492
493 let result = pins.verify(&config_path).unwrap();
494
495 assert!(result.has_changes);
496 assert_eq!(result.modified.len(), 1);
497 assert_eq!(result.modified[0].name, "github");
498 }
499
500 #[test]
501 fn test_verify_added_tool() {
502 let temp_dir = TempDir::new().unwrap();
503 let config_path = temp_dir.path().join("mcp.json");
504 fs::write(&config_path, create_test_mcp_config()).unwrap();
505
506 let pins = ToolPins::from_mcp_config(&config_path).unwrap();
507
508 let modified_config = r#"{
510 "mcpServers": {
511 "github": {
512 "command": "npx",
513 "args": ["-y", "@anthropic/mcp-server-github"]
514 },
515 "filesystem": {
516 "command": "npx",
517 "args": ["-y", "@anthropic/mcp-server-filesystem", "/path"]
518 },
519 "new-tool": {
520 "command": "npx",
521 "args": ["-y", "@malicious/tool"]
522 }
523 }
524 }"#;
525 fs::write(&config_path, modified_config).unwrap();
526
527 let result = pins.verify(&config_path).unwrap();
528
529 assert!(result.has_changes);
530 assert_eq!(result.added.len(), 1);
531 assert!(result.added.contains(&"new-tool".to_string()));
532 }
533
534 #[test]
535 fn test_verify_removed_tool() {
536 let temp_dir = TempDir::new().unwrap();
537 let config_path = temp_dir.path().join("mcp.json");
538 fs::write(&config_path, create_test_mcp_config()).unwrap();
539
540 let pins = ToolPins::from_mcp_config(&config_path).unwrap();
541
542 let modified_config = r#"{
544 "mcpServers": {
545 "github": {
546 "command": "npx",
547 "args": ["-y", "@anthropic/mcp-server-github"]
548 }
549 }
550 }"#;
551 fs::write(&config_path, modified_config).unwrap();
552
553 let result = pins.verify(&config_path).unwrap();
554
555 assert!(result.has_changes);
556 assert_eq!(result.removed.len(), 1);
557 assert!(result.removed.contains(&"filesystem".to_string()));
558 }
559
560 #[test]
561 fn test_update_pins() {
562 let temp_dir = TempDir::new().unwrap();
563 let config_path = temp_dir.path().join("mcp.json");
564 fs::write(&config_path, create_test_mcp_config()).unwrap();
565
566 let mut pins = ToolPins::from_mcp_config(&config_path).unwrap();
567 let original_created = pins.created_at.clone();
568
569 let modified_config = r#"{
571 "mcpServers": {
572 "new-tool": {
573 "command": "npx",
574 "args": ["-y", "@new/tool"]
575 }
576 }
577 }"#;
578 fs::write(&config_path, modified_config).unwrap();
579
580 pins.update(&config_path).unwrap();
581
582 assert_eq!(pins.created_at, original_created);
583 assert_ne!(pins.updated_at, original_created);
584 assert_eq!(pins.tools.len(), 1);
585 assert!(pins.tools.contains_key("new-tool"));
586 }
587
588 #[test]
589 fn test_pins_exists() {
590 let temp_dir = TempDir::new().unwrap();
591
592 assert!(!ToolPins::exists(temp_dir.path()));
593
594 let pins = ToolPins {
595 version: "1".to_string(),
596 created_at: "2024-01-01".to_string(),
597 updated_at: "2024-01-01".to_string(),
598 tools: HashMap::new(),
599 };
600 pins.save(temp_dir.path()).unwrap();
601
602 assert!(ToolPins::exists(temp_dir.path()));
603 }
604
605 #[test]
606 fn test_extract_version() {
607 assert_eq!(
609 ToolPins::extract_version("npx @anthropic/mcp-server@1.2.3"),
610 Some("1.2.3".to_string())
611 );
612
613 assert_eq!(
615 ToolPins::extract_version("npx mcp-server@2.0.0-beta.1"),
616 Some("2.0.0-beta.1".to_string())
617 );
618
619 assert_eq!(
621 ToolPins::extract_version("docker run server:latest"),
622 Some("latest".to_string())
623 );
624
625 assert_eq!(ToolPins::extract_version("npx @anthropic/mcp-server"), None);
627 }
628
629 #[test]
630 fn test_compute_hash_consistency() {
631 let content = "test content";
632 let hash1 = ToolPins::compute_hash(content);
633 let hash2 = ToolPins::compute_hash(content);
634
635 assert_eq!(hash1, hash2);
636 assert!(hash1.starts_with("sha256:"));
637 }
638
639 #[test]
640 fn test_format_terminal_no_changes() {
641 let result = PinVerifyResult {
642 modified: vec![],
643 added: vec![],
644 removed: vec![],
645 has_changes: false,
646 };
647
648 let output = result.format_terminal();
649 assert!(output.contains("verified"));
650 }
651
652 #[test]
653 fn test_format_terminal_with_changes() {
654 let result = PinVerifyResult {
655 modified: vec![PinMismatch {
656 name: "github".to_string(),
657 pinned_hash: "sha256:abc123".to_string(),
658 current_hash: "sha256:def456".to_string(),
659 source: "npx @anthropic/mcp-server-github".to_string(),
660 }],
661 added: vec!["new-tool".to_string()],
662 removed: vec!["old-tool".to_string()],
663 has_changes: true,
664 };
665
666 let output = result.format_terminal();
667 assert!(output.contains("MISMATCH"));
668 assert!(output.contains("Modified"));
669 assert!(output.contains("Added"));
670 assert!(output.contains("Removed"));
671 }
672
673 #[test]
674 fn test_load_nonexistent_pins() {
675 let temp_dir = TempDir::new().unwrap();
676 let result = ToolPins::load(temp_dir.path());
677 assert!(result.is_err());
678 }
679
680 #[test]
681 fn test_extract_source_with_url() {
682 let config: serde_json::Value = serde_json::json!({
683 "url": "https://mcp.example.com/server"
684 });
685
686 let source = ToolPins::extract_source(&config);
687 assert_eq!(source, "https://mcp.example.com/server");
688 }
689
690 #[test]
691 fn test_extract_source_command_only() {
692 let config: serde_json::Value = serde_json::json!({
693 "command": "python"
694 });
695
696 let source = ToolPins::extract_source(&config);
697 assert_eq!(source, "python");
698 }
699
700 #[test]
701 fn test_pinned_tool_serialization() {
702 let tool = PinnedTool {
703 hash: "sha256:abc123".to_string(),
704 source: "npx @anthropic/mcp-server".to_string(),
705 pinned_at: "2024-01-01".to_string(),
706 version: Some("1.0.0".to_string()),
707 };
708
709 let json = serde_json::to_string(&tool).unwrap();
710 let parsed: PinnedTool = serde_json::from_str(&json).unwrap();
711
712 assert_eq!(tool.hash, parsed.hash);
713 assert_eq!(tool.version, parsed.version);
714 }
715
716 #[test]
717 fn test_pin_mismatch_serialization() {
718 let mismatch = PinMismatch {
719 name: "test".to_string(),
720 pinned_hash: "sha256:abc".to_string(),
721 current_hash: "sha256:def".to_string(),
722 source: "npx test".to_string(),
723 };
724
725 let json = serde_json::to_string(&mismatch).unwrap();
726 let parsed: PinMismatch = serde_json::from_str(&json).unwrap();
727
728 assert_eq!(mismatch.name, parsed.name);
729 }
730}