From b70e11c1e8cda29ee9c3e630b8e072eb8b74bfeb Mon Sep 17 00:00:00 2001 From: haonan Date: Fri, 13 Nov 2026 10:10:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AF=B9=E9=BD=90=20iOS=20=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E5=8F=91=E7=8E=B0=E7=9A=84=E6=B7=B1=E5=BA=A6=E4=B8=8E?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除工程发现遍历中与深度无关的 visited 去重,避免浅路径漏检 - bundle 校验的 visited 记录最浅到达深度,保持循环保护同时覆盖浅路径 - 普通目录与目录链接统一应用 .git/Pods/.build/DerivedData 跳过规则 - 增加深路径先到、共享目录多路径和 DerivedData 链接回归测试 --- apps/shellsmith-mcp/src/tools.rs | 37 +++++++++-------- apps/shellsmith-mcp/tests/mcp_stdio.rs | 57 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/apps/shellsmith-mcp/src/tools.rs b/apps/shellsmith-mcp/src/tools.rs index e8897ba..624e369 100644 --- a/apps/shellsmith-mcp/src/tools.rs +++ b/apps/shellsmith-mcp/src/tools.rs @@ -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; @@ -1721,7 +1721,7 @@ const MAX_IOS_BUNDLE_DEPTH: usize = 32; /// 校验 iOS 工程 bundle 内实际会被读取的文件路径,重点拦截工程内部的文件级符号链接。 fn validate_ios_bundle(server: &Arc, root: &Path) -> Result<()> { let mut pending = vec![(root.to_path_buf(), 0usize)]; - let mut visited = HashSet::new(); + let mut visited: HashMap = HashMap::new(); let mut entries = 0usize; while let Some((directory, depth)) = pending.pop() { anyhow::ensure!( @@ -1730,9 +1730,13 @@ fn validate_ios_bundle(server: &Arc, 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 { @@ -1779,22 +1783,25 @@ fn collect_ios_projects( server: &Arc, root: &Path, depth: usize, - visited: &mut HashSet, output: &mut Vec, ) -> 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() { @@ -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(()) @@ -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)?; } diff --git a/apps/shellsmith-mcp/tests/mcp_stdio.rs b/apps/shellsmith-mcp/tests/mcp_stdio.rs index 68386b6..c6f6e90 100644 --- a/apps/shellsmith-mcp/tests/mcp_stdio.rs +++ b/apps/shellsmith-mcp/tests/mcp_stdio.rs @@ -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() {