NewLife/X

标记 StringHelper.TrimStart 方法为过期,更新 PathHelper 中的路径处理逻辑,确保路径分隔符一致性,添加 PathHelperTests 中的相关测试用例
何炳宏 authored at 2026-03-30 17:56:16 何炳宏 committed at 2026-03-30 17:58:03
c72d7e3
Tree
1 Parent(s) 2eb5ac8
Summary: 4 changed files with 101 additions and 4 deletions.
Modified +1 -0
Modified +7 -2
Modified +3 -1
Modified +90 -1
Modified +1 -0
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;
Modified +7 -2
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)
Modified +3 -1
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();
 
             // 如果是应用配置文件,不要更新
Modified +90 -1
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()
     {