diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ViewEnginePath.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ViewEnginePath.cs index a77cd5882a..5d0b79e827 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ViewEnginePath.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ViewEnginePath.cs @@ -11,9 +11,24 @@ namespace Microsoft.AspNetCore.Mvc.Internal { public static class ViewEnginePath { + public static readonly char[] PathSeparators = new[] { '/', '\\' }; private const string CurrentDirectoryToken = "."; private const string ParentDirectoryToken = ".."; - private static readonly char[] _pathSeparators = new[] { '/', '\\' }; + private static readonly string[] TokensRequiringNormalization = new string[] + { + // ./ + CurrentDirectoryToken + PathSeparators[0], + // .\ + CurrentDirectoryToken + PathSeparators[1], + // ../ + ParentDirectoryToken + PathSeparators[0], + // ..\ + ParentDirectoryToken + PathSeparators[1], + // // + "" + PathSeparators[0] + PathSeparators[0], + // \\ + "" + PathSeparators[1] + PathSeparators[1], + }; public static string CombinePath(string first, string second) { @@ -42,18 +57,18 @@ public static string CombinePath(string first, string second) result = first.Substring(0, index + 1) + second; } - return ResolvePath(result); + return NormalizePath(result); } - public static string ResolvePath(string path) + public static string NormalizePath(string path) { - if (!RequiresPathResolution(path)) + if (!RequiresPathNormalization(path)) { return path; } var pathSegments = new List(); - var tokenizer = new StringTokenizer(path, _pathSeparators); + var tokenizer = new StringTokenizer(path, PathSeparators); foreach (var segment in tokenizer) { if (segment.Length == 0) @@ -93,10 +108,17 @@ public static string ResolvePath(string path) return builder.ToString(); } - private static bool RequiresPathResolution(string path) + private static bool RequiresPathNormalization(string path) { - return path.IndexOf(ParentDirectoryToken, StringComparison.Ordinal) != -1 || - path.IndexOf(CurrentDirectoryToken, StringComparison.Ordinal) != -1; + for (var i = 0; i < TokensRequiringNormalization.Length; i++) + { + if (path.IndexOf(TokensRequiringNormalization[i]) != -1) + { + return true; + } + } + + return false; } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs index ad0ec5e1cf..6875569186 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs @@ -142,7 +142,7 @@ public RazorPageResult GetPage(string executingFilePath, string pagePath) throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(pagePath)); } - if (!(IsApplicationRelativePath(pagePath) || IsRelativePath(pagePath))) + if (!LooksLikePath(pagePath)) { // Not a path this method can handle. return new RazorPageResult(pagePath, Enumerable.Empty()); @@ -191,7 +191,7 @@ public ViewEngineResult GetView(string executingFilePath, string viewPath, bool throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(viewPath)); } - if (!(IsApplicationRelativePath(viewPath) || IsRelativePath(viewPath))) + if (!LooksLikePath(viewPath)) { // Not a path this method can handle. return ViewEngineResult.NotFound(viewPath, Enumerable.Empty()); @@ -205,8 +205,7 @@ private ViewLocationCacheResult LocatePageFromPath(string executingFilePath, str { var applicationRelativePath = GetAbsolutePath(executingFilePath, pagePath); var cacheKey = new ViewLocationCacheKey(applicationRelativePath, isMainPage); - ViewLocationCacheResult cacheResult; - if (!ViewLookupCache.TryGetValue(cacheKey, out cacheResult)) + if (!ViewLookupCache.TryGetValue(cacheKey, out ViewLocationCacheResult cacheResult)) { var expirationTokens = new HashSet(); cacheResult = CreateCacheResult(expirationTokens, applicationRelativePath, isMainPage); @@ -300,11 +299,19 @@ public string GetAbsolutePath(string executingFilePath, string pagePath) if (IsApplicationRelativePath(pagePath)) { - // An absolute path already; no change required. - return pagePath; + // An absolute path already; no need to concat it with executingFilePath. + return ViewEnginePath.NormalizePath(pagePath); } - if (!IsRelativePath(pagePath)) + if (pagePath.IndexOfAny(ViewEnginePath.PathSeparators) != -1) + { + // A name that looks like a path (e.g. Shared/_Partial). Add an extension to it and treat it like one. + if (!pagePath.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase)) + { + pagePath = pagePath + ViewExtension; + } + } + else if (!IsRelativePath(pagePath)) { // A page name; no change required. return pagePath; @@ -316,7 +323,7 @@ public string GetAbsolutePath(string executingFilePath, string pagePath) // path relative to currently-executing view, if any. // Not yet executing a view. Start in app root. var absolutePath = "/" + pagePath; - return ViewEnginePath.ResolvePath(absolutePath); + return ViewEnginePath.NormalizePath(absolutePath); } return ViewEnginePath.CombinePath(executingFilePath, pagePath); @@ -492,5 +499,27 @@ private static bool IsRelativePath(string name) // Though ./ViewName looks like a relative path, framework searches for that view using view locations. return name.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase); } + + private static bool LooksLikePath(string name) + { + Debug.Assert(!string.IsNullOrEmpty(name)); + + if (IsApplicationRelativePath(name)) + { + return true; + } + + if (IsRelativePath(name)) + { + return true; + } + + if (name.IndexOfAny(ViewEnginePath.PathSeparators) != -1) + { + return true; + } + + return false; + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs index d8c8ac6328..c831a287e3 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs @@ -493,5 +493,18 @@ public async Task ViewEngine_NormalizesPathsReturnedByViewLocationExpanders() // Assert Assert.Equal(expected, responseContent.Trim()); } + + [Fact] + public async Task ViewEngine_ResolvesPathsWithSlashesThatDoNotHaveExtensions() + { + // Arrange + var expected = @"Hello from EmbeddedHome\EmbeddedPartial"; + + // Act + var responseContent = await Client.GetStringAsync("/EmbeddedViews/RelativeNonPath"); + + // Assert + Assert.Equal(expected, responseContent.Trim()); + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs b/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs index 3c8d4350ec..b450de3677 100644 --- a/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs @@ -24,7 +24,7 @@ using Moq; using Xunit; -namespace Microsoft.AspNetCore.Mvc.Razor.Test +namespace Microsoft.AspNetCore.Mvc.Razor { public class RazorViewEngineTest { @@ -1486,13 +1486,10 @@ public void GetPage_ResolvesRelativeToAppRoot_WithRelativePath_IfNoPageExecuting [InlineData(null, null)] [InlineData(null, "")] [InlineData(null, "Page")] - [InlineData(null, "Folder/Page")] - [InlineData(null, "Folder1/Folder2/Page")] [InlineData("/Home/Index.cshtml", null)] [InlineData("/Home/Index.cshtml", "")] [InlineData("/Home/Index.cshtml", "Page")] - [InlineData("/Home/Index.cshtml", "Folder/Page")] - [InlineData("/Home/Index.cshtml", "Folder1/Folder2/Page")] + public void GetAbsolutePath_ReturnsPagePathUnchanged_IfNotAPath(string executingFilePath, string pagePath) { // Arrange @@ -1505,6 +1502,23 @@ public void GetAbsolutePath_ReturnsPagePathUnchanged_IfNotAPath(string executing Assert.Same(pagePath, result); } + [Theory] + [InlineData(null, "Folder/Page", "/Folder/Page.cshtml")] + [InlineData(null, "Folder1/Folder2/Page", "/Folder1/Folder2/Page.cshtml")] + [InlineData("/Home/Index.cshtml", "Folder/Page", "/Home/Folder/Page.cshtml")] + [InlineData("/Home/Index.cshtml", "Folder1/Folder2/Page", "/Home/Folder1/Folder2/Page.cshtml")] + public void GetAbsolutePath_ResolvesNamesThatLookLikePaths(string executingFilePath, string pagePath, string expected) + { + // Arrange + var viewEngine = CreateViewEngine(); + + // Act + var result = viewEngine.GetAbsolutePath(executingFilePath, pagePath); + + // Assert + Assert.Equal(expected, result); + } + [Theory] [InlineData("/Views/Home/Index.cshtml", "../Shared/_Partial.cshtml")] [InlineData("/Views/Home/Index.cshtml", "..\\Shared\\_Partial.cshtml")] diff --git a/test/WebSites/RazorWebSite/Controllers/EmbeddedViewsController.cs b/test/WebSites/RazorWebSite/Controllers/EmbeddedViewsController.cs index 33837ea026..98914bad7d 100644 --- a/test/WebSites/RazorWebSite/Controllers/EmbeddedViewsController.cs +++ b/test/WebSites/RazorWebSite/Controllers/EmbeddedViewsController.cs @@ -8,5 +8,6 @@ namespace RazorWebSite.Controllers public class EmbeddedViewsController : Controller { public IActionResult Index() => View("/Views/EmbeddedHome/Index.cshtml"); + public IActionResult RelativeNonPath() => View("/Views/EmbeddedHome/RelativeNonPath.cshtml"); } } diff --git a/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedHome/EmbeddedPartial.cshtml b/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedHome/EmbeddedPartial.cshtml new file mode 100644 index 0000000000..cebe57816a --- /dev/null +++ b/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedHome/EmbeddedPartial.cshtml @@ -0,0 +1 @@ +Hello from EmbeddedHome\EmbeddedPartial \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedHome/RelativeNonPath.cshtml b/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedHome/RelativeNonPath.cshtml new file mode 100644 index 0000000000..1aca712209 --- /dev/null +++ b/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedHome/RelativeNonPath.cshtml @@ -0,0 +1,2 @@ +@{ Layout = "../EmbeddedShared/_Layout"; } +@Html.Partial("./EmbeddedPartial") \ No newline at end of file