标记 StringHelper.TrimStart 方法为过期,更新 PathHelper 中的路径处理逻辑,确保路径分隔符一致性,添加 PathHelperTests 中的相关测试用例何炳宏 authored at 2026-03-30 17:56:16 何炳宏 committed at 2026-03-30 17:58:03
diff --git a/NewLife.Core/Extension/StringHelper.cs b/NewLife.Core/Extension/StringHelper.cs
index f66beb1..25f5ae9 100644
--- a/NewLife.Core/Extension/StringHelper.cs
+++ b/NewLife.Core/Extension/StringHelper.cs
@@ -374,6 +374,7 @@ public static class StringHelper
/// <param name="str">当前字符串</param>
/// <param name="starts">前缀集合</param>
/// <returns>移除后的字符串</returns>
+ [Obsolete("已过期:此方法易与 ReadOnlySpan<char>.TrimStart(ReadOnlySpan<char>) 混淆——传入字符集合可能被当作要移除的字符集合而非前缀。请避免使用此方法进行按前缀截断,改为使用显式的前缀判断或其它明确方法。")]
public static String TrimStart(this String str, params String[] starts)
{
if (str.IsNullOrEmpty()) return str;
diff --git a/NewLife.Core/IO/PathHelper.cs b/NewLife.Core/IO/PathHelper.cs
index cfa071a..23927ce 100644
--- a/NewLife.Core/IO/PathHelper.cs
+++ b/NewLife.Core/IO/PathHelper.cs
@@ -505,9 +505,12 @@ public static class PathHelper
// 来源目录根,用于截断
var root = di.FullName.EnsureEnd(Path.DirectorySeparatorChar.ToString());
+ // 规范化根分隔符,确保和 FileInfo.FullName 使用相同分隔符
+ var normalizedRoot = Path.GetFullPath(root).Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar).EnsureEnd(Path.DirectorySeparatorChar.ToString());
foreach (var item in di.GetAllFiles(exts, allSub))
{
- var name = item.FullName.TrimStart(root).ToString();
+ var full = Path.GetFullPath(item.FullName).Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
+ var name = full.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase) ? full[normalizedRoot.Length..] : full;
var dst = destDirName.CombinePath(name);
callback?.Invoke(name);
item.CopyTo(dst.EnsureDirectory(true), true);
@@ -534,10 +537,12 @@ public static class PathHelper
// 目标目录根,用于截断
var root = dest.FullName.EnsureEnd(Path.DirectorySeparatorChar.ToString());
+ var normalizedRoot = Path.GetFullPath(root).Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar).EnsureEnd(Path.DirectorySeparatorChar.ToString());
// 遍历目标目录,拷贝同名文件
foreach (var item in dest.GetAllFiles(exts, allSub))
{
- var name = item.FullName.TrimStart(root).ToString();
+ var full = Path.GetFullPath(item.FullName).Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
+ var name = full.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase) ? full[normalizedRoot.Length..] : full;
var fi = di.FullName.CombinePath(name).AsFile();
//fi.CopyToIfNewer(item.FullName);
if (fi.Exists && item.Exists && fi.LastWriteTime > item.LastWriteTime)
diff --git a/NewLife.Core/Net/Upgrade.cs b/NewLife.Core/Net/Upgrade.cs
index acace05..f681f47 100644
--- a/NewLife.Core/Net/Upgrade.cs
+++ b/NewLife.Core/Net/Upgrade.cs
@@ -321,9 +321,11 @@ public class Upgrade
// 来源目录根,用于截断
var root = di.FullName.EnsureEnd(Path.DirectorySeparatorChar.ToString());
+ var normalizedRoot = Path.GetFullPath(root).Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar).EnsureEnd(Path.DirectorySeparatorChar.ToString());
foreach (var item in di.GetAllFiles(null, true))
{
- var name = item.FullName.TrimStart(root);
+ var full = Path.GetFullPath(item.FullName).Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
+ var name = full.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase) ? full[normalizedRoot.Length..] : full;
var dst = dest.CombinePath(name).GetBasePath();
// 如果是应用配置文件,不要更新
diff --git a/XUnitTest.Core/IO/PathHelperTests.cs b/XUnitTest.Core/IO/PathHelperTests.cs
index 54bd10e..b806035 100644
--- a/XUnitTest.Core/IO/PathHelperTests.cs
+++ b/XUnitTest.Core/IO/PathHelperTests.cs
@@ -1,4 +1,7 @@
-using NewLife;
+using System;
+using System.IO;
+using System.Linq;
+using NewLife;
using Xunit;
namespace XUnitTest.IO;
@@ -18,6 +21,92 @@ public class PathHelperTests
return paths.Any(File.Exists);
}
+ [Fact(DisplayName = "CopyTo 应该复制目录内所有文件并保持相对路径")]
+ public void CopyTo_CopiesFilesWithRelativePaths()
+ {
+ var tmp = Path.Combine(Path.GetTempPath(), "NewLife_Test_CopyTo_" + Guid.NewGuid().ToString("N"));
+ var src = Path.Combine(tmp, "src");
+ var dst = Path.Combine(tmp, "dst");
+
+ try
+ {
+ Directory.CreateDirectory(src);
+ Directory.CreateDirectory(dst);
+
+ // 创建文件和子目录
+ var f1 = Path.Combine(src, "a.txt");
+ File.WriteAllText(f1, "hello1");
+
+ var sub = Path.Combine(src, "sub");
+ Directory.CreateDirectory(sub);
+ var f2 = Path.Combine(sub, "b.txt");
+ File.WriteAllText(f2, "hello2");
+
+ var di = new DirectoryInfo(src);
+ var res = di.CopyTo(dst, null, true);
+
+ // 断言返回的目标路径存在且数量为2
+ Assert.NotNull(res);
+ Assert.Equal(2, res.Length);
+
+ foreach (var r in res)
+ {
+ Assert.True(File.Exists(r), $"目标文件不存在: {r}");
+ // 目标路径必须在 dst 根下
+ Assert.StartsWith(Path.GetFullPath(dst).TrimEnd(Path.DirectorySeparatorChar), Path.GetFullPath(r), StringComparison.OrdinalIgnoreCase);
+ }
+
+ // 内容验证
+ var dst1 = Path.Combine(dst, "a.txt");
+ var dst2 = Path.Combine(dst, "sub", "b.txt");
+ Assert.Equal("hello1", File.ReadAllText(dst1));
+ Assert.Equal("hello2", File.ReadAllText(dst2));
+ }
+ finally
+ {
+ try { Directory.Delete(tmp, true); } catch { }
+ }
+ }
+
+ [Fact(DisplayName = "CopyTo 不应被 TrimStart 风格的错误截断影响(示例 C:/proj)")]
+ public void CopyTo_DoesNotManglePath_WhenRootContainsChars()
+ {
+ // 演示如果使用 TrimStart(root.ToCharArray()) 对字符串做前缀移除,会把字符集当作集合,导致错误截断
+ var simRoot = "C:/proj";
+ var simFull = "C:/proj/projDir/1.txt";
+ var broken = simFull.TrimStart(simRoot.ToCharArray());
+ Assert.Equal("Dir/1.txt", broken);
+
+ // 真实文件系统测试,确保我们的 CopyTo 不会产生上述错误
+ var tmp = Path.Combine(Path.GetTempPath(), "NewLife_Test_CopyToPrefix2_" + Guid.NewGuid().ToString("N"));
+ var src = Path.Combine(tmp, "C_proj");
+ var dst = Path.Combine(tmp, "dst");
+
+ try
+ {
+ var nested = Path.Combine(src, "projDir");
+ Directory.CreateDirectory(nested);
+ Directory.CreateDirectory(dst);
+
+ var f = Path.Combine(nested, "1.txt");
+ File.WriteAllText(f, "ok");
+
+ var di = new DirectoryInfo(src);
+ var res = di.CopyTo(dst, null, true);
+
+ // 找到目标文件路径
+ var expectedRel = Path.Combine("projDir", "1.txt");
+ var found = res.FirstOrDefault(p => p.EndsWith(expectedRel, StringComparison.OrdinalIgnoreCase));
+ Assert.NotNull(found);
+ Assert.True(File.Exists(found));
+ Assert.Equal("ok", File.ReadAllText(found));
+ }
+ finally
+ {
+ try { Directory.Delete(tmp, true); } catch { }
+ }
+ }
+
[Fact]
public void BasePath()
{