Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 19 additions & 18 deletions apps/shellsmith-mcp/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::jobs::{CommandPlan, JobPlan, Server};
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashSet;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
Expand Down Expand Up @@ -1721,7 +1721,7 @@ const MAX_IOS_BUNDLE_DEPTH: usize = 32;
/// 校验 iOS 工程 bundle 内实际会被读取的文件路径,重点拦截工程内部的文件级符号链接。
fn validate_ios_bundle(server: &Arc<Server>, root: &Path) -> Result<()> {
let mut pending = vec![(root.to_path_buf(), 0usize)];
let mut visited = HashSet::new();
let mut visited: HashMap<PathBuf, usize> = HashMap::new();
let mut entries = 0usize;
while let Some((directory, depth)) = pending.pop() {
anyhow::ensure!(
Expand All @@ -1730,9 +1730,13 @@ fn validate_ios_bundle(server: &Arc<Server>, root: &Path) -> Result<()> {
directory.display()
);
let directory = directory.canonicalize().unwrap_or(directory);
if !visited.insert(directory.clone()) {
if visited
.get(&directory)
.is_some_and(|previous_depth| *previous_depth <= depth)
{
continue;
}
visited.insert(directory.clone(), depth);
let children = fs::read_dir(&directory)
.with_context(|| format!("读取 iOS 工程目录失败: {}", directory.display()))?;
for child in children {
Expand Down Expand Up @@ -1779,22 +1783,25 @@ fn collect_ios_projects(
server: &Arc<Server>,
root: &Path,
depth: usize,
visited: &mut HashSet<PathBuf>,
output: &mut Vec<PathBuf>,
) -> Result<()> {
if depth > 2 {
return Ok(());
}
let root_key = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
if !visited.insert(root_key) {
return Ok(());
}
let entries = fs::read_dir(root)
.with_context(|| format!("读取 iOS workspace 目录失败: {}", root.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let is_project = path.extension().and_then(|value| value.to_str()) == Some("xcodeproj");
let skip = matches!(
entry.file_name().to_str(),
Some(".git" | "Pods" | ".build" | "DerivedData")
);
// CLI 在工程发现阶段按入口名跳过缓存目录,普通目录和目录链接一致。
if skip && !is_project {
continue;
}
let metadata = fs::symlink_metadata(&path)
.with_context(|| format!("读取 iOS workspace 路径失败: {}", path.display()))?;
if metadata.file_type().is_symlink() {
Expand All @@ -1817,19 +1824,14 @@ fn collect_ios_projects(
};
if target.is_dir() {
server.ensure_allowed(&target, "iOS 工程内部路径")?;
collect_ios_projects(server, &target, depth + 1, visited, output)?;
collect_ios_projects(server, &target, depth + 1, output)?;
}
continue;
}
if is_project {
output.push(path);
} else if metadata.is_dir()
&& !matches!(
entry.file_name().to_str(),
Some(".git" | "Pods" | ".build" | "DerivedData")
)
{
collect_ios_projects(server, &path, depth + 1, visited, output)?;
} else if metadata.is_dir() {
collect_ios_projects(server, &path, depth + 1, output)?;
}
}
Ok(())
Expand Down Expand Up @@ -1859,8 +1861,7 @@ fn validate_ios_project(
validate_ios_bundle(server, project)?;
if let Some(root) = project.parent() {
let mut projects = Vec::new();
let mut visited = HashSet::new();
collect_ios_projects(server, root, 0, &mut visited, &mut projects)?;
collect_ios_projects(server, root, 0, &mut projects)?;
for project in projects {
validate_ios_bundle(server, &project)?;
}
Expand Down
57 changes: 57 additions & 0 deletions apps/shellsmith-mcp/tests/mcp_stdio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,63 @@ fn checks_projects_reached_through_directory_symlinks() {
}
}

#[cfg(unix)]
#[test]
fn skips_cache_directories_through_symlinks() {
use std::os::unix::fs::symlink;

let sample = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let workspace = sample.path().join("Audit.xcworkspace");
fs::create_dir_all(&workspace).unwrap();
let project = sample.path().join("Inside.xcodeproj");
fs::create_dir_all(&project).unwrap();
fs::write(project.join("project.pbxproj"), b"// inside").unwrap();
let cache = outside.path().join("DerivedData");
fs::create_dir_all(&cache).unwrap();
symlink(&cache, sample.path().join("DerivedData")).unwrap();
let cli = write_fake_cli(sample.path());

let mut client = McpClient::start(&cli, &[sample.path()], &[]);
let result = client.call(
"shellsmith_check_ios",
json!({"project": workspace.display().to_string(), "scheme": "Audit"}),
);
assert_eq!(result["isError"], false, "{result}");
}

#[cfg(unix)]
#[test]
fn checks_shared_directories_reached_at_different_depths() {
use std::os::unix::fs::symlink;

let allowed = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let holder = allowed.path().join("holder");
let workspace = holder.join("Audit.xcworkspace");
fs::create_dir_all(&workspace).unwrap();
let left = allowed.path().join("left");
let nested = left.join("Child/Nested.xcodeproj");
fs::create_dir_all(&nested).unwrap();
let private = outside.path().join("private.pbxproj");
fs::write(&private, b"PRODUCT_BUNDLE_IDENTIFIER = com.audit.depth;").unwrap();
symlink(&private, nested.join("project.pbxproj")).unwrap();
let right = allowed.path().join("right");
fs::create_dir_all(&right).unwrap();
symlink(&left, right.join("to-second")).unwrap();
symlink(&right, holder.join("LinkB")).unwrap();
symlink(&left, holder.join("LinkA")).unwrap();
let cli = write_fake_cli(allowed.path());

let mut client = McpClient::start(&cli, &[allowed.path()], &[]);
let result = client.call(
"shellsmith_check_ios",
json!({"project": workspace.display().to_string(), "scheme": "Audit"}),
);
assert_eq!(result["isError"], true, "{result}");
assert!(text(&result).contains("不在允许的目录内"), "{result}");
}

#[cfg(unix)]
#[test]
fn checks_project_aliases_by_entry_name() {
Expand Down
Loading