From 970c48cb8267aa69caddf7cd49e97e36acbd5df7 Mon Sep 17 00:00:00 2001 From: haileymck Date: Thu, 7 May 2026 14:31:34 -0700 Subject: [PATCH 1/2] fix Navigating to /Blogs throws InvalidOperationException due to view folder mismatch after MVC scaffolding with EF --- .../EfControllerScaffolderBuilderExtensions.cs | 16 +++++++++++++++- .../dotnet-scaffold/AspNet/Helpers/ViewHelper.cs | 6 ++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/EfControllerScaffolderBuilderExtensions.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/EfControllerScaffolderBuilderExtensions.cs index d71586174b..c0a719d1b8 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/EfControllerScaffolderBuilderExtensions.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/EfControllerScaffolderBuilderExtensions.cs @@ -177,7 +177,21 @@ public static IScaffoldBuilder WithMvcViewsStep(this IScaffoldBuilder builder) //TODO add extensions if 'TemplateFoldersUtilities' is not reworked. var allT4TemplatePaths = new TemplateFoldersUtilities().GetAllT4TemplatesForTargetFramework(["Views"], viewModel.ProjectInfo.ProjectPath); - var viewTemplateProperties = ViewHelper.GetTextTemplatingProperties(allT4TemplatePaths, viewModel); + + // Use controller root name for view folder to match MVC conventions + // e.g. BlogsController -> /Views/Blogs/ + string? viewFolderName = null; + context.Properties.TryGetValue(nameof(EfControllerModel), out var efControllerModelObj); + if (efControllerModelObj is EfControllerModel efControllerModel) + { + var controllerName = efControllerModel.ControllerName; + const string controllerSuffix = "Controller"; + viewFolderName = controllerName.EndsWith(controllerSuffix, StringComparison.Ordinal) + ? controllerName.Substring(0, controllerName.Length - controllerSuffix.Length) + : controllerName; + } + + var viewTemplateProperties = ViewHelper.GetTextTemplatingProperties(allT4TemplatePaths, viewModel, viewFolderName); if (viewTemplateProperties.Any()) { step.TextTemplatingProperties = viewTemplateProperties; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/ViewHelper.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/ViewHelper.cs index 237575859f..ef0c06322e 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/ViewHelper.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/ViewHelper.cs @@ -40,8 +40,9 @@ internal class ViewHelper /// /// The collection of all T4 template paths. /// The view model containing project and model information. + /// An optional folder name for the view output path. If not provided, the model type name will be used. /// An enumerable collection of instances. - internal static IEnumerable GetTextTemplatingProperties(IEnumerable allT4TemplatePaths, ViewModel viewModel) + internal static IEnumerable GetTextTemplatingProperties(IEnumerable allT4TemplatePaths, ViewModel viewModel, string? viewFolderName = null) { var textTemplatingProperties = new List(); if (allT4TemplatePaths is not null && allT4TemplatePaths.Any()) @@ -57,7 +58,8 @@ internal static IEnumerable GetTextTemplatingProperties( continue; } - string baseOutputPath = GetBaseOutputPath(viewModel.ModelInfo.ModelTypeName, viewModel.ProjectInfo.ProjectPath); + string folderName = viewFolderName ?? viewModel.ModelInfo.ModelTypeName; + string baseOutputPath = GetBaseOutputPath(folderName, viewModel.ProjectInfo.ProjectPath); string outputFileName = Path.Combine(baseOutputPath, $"{templateName}{Common.Constants.ViewExtension}"); textTemplatingProperties.Add(new() { From 11011795937d6523014362cdf657bfbc69810972 Mon Sep 17 00:00:00 2001 From: haileymck Date: Tue, 19 May 2026 15:19:26 -0700 Subject: [PATCH 2/2] Address Copilot PR review comments for #3748 - Validate viewFolderName against path traversal (reject '.', '..', and names containing invalid path chars) before using it as a Views subfolder, falling back to ModelTypeName on rejection - Add unit tests: viewFolderName override uses the provided name, null falls back to ModelTypeName, invalid names (path traversal attempts) fall back to ModelTypeName --- .../AspNet/Helpers/ViewHelper.cs | 12 ++- .../AspNet/Helpers/ViewHelperTests.cs | 81 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/ViewHelper.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/ViewHelper.cs index ef0c06322e..b6c4cf555c 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/ViewHelper.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/ViewHelper.cs @@ -58,7 +58,9 @@ internal static IEnumerable GetTextTemplatingProperties( continue; } - string folderName = viewFolderName ?? viewModel.ModelInfo.ModelTypeName; + string folderName = viewFolderName is not null && IsValidFolderName(viewFolderName) + ? viewFolderName + : viewModel.ModelInfo.ModelTypeName; string baseOutputPath = GetBaseOutputPath(folderName, viewModel.ProjectInfo.ProjectPath); string outputFileName = Path.Combine(baseOutputPath, $"{templateName}{Common.Constants.ViewExtension}"); textTemplatingProperties.Add(new() @@ -155,4 +157,12 @@ private static string GetBaseOutputPath(string modelName, string? projectPath) string projectBasePath = Path.GetDirectoryName(projectPath) ?? Directory.GetCurrentDirectory(); return Path.Combine(projectBasePath, "Views", modelName); } + + private static bool IsValidFolderName(string name) + { + if (string.IsNullOrWhiteSpace(name)) return false; + if (name == "." || name == "..") return false; + if (name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) return false; + return true; + } } diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Helpers/ViewHelperTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Helpers/ViewHelperTests.cs index 84885a61ec..6d008c846c 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Helpers/ViewHelperTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Helpers/ViewHelperTests.cs @@ -305,4 +305,85 @@ public void IndexTemplate_Constant_HasExpectedValue() // Assert Assert.Equal("Index.tt", ViewHelper.IndexTemplate); } + + [Fact] + public void GetTextTemplatingProperties_WithViewFolderName_UsesViewFolderNameInOutputPath() + { + // Arrange + string projectPath = Path.Combine("test", "project", "TestProject.csproj"); + string templatePath = Path.Combine("templates", ViewHelper.CreateTemplate); + List templatePaths = [templatePath]; + + ViewModel viewModel = new ViewModel + { + PageType = "Create", + ModelInfo = new ModelInfo { ModelTypeName = "Blog" }, + ProjectInfo = new ProjectInfo(projectPath), + DbContextInfo = new DbContextInfo() + }; + + // Act + IEnumerable result = ViewHelper.GetTextTemplatingProperties(templatePaths, viewModel, "Blogs"); + + // Assert + TextTemplatingProperty? property = result.FirstOrDefault(); + Assert.NotNull(property); + Assert.Contains("Blogs", property.OutputPath); + Assert.DoesNotContain("Blog" + Path.DirectorySeparatorChar, property.OutputPath.Replace("Blogs", string.Empty)); + } + + [Fact] + public void GetTextTemplatingProperties_WithNullViewFolderName_FallsBackToModelTypeName() + { + // Arrange + string projectPath = Path.Combine("test", "project", "TestProject.csproj"); + string templatePath = Path.Combine("templates", ViewHelper.CreateTemplate); + List templatePaths = [templatePath]; + + ViewModel viewModel = new ViewModel + { + PageType = "Create", + ModelInfo = new ModelInfo { ModelTypeName = "Product" }, + ProjectInfo = new ProjectInfo(projectPath), + DbContextInfo = new DbContextInfo() + }; + + // Act + IEnumerable result = ViewHelper.GetTextTemplatingProperties(templatePaths, viewModel, null); + + // Assert + TextTemplatingProperty? property = result.FirstOrDefault(); + Assert.NotNull(property); + Assert.Contains("Product", property.OutputPath); + } + + [Theory] + [InlineData("..")] + [InlineData(".")] + [InlineData("Views/../secrets")] + [InlineData("foo/bar")] + [InlineData("foo\\bar")] + public void GetTextTemplatingProperties_WithInvalidViewFolderName_FallsBackToModelTypeName(string invalidFolderName) + { + // Arrange + string projectPath = Path.Combine("test", "project", "TestProject.csproj"); + string templatePath = Path.Combine("templates", ViewHelper.CreateTemplate); + List templatePaths = [templatePath]; + + ViewModel viewModel = new ViewModel + { + PageType = "Create", + ModelInfo = new ModelInfo { ModelTypeName = "Product" }, + ProjectInfo = new ProjectInfo(projectPath), + DbContextInfo = new DbContextInfo() + }; + + // Act + IEnumerable result = ViewHelper.GetTextTemplatingProperties(templatePaths, viewModel, invalidFolderName); + + // Assert + TextTemplatingProperty? property = result.FirstOrDefault(); + Assert.NotNull(property); + Assert.Contains("Product", property.OutputPath); + } }