From 4730ac015bfa4613ad3fddad42048a81e903b3c2 Mon Sep 17 00:00:00 2001 From: haonan Date: Fri, 13 Nov 2026 13:45:04 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E6=8B=92=E7=BB=9D=E9=9D=9E=E6=99=AE?= =?UTF-8?q?=E9=80=9A=20iOS=20=E9=A2=84=E6=A3=80=E6=96=87=E4=BB=B6=E5=B9=B6?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=AF=BB=E5=8F=96=E9=98=BB=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CocoaPods 判定读取 project.pbxproj 前校验目标是普通文件 - iOS 配置与保护报告读取前同样校验普通文件,避免 FIFO 等阻塞 - 保留缺失文件原有行为,不引入额外误拦截 - 增加父工程 pbx、配置和报告 FIFO 回归测试 --- apps/shellsmith-mcp/src/tools.rs | 24 ++++++-- apps/shellsmith-mcp/tests/mcp_stdio.rs | 85 ++++++++++++++++++++++++++ docs/mcp.md | 2 +- 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/apps/shellsmith-mcp/src/tools.rs b/apps/shellsmith-mcp/src/tools.rs index 0a2539e..f7891d6 100644 --- a/apps/shellsmith-mcp/src/tools.rs +++ b/apps/shellsmith-mcp/src/tools.rs @@ -1434,6 +1434,7 @@ fn read_report(server: &Arc, args: Value) -> Result { if path.extension().and_then(|value| value.to_str()) != Some("json") { anyhow::bail!("保护报告必须是 JSON 文件"); } + ensure_regular_file(&path, "保护报告")?; let metadata = fs::metadata(&path)?; if metadata.len() > 64 * 1024 * 1024 { anyhow::bail!("保护报告超过 64 MiB"); @@ -1726,6 +1727,17 @@ fn absolute_entry_path(value: &str) -> Result { } } +fn ensure_regular_file(path: &Path, label: &str) -> Result<()> { + let metadata = + fs::metadata(path).with_context(|| format!("读取{label}元数据失败: {}", path.display()))?; + anyhow::ensure!( + metadata.is_file(), + "{label}不是普通文件: {}", + path.display() + ); + Ok(()) +} + const MAX_IOS_BUNDLE_ENTRIES: usize = 50_000; const MAX_IOS_BUNDLE_DEPTH: usize = 32; @@ -1903,10 +1915,13 @@ fn validate_ios_cocoapods_paths(server: &Arc, project: &Path) -> Result< let project_dir = if internal_workspace { parent } else { project }; let pbx_path = project_dir.join("project.pbxproj"); // 读取前先校验实际路径;内部 workspace 的父工程 pbx 不在 workspace bundle 的扫描范围内。 - if pbx_path.exists() { - server.resolve_input(&pbx_path.to_string_lossy(), "iOS project.pbxproj")?; - } - let pbx = fs::read_to_string(&pbx_path).unwrap_or_default(); + let pbx = if pbx_path.exists() { + let pbx_path = server.resolve_input(&pbx_path.to_string_lossy(), "iOS project.pbxproj")?; + ensure_regular_file(&pbx_path, "iOS project.pbxproj")?; + fs::read_to_string(&pbx_path).unwrap_or_default() + } else { + String::new() + }; let uses_pods = root.join("Podfile").is_file() || root.join("podfile").is_file() || root.join("Podfile.lock").is_file() @@ -1935,6 +1950,7 @@ fn validate_ios_cocoapods_paths(server: &Arc, project: &Path) -> Result< /// iOS 配置中的工程、entrypoint 和 Swift Confidential 路径也必须受允许目录约束。 fn validate_ios_config_paths(server: &Arc, config: &Path, dry_run: bool) -> Result<()> { + ensure_regular_file(config, "iOS 配置")?; let content = fs::read_to_string(config) .with_context(|| format!("读取 iOS 配置失败:{}", config.display()))?; let value: toml::Value = toml::from_str(&content).context("解析 iOS 配置失败")?; diff --git a/apps/shellsmith-mcp/tests/mcp_stdio.rs b/apps/shellsmith-mcp/tests/mcp_stdio.rs index 2dc7ecf..35a20b6 100644 --- a/apps/shellsmith-mcp/tests/mcp_stdio.rs +++ b/apps/shellsmith-mcp/tests/mcp_stdio.rs @@ -990,6 +990,91 @@ fn rejects_internal_workspace_parent_pbx_symlink() { assert_eq!(job["structuredContent"]["status"], "succeeded", "{job}"); } +#[cfg(unix)] +#[test] +fn rejects_non_regular_parent_pbx_without_blocking() { + let allowed = tempfile::tempdir().unwrap(); + let host = allowed.path().join("Host.xcodeproj"); + let workspace = host.join("project.xcworkspace"); + fs::create_dir_all(&workspace).unwrap(); + let fifo = host.join("project.pbxproj"); + let status = Command::new("mkfifo").arg(&fifo).status().unwrap(); + assert!(status.success(), "创建 FIFO 失败"); + let cli = write_fake_cli(allowed.path()); + let config = workspace.join("ios.toml"); + fs::write( + &config, + format!( + "[project]\npath = \"{}\"\nscheme = \"Audit\"\nteam_id = \"ABCDEFGHIJ\"\nbundle_ids = [\"com.example.audit\"]\n[protection]\nprofile = \"compat\"\n", + workspace.display() + ), + ) + .unwrap(); + + let mut client = McpClient::start(&cli, &[allowed.path()], &[]); + let started = Instant::now(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + let check_elapsed = started.elapsed(); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("不是普通文件"), "{check}"); + assert!( + check_elapsed < Duration::from_secs(5), + "非普通文件阻塞了检查: {check_elapsed:?}" + ); + + let started = Instant::now(); + let protect = client.call( + "shellsmith_protect_ios", + json!({ + "ios_config": config.display().to_string(), + "output": allowed.path().join("out").display().to_string(), + "dry_run": true + }), + ); + let protect_elapsed = started.elapsed(); + assert_eq!(protect["isError"], true, "{protect}"); + assert!(text(&protect).contains("不是普通文件"), "{protect}"); + assert!( + protect_elapsed < Duration::from_secs(5), + "非普通文件阻塞了保护预检: {protect_elapsed:?}" + ); +} + +#[cfg(unix)] +#[test] +fn rejects_non_regular_ios_config_and_report() { + let temp = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(temp.path()); + let config_fifo = temp.path().join("ios.toml"); + let report_fifo = temp.path().join("report.json"); + for fifo in [&config_fifo, &report_fifo] { + let status = Command::new("mkfifo").arg(fifo).status().unwrap(); + assert!(status.success(), "创建 FIFO 失败"); + } + + let mut client = McpClient::start(&cli, &[temp.path()], &[]); + let protect = client.call( + "shellsmith_protect_ios", + json!({ + "ios_config": config_fifo.display().to_string(), + "output": temp.path().join("out").display().to_string(), + "dry_run": true + }), + ); + assert_eq!(protect["isError"], true, "{protect}"); + assert!(text(&protect).contains("不是普通文件"), "{protect}"); + + let report = client.call( + "shellsmith_read_report", + json!({"path": report_fifo.display().to_string()}), + ); + assert_eq!(report["isError"], true, "{report}"); + assert!(text(&report).contains("不是普通文件"), "{report}"); +} + #[cfg(unix)] #[test] fn rejects_cocoapods_lock_symlink_escape() { diff --git a/docs/mcp.md b/docs/mcp.md index 9fbd921..bf72c50 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -214,7 +214,7 @@ exec /opt/Shellsmith/bin/shellsmith-mcp --allow-root "$HOME/projects" ## 安全边界 - **密码**:只从 MCP 进程环境读取;工具 schema 中没有 `password`、`ks_pass`、`key_password` 字段。错误信息不会回显密码。 -- **路径**:输入路径必须存在并会被规范化;输出父目录必须存在;配置 `--allow-root` 后,所有输入和输出都必须位于允许目录内。iOS 配置中的 `project.path`、实际启用的 `project.entrypoint`/`confidential.config`,以及 `.xcodeproj`/`.xcworkspace` bundle 内部的符号链接都会单独校验。iOS 检查/保护还要求工程源码根目录在允许范围内;只允许 bundle 本身会被明确拒绝。CocoaPods 的 `Podfile.lock`、`Pods/Manifest.lock` 等实际读取文件也会校验;bundle 内与 CLI 实际读取无关的失效链接会被忽略。 +- **路径**:输入路径必须存在并会被规范化;输出父目录必须存在;配置 `--allow-root` 后,所有输入和输出都必须位于允许目录内。iOS 配置中的 `project.path`、实际启用的 `project.entrypoint`/`confidential.config`,以及 `.xcodeproj`/`.xcworkspace` bundle 内部的符号链接都会单独校验。iOS 检查/保护还要求工程源码根目录在允许范围内;只允许 bundle 本身会被明确拒绝。CocoaPods 的 `Podfile.lock`、`Pods/Manifest.lock` 以及用于判断依赖的 `project.pbxproj` 会在读取前校验路径和普通文件类型;bundle 内与 CLI 实际读取无关的失效链接会被忽略。 - **确认**:加固、签名、安装要求 `confirm: true`;原地覆盖还要求 `allow_in_place: true`。 - **取消**:异步任务和同步检查都会终止整个 CLI 进程树;MCP 持有任务私有 `TMPDIR`/暂存根目录,取消或退出后会清理口令与 staging。回滚失败时 CLI 明确保留的恢复副本位于输出目录,不会被 MCP 删除。 - **不上传**:MCP 不包含遥测、更新检查或远程托管逻辑。只有显式使用 BYOS 时才会访问你配置的 HTTPS 服务。 From 99ab0ab4aff6d7fc38078c2a0ac7812aed1046af Mon Sep 17 00:00:00 2001 From: haonan Date: Fri, 13 Nov 2026 14:15:43 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E6=A0=A1=E9=AA=8C=20CocoaPods=20?= =?UTF-8?q?=E9=94=81=E6=96=87=E4=BB=B6=E4=B8=8E=E5=AD=90=E5=B7=A5=E7=A8=8B?= =?UTF-8?q?=20pbx=20=E7=9A=84=E6=96=87=E4=BB=B6=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Podfile.lock 和 Pods/Manifest.lock 在解析路径后要求普通文件 - bundle 扫描对 project.pbxproj/contents.xcworkspacedata 检查普通文件类型 - 覆盖 workspace 发现的子工程 pbx 及其链接目标 - 增加锁文件与子工程 FIFO 阻塞回归测试 --- apps/shellsmith-mcp/src/tools.rs | 41 ++++++-- apps/shellsmith-mcp/tests/mcp_stdio.rs | 126 +++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 10 deletions(-) diff --git a/apps/shellsmith-mcp/src/tools.rs b/apps/shellsmith-mcp/src/tools.rs index f7891d6..e22f889 100644 --- a/apps/shellsmith-mcp/src/tools.rs +++ b/apps/shellsmith-mcp/src/tools.rs @@ -1771,16 +1771,23 @@ fn validate_ios_bundle(server: &Arc, root: &Path) -> Result<()> { root.display() ); let path = child.path(); + let required = matches!( + path.file_name().and_then(|value| value.to_str()), + Some("project.pbxproj" | "contents.xcworkspacedata") + ); let metadata = fs::symlink_metadata(&path) .with_context(|| format!("读取 iOS 工程路径失败: {}", path.display()))?; if metadata.file_type().is_symlink() { - let required = matches!( - path.file_name().and_then(|value| value.to_str()), - Some("project.pbxproj" | "contents.xcworkspacedata") - ); match path.canonicalize() { Ok(target) => { server.ensure_allowed(&target, "iOS 工程内部路径")?; + if required { + anyhow::ensure!( + target.is_file(), + "iOS 工程必需文件不是普通文件: {}", + path.display() + ); + } if target.is_dir() { pending.push((target, depth + 1)); } @@ -1793,6 +1800,12 @@ fn validate_ios_bundle(server: &Arc, root: &Path) -> Result<()> { } Err(_) => {} } + } else if required { + anyhow::ensure!( + metadata.is_file(), + "iOS 工程必需文件不是普通文件: {}", + path.display() + ); } else if metadata.is_dir() { pending.push((path, depth + 1)); } @@ -1931,18 +1944,26 @@ fn validate_ios_cocoapods_paths(server: &Arc, project: &Path) -> Result< return Ok(()); } - for (path, label) in [ - (root.join("Podfile"), "CocoaPods Podfile"), - (root.join("podfile"), "CocoaPods podfile"), - (root.join("Podfile.lock"), "CocoaPods Podfile.lock"), - (root.join("Pods/Manifest.lock"), "CocoaPods Manifest.lock"), + for (path, label, regular_required) in [ + (root.join("Podfile"), "CocoaPods Podfile", false), + (root.join("podfile"), "CocoaPods podfile", false), + (root.join("Podfile.lock"), "CocoaPods Podfile.lock", true), + ( + root.join("Pods/Manifest.lock"), + "CocoaPods Manifest.lock", + true, + ), ( root.join("Pods/Pods.xcodeproj/project.pbxproj"), "CocoaPods Pods 工程", + false, ), ] { if path.exists() { - server.resolve_input(&path.to_string_lossy(), label)?; + let resolved = server.resolve_input(&path.to_string_lossy(), label)?; + if regular_required { + ensure_regular_file(&resolved, label)?; + } } } Ok(()) diff --git a/apps/shellsmith-mcp/tests/mcp_stdio.rs b/apps/shellsmith-mcp/tests/mcp_stdio.rs index 35a20b6..ee1d9e9 100644 --- a/apps/shellsmith-mcp/tests/mcp_stdio.rs +++ b/apps/shellsmith-mcp/tests/mcp_stdio.rs @@ -990,6 +990,132 @@ fn rejects_internal_workspace_parent_pbx_symlink() { assert_eq!(job["structuredContent"]["status"], "succeeded", "{job}"); } +#[cfg(unix)] +#[test] +fn rejects_non_regular_cocoapods_locks_without_blocking() { + let sample = 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 pods_project = sample.path().join("Pods/Pods.xcodeproj"); + fs::create_dir_all(&pods_project).unwrap(); + fs::write(pods_project.join("project.pbxproj"), b"// pods").unwrap(); + fs::write(sample.path().join("Podfile"), b"platform :ios\n").unwrap(); + let podfile_lock = sample.path().join("Podfile.lock"); + let manifest_lock = sample.path().join("Pods/Manifest.lock"); + fs::write(&manifest_lock, b"PODS:\n").unwrap(); + assert!(Command::new("mkfifo") + .arg(&podfile_lock) + .status() + .unwrap() + .success()); + let cli = write_fake_cli(sample.path()); + let config = workspace.join("ios.toml"); + fs::write( + &config, + format!( + "[project]\npath = \"{}\"\nscheme = \"Audit\"\nteam_id = \"ABCDEFGHIJ\"\nbundle_ids = [\"com.example.audit\"]\n[protection]\nprofile = \"compat\"\n", + workspace.display() + ), + ) + .unwrap(); + + let mut client = McpClient::start(&cli, &[sample.path()], &[]); + let started = Instant::now(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + let check_elapsed = started.elapsed(); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("不是普通文件"), "{check}"); + assert!( + check_elapsed < Duration::from_secs(5), + "FIFO 锁文件阻塞了检查: {check_elapsed:?}" + ); + + let started = Instant::now(); + let protect = client.call( + "shellsmith_protect_ios", + json!({ + "ios_config": config.display().to_string(), + "output": sample.path().join("out").display().to_string(), + "dry_run": true + }), + ); + assert_eq!(protect["isError"], true, "{protect}"); + assert!(text(&protect).contains("不是普通文件"), "{protect}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "FIFO 锁文件阻塞了保护预检" + ); + + fs::remove_file(&podfile_lock).unwrap(); + fs::write(&podfile_lock, b"PODS:\n").unwrap(); + fs::remove_file(&manifest_lock).unwrap(); + assert!(Command::new("mkfifo") + .arg(&manifest_lock) + .status() + .unwrap() + .success()); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("不是普通文件"), "{check}"); +} + +#[cfg(unix)] +#[test] +fn rejects_non_regular_child_project_pbx_without_blocking() { + let sample = tempfile::tempdir().unwrap(); + let workspace = sample.path().join("Audit.xcworkspace"); + fs::create_dir_all(&workspace).unwrap(); + let child = sample.path().join("Child.xcodeproj"); + fs::create_dir_all(&child).unwrap(); + assert!(Command::new("mkfifo") + .arg(child.join("project.pbxproj")) + .status() + .unwrap() + .success()); + let cli = write_fake_cli(sample.path()); + let config = workspace.join("ios.toml"); + fs::write( + &config, + format!( + "[project]\npath = \"{}\"\nscheme = \"Audit\"\nteam_id = \"ABCDEFGHIJ\"\nbundle_ids = [\"com.example.audit\"]\n[protection]\nprofile = \"compat\"\n", + workspace.display() + ), + ) + .unwrap(); + + let mut client = McpClient::start(&cli, &[sample.path()], &[]); + let started = Instant::now(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("不是普通文件"), "{check}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "FIFO 子工程 pbx 阻塞了检查" + ); + let protect = client.call( + "shellsmith_protect_ios", + json!({ + "ios_config": config.display().to_string(), + "output": sample.path().join("out").display().to_string(), + "dry_run": true + }), + ); + assert_eq!(protect["isError"], true, "{protect}"); + assert!(text(&protect).contains("不是普通文件"), "{protect}"); +} + #[cfg(unix)] #[test] fn rejects_non_regular_parent_pbx_without_blocking() { From ad9eb0a4591c0f7eb747b6be33f1dc6c6da016ca Mon Sep 17 00:00:00 2001 From: haonan Date: Fri, 13 Nov 2026 14:43:15 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E6=A0=A1=E9=AA=8C=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E5=86=99=E4=B8=8D=E6=95=8F=E6=84=9F=E6=96=87=E4=BB=B6=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=B8=8A=E7=9A=84=E5=BF=85=E9=9C=80=E5=B7=A5=E7=A8=8B?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bundle 扫描直接按 CLI 打开的小写路径检查 project.pbxproj/contents.xcworkspacedata - 大小写不敏感文件系统上大写目录项会被同一路径命中并校验普通文件类型 - 增加大写 FIFO pbx 回归测试,普通同名文件恢复后通过 --- apps/shellsmith-mcp/src/tools.rs | 11 +++++ apps/shellsmith-mcp/tests/mcp_stdio.rs | 66 ++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/apps/shellsmith-mcp/src/tools.rs b/apps/shellsmith-mcp/src/tools.rs index e22f889..435bd03 100644 --- a/apps/shellsmith-mcp/src/tools.rs +++ b/apps/shellsmith-mcp/src/tools.rs @@ -1760,6 +1760,17 @@ fn validate_ios_bundle(server: &Arc, root: &Path) -> Result<()> { continue; } visited.insert(directory.clone(), depth); + // 直接按 CLI 会打开的小写路径检查类型;大小写不敏感文件系统会命中大写目录项。 + for name in ["project.pbxproj", "contents.xcworkspacedata"] { + let required = directory.join(name); + if required.exists() { + let target = required + .canonicalize() + .with_context(|| format!("iOS 工程必需文件无法解析: {}", required.display()))?; + server.ensure_allowed(&target, "iOS 工程内部路径")?; + ensure_regular_file(&target, "iOS 工程必需文件")?; + } + } let children = fs::read_dir(&directory) .with_context(|| format!("读取 iOS 工程目录失败: {}", directory.display()))?; for child in children { diff --git a/apps/shellsmith-mcp/tests/mcp_stdio.rs b/apps/shellsmith-mcp/tests/mcp_stdio.rs index ee1d9e9..3d16a49 100644 --- a/apps/shellsmith-mcp/tests/mcp_stdio.rs +++ b/apps/shellsmith-mcp/tests/mcp_stdio.rs @@ -990,6 +990,72 @@ fn rejects_internal_workspace_parent_pbx_symlink() { assert_eq!(job["structuredContent"]["status"], "succeeded", "{job}"); } +#[cfg(unix)] +#[test] +fn rejects_uppercase_non_regular_pbx_on_case_insensitive_fs() { + let sample = tempfile::tempdir().unwrap(); + fs::write(sample.path().join("CaseProbe"), b"x").unwrap(); + if !sample.path().join("CASEPROBE").exists() { + return; + } + + let workspace = sample.path().join("Audit.xcworkspace"); + fs::create_dir_all(&workspace).unwrap(); + let child = sample.path().join("Child.xcodeproj"); + fs::create_dir_all(&child).unwrap(); + assert!(Command::new("mkfifo") + .arg(child.join("PROJECT.PBXPROJ")) + .status() + .unwrap() + .success()); + let cli = write_fake_cli(sample.path()); + let config = workspace.join("ios.toml"); + fs::write( + &config, + format!( + "[project]\npath = \"{}\"\nscheme = \"Audit\"\nteam_id = \"ABCDEFGHIJ\"\nbundle_ids = [\"com.example.audit\"]\n[protection]\nprofile = \"compat\"\n", + workspace.display() + ), + ) + .unwrap(); + + let mut client = McpClient::start(&cli, &[sample.path()], &[]); + let started = Instant::now(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("不是普通文件"), "{check}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "大写 FIFO pbx 阻塞了检查" + ); + let protect = client.call( + "shellsmith_protect_ios", + json!({ + "ios_config": config.display().to_string(), + "output": sample.path().join("out").display().to_string(), + "dry_run": true + }), + ); + assert_eq!(protect["isError"], true, "{protect}"); + assert!(text(&protect).contains("不是普通文件"), "{protect}"); + + // 同名普通文件恢复后,大小写不敏感文件系统上应能正常通过。 + fs::remove_file(child.join("PROJECT.PBXPROJ")).unwrap(); + fs::write( + child.join("PROJECT.PBXPROJ"), + b"PRODUCT_BUNDLE_IDENTIFIER = com.audit.case;", + ) + .unwrap(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(check["isError"], false, "{check}"); +} + #[cfg(unix)] #[test] fn rejects_non_regular_cocoapods_locks_without_blocking() { From 294ddee534b346083c0511ad6b39166127b8a272 Mon Sep 17 00:00:00 2001 From: haonan Date: Fri, 13 Nov 2026 17:20:50 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E8=AF=86=E5=88=AB=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E5=86=99=E4=B8=8D=E6=95=8F=E6=84=9F=E6=96=87=E4=BB=B6=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=B8=8A=E7=9A=84=E5=BF=85=E9=9C=80=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 必需文件按 CLI 打开路径使用 symlink_metadata 检查 - 大小写不敏感文件系统可命中大写目录项 - 失效的必需文件链接明确报错,非必需失效链接继续忽略 --- apps/shellsmith-mcp/src/tools.rs | 71 +++++++++++--------------- apps/shellsmith-mcp/tests/mcp_stdio.rs | 29 ++++++----- 2 files changed, 47 insertions(+), 53 deletions(-) diff --git a/apps/shellsmith-mcp/src/tools.rs b/apps/shellsmith-mcp/src/tools.rs index 435bd03..6e4a0b2 100644 --- a/apps/shellsmith-mcp/src/tools.rs +++ b/apps/shellsmith-mcp/src/tools.rs @@ -1760,15 +1760,30 @@ fn validate_ios_bundle(server: &Arc, root: &Path) -> Result<()> { continue; } visited.insert(directory.clone(), depth); - // 直接按 CLI 会打开的小写路径检查类型;大小写不敏感文件系统会命中大写目录项。 + // 使用 CLI 的读取路径,让文件系统匹配大小写,同时识别必需文件的失效链接。 for name in ["project.pbxproj", "contents.xcworkspacedata"] { - let required = directory.join(name); - if required.exists() { - let target = required - .canonicalize() - .with_context(|| format!("iOS 工程必需文件无法解析: {}", required.display()))?; + let path = directory.join(name); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(error).with_context(|| { + format!("读取 iOS 工程必需文件元数据失败: {}", path.display()) + }); + } + }; + if metadata.file_type().is_symlink() { + let target = path.canonicalize().with_context(|| { + format!("iOS 工程必需文件符号链接无法解析: {}", path.display()) + })?; server.ensure_allowed(&target, "iOS 工程内部路径")?; ensure_regular_file(&target, "iOS 工程必需文件")?; + } else { + anyhow::ensure!( + metadata.is_file(), + "iOS 工程必需文件不是普通文件: {}", + path.display() + ); } } let children = fs::read_dir(&directory) @@ -1782,42 +1797,18 @@ fn validate_ios_bundle(server: &Arc, root: &Path) -> Result<()> { root.display() ); let path = child.path(); - let required = matches!( - path.file_name().and_then(|value| value.to_str()), - Some("project.pbxproj" | "contents.xcworkspacedata") - ); - let metadata = fs::symlink_metadata(&path) + let file_type = child + .file_type() .with_context(|| format!("读取 iOS 工程路径失败: {}", path.display()))?; - if metadata.file_type().is_symlink() { - match path.canonicalize() { - Ok(target) => { - server.ensure_allowed(&target, "iOS 工程内部路径")?; - if required { - anyhow::ensure!( - target.is_file(), - "iOS 工程必需文件不是普通文件: {}", - path.display() - ); - } - if target.is_dir() { - pending.push((target, depth + 1)); - } - } - Err(error) if required => { - return Err(anyhow::Error::new(error).context(format!( - "iOS 工程必需文件符号链接无法解析: {}", - path.display() - ))); - } - Err(_) => {} + if file_type.is_symlink() { + let Ok(target) = path.canonicalize() else { + continue; + }; + server.ensure_allowed(&target, "iOS 工程内部路径")?; + if target.is_dir() { + pending.push((target, depth + 1)); } - } else if required { - anyhow::ensure!( - metadata.is_file(), - "iOS 工程必需文件不是普通文件: {}", - path.display() - ); - } else if metadata.is_dir() { + } else if file_type.is_dir() { pending.push((path, depth + 1)); } } diff --git a/apps/shellsmith-mcp/tests/mcp_stdio.rs b/apps/shellsmith-mcp/tests/mcp_stdio.rs index 3d16a49..8f9f3de 100644 --- a/apps/shellsmith-mcp/tests/mcp_stdio.rs +++ b/apps/shellsmith-mcp/tests/mcp_stdio.rs @@ -992,7 +992,9 @@ fn rejects_internal_workspace_parent_pbx_symlink() { #[cfg(unix)] #[test] -fn rejects_uppercase_non_regular_pbx_on_case_insensitive_fs() { +fn validates_uppercase_required_pbx_on_case_insensitive_fs() { + use std::os::unix::fs::symlink; + let sample = tempfile::tempdir().unwrap(); fs::write(sample.path().join("CaseProbe"), b"x").unwrap(); if !sample.path().join("CASEPROBE").exists() { @@ -1003,11 +1005,8 @@ fn rejects_uppercase_non_regular_pbx_on_case_insensitive_fs() { fs::create_dir_all(&workspace).unwrap(); let child = sample.path().join("Child.xcodeproj"); fs::create_dir_all(&child).unwrap(); - assert!(Command::new("mkfifo") - .arg(child.join("PROJECT.PBXPROJ")) - .status() - .unwrap() - .success()); + let pbx = child.join("PROJECT.PBXPROJ"); + assert!(Command::new("mkfifo").arg(&pbx).status().unwrap().success()); let cli = write_fake_cli(sample.path()); let config = workspace.join("ios.toml"); fs::write( @@ -1042,13 +1041,17 @@ fn rejects_uppercase_non_regular_pbx_on_case_insensitive_fs() { assert_eq!(protect["isError"], true, "{protect}"); assert!(text(&protect).contains("不是普通文件"), "{protect}"); - // 同名普通文件恢复后,大小写不敏感文件系统上应能正常通过。 - fs::remove_file(child.join("PROJECT.PBXPROJ")).unwrap(); - fs::write( - child.join("PROJECT.PBXPROJ"), - b"PRODUCT_BUNDLE_IDENTIFIER = com.audit.case;", - ) - .unwrap(); + fs::remove_file(&pbx).unwrap(); + symlink(sample.path().join("missing.pbxproj"), &pbx).unwrap(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("符号链接无法解析"), "{check}"); + + fs::remove_file(&pbx).unwrap(); + fs::write(&pbx, b"PRODUCT_BUNDLE_IDENTIFIER = com.audit.case;").unwrap(); let check = client.call( "shellsmith_check_ios", json!({"project": workspace.display().to_string()}),