diff --git a/src/Servers/IIS/AspNetCoreModuleV2/AspNetCore/applicationinfo.cpp b/src/Servers/IIS/AspNetCoreModuleV2/AspNetCore/applicationinfo.cpp index 93ef956c00b2..d9c925dc85a1 100644 --- a/src/Servers/IIS/AspNetCoreModuleV2/AspNetCore/applicationinfo.cpp +++ b/src/Servers/IIS/AspNetCoreModuleV2/AspNetCore/applicationinfo.cpp @@ -84,6 +84,8 @@ APPLICATION_INFO::CreateApplication(IHttpContext& pHttpContext) return S_OK; } + std::wstring pExceptionMessage; + try { const WebConfigConfigurationSource configurationSource(m_pServer.GetAdminManager(), pHttpApplication); @@ -138,29 +140,43 @@ APPLICATION_INFO::CreateApplication(IHttpContext& pHttpContext) } return S_OK; } - catch (const ConfigurationLoadException &ex) - { - EventLog::Error( - ASPNETCORE_CONFIGURATION_LOAD_ERROR, - ASPNETCORE_CONFIGURATION_LOAD_ERROR_MSG, - ex.get_message().c_str()); - } catch (...) { OBSERVE_CAUGHT_EXCEPTION(); + pExceptionMessage = CaughtExceptionToString(); EventLog::Error( ASPNETCORE_CONFIGURATION_LOAD_ERROR, ASPNETCORE_CONFIGURATION_LOAD_ERROR_MSG, - L""); + pExceptionMessage.c_str()); } + ErrorContext errorContext; + errorContext.statusCode = 500i16; + errorContext.subStatusCode = 30i16; + errorContext.generalErrorType = "ASP.NET Core app failed to start - An exception was thrown during startup"; + if (GetLastError() == ERROR_ACCESS_DENIED) + { + errorContext.errorReason = "Ensure the application pool process model has write permissions to the shadow copy directory"; + } + // TODO: Depend on show detailed errors or nah? + errorContext.detailedErrorContent = to_multi_byte_string(pExceptionMessage, CP_UTF8); + auto page = ANCM_ERROR_PAGE; + + + auto responseContent = FILE_UTILITY::GetHtml(g_hServerModule, + page, + errorContext.statusCode, + errorContext.subStatusCode, + errorContext.generalErrorType, + errorContext.errorReason, + errorContext.detailedErrorContent); m_pApplication = make_application( pHttpApplication, E_FAIL, false /* disableStartupPage */, - "" /* responseContent */, - 500i16 /* statusCode */, - 0i16 /* subStatusCode */, + responseContent /* responseContent */, + errorContext.statusCode /* statusCode */, + errorContext.subStatusCode/* subStatusCode */, "Internal Server Error"); return S_OK; @@ -193,7 +209,26 @@ APPLICATION_INFO::TryCreateApplication(IHttpContext& pHttpContext, const ShimOpt } } - auto shadowCopyPath = HandleShadowCopy(options, pHttpContext); + std::filesystem::path shadowCopyPath; + + // Only support shadow copying for IIS. + if (options.QueryShadowCopyEnabled() && !m_pServer.IsCommandLineLaunch()) + { + try + { + shadowCopyPath = HandleShadowCopy(options, pHttpContext, error); + } + catch (...) + { + OBSERVE_CAUGHT_EXCEPTION(); + throw; + } + + if (shadowCopyPath.empty()) + { + return E_FAIL; + } + } RETURN_IF_FAILED(m_handlerResolver.GetApplicationFactory(*pHttpContext.GetApplication(), shadowCopyPath, m_pApplicationFactory, options, error)); LOG_INFO(L"Creating handler application"); @@ -275,74 +310,116 @@ APPLICATION_INFO::ShutDownApplication(const bool fServerInitiated) * we will start a thread that deletes all other folders in that directory. */ std::filesystem::path -APPLICATION_INFO::HandleShadowCopy(const ShimOptions& options, IHttpContext& pHttpContext) +APPLICATION_INFO::HandleShadowCopy(const ShimOptions& options, IHttpContext& pHttpContext, ErrorContext& error) { - std::filesystem::path shadowCopyPath; + std::filesystem::path shadowCopyPath = options.QueryShadowCopyDirectory(); + std::wstring physicalPath = pHttpContext.GetApplication()->GetApplicationPhysicalPath(); - // Only support shadow copying for IIS. - if (options.QueryShadowCopyEnabled() && !m_pServer.IsCommandLineLaunch()) + // Make shadow copy path absolute. + if (!shadowCopyPath.is_absolute()) { - shadowCopyPath = options.QueryShadowCopyDirectory(); - std::wstring physicalPath = pHttpContext.GetApplication()->GetApplicationPhysicalPath(); - - // Make shadow copy path absolute. - if (!shadowCopyPath.is_absolute()) - { - shadowCopyPath = std::filesystem::absolute(std::filesystem::path(physicalPath) / shadowCopyPath); - } + shadowCopyPath = std::filesystem::absolute(std::filesystem::path(physicalPath) / shadowCopyPath); + } - // The shadow copy directory itself isn't copied to directly. - // Instead subdirectories with numerically increasing names are created. - // This is because on shutdown, the app itself will still have all dlls loaded, - // meaning we can't copy to the same subdirectory. Therefore, on shutdown, - // we create a directory that is one larger than the previous largest directory number. - auto directoryName = 0; - std::string directoryNameStr = "0"; - auto shadowCopyBaseDirectory = std::filesystem::directory_entry(shadowCopyPath); - if (!shadowCopyBaseDirectory.exists()) + // The shadow copy directory itself isn't copied to directly. + // Instead subdirectories with numerically increasing names are created. + // This is because on shutdown, the app itself will still have all dlls loaded, + // meaning we can't copy to the same subdirectory. Therefore, on shutdown, + // we create a directory that is one larger than the previous largest directory number. + auto directoryName = 0; + std::string directoryNameStr = "0"; + auto shadowCopyBaseDirectory = std::filesystem::directory_entry(shadowCopyPath); + if (!shadowCopyBaseDirectory.exists()) + { + auto ret = CreateDirectory(shadowCopyBaseDirectory.path().wstring().c_str(), nullptr); + if (!ret) { - CreateDirectory(shadowCopyBaseDirectory.path().wstring().c_str(), nullptr); + auto pathString = to_multi_byte_string(shadowCopyBaseDirectory.path(), CP_UTF8); + auto errorCode = std::error_code(GetLastError(), std::system_category()); + std::string errorMessage = format("Failed to create shadow copy base directory %s. Error: %s", + pathString.c_str(), + errorCode.message().c_str()); + + // TODO: Better substatus code + error.statusCode = 500i16; + error.subStatusCode = 30i16; + error.generalErrorType = format("ASP.NET Core app failed to start - Failed to copy to shadow copy directory"); + error.errorReason = format("Ensure the application pool process model has write permissions for the shadow copy base directory %s", + pathString.c_str()); + error.detailedErrorContent = errorMessage; + return std::wstring(); } + } - for (auto& entry : std::filesystem::directory_iterator(shadowCopyPath)) + for (auto& entry : std::filesystem::directory_iterator(shadowCopyPath)) + { + if (entry.is_directory()) { - if (entry.is_directory()) + try { - try - { - auto tempDirName = entry.path().filename().string(); - int intFileName = std::stoi(tempDirName); - if (intFileName > directoryName) - { - directoryName = intFileName; - directoryNameStr = tempDirName; - } - } - catch (...) + auto tempDirName = entry.path().filename().string(); + int intFileName = std::stoi(tempDirName); + if (intFileName > directoryName) { - OBSERVE_CAUGHT_EXCEPTION(); - // Ignore any folders that can't be converted to an int. + directoryName = intFileName; + directoryNameStr = tempDirName; } } + catch (...) + { + OBSERVE_CAUGHT_EXCEPTION(); + // Ignore any folders that can't be converted to an int. + } } + } - int copiedFileCount = 0; + int copiedFileCount = 0; - shadowCopyPath = shadowCopyPath / directoryNameStr; - LOG_INFOF(L"Copying to shadow copy directory %ls.", shadowCopyPath.c_str()); + shadowCopyPath = shadowCopyPath / directoryNameStr; + LOG_INFOF(L"Copying from %ls to shadow copy directory %ls.", physicalPath.c_str(), shadowCopyPath.c_str()); + + // Avoid using canonical for shadowCopyBaseDirectory + // It could expand to a network drive, or an expanded link folder path + // We already made it an absolute path relative to the physicalPath above + try { + // CopyToDirectory will succeed or throw exception, so return value can be ignored + Environment::CopyToDirectory(physicalPath, shadowCopyPath, options.QueryCleanShadowCopyDirectory(), shadowCopyBaseDirectory.path(), copiedFileCount); + } + catch (const std::system_error& ex) + { + auto exWideString = to_wide_string(ex.what(), CP_ACP); - // Avoid using canonical for shadowCopyBaseDirectory - // It could expand to a network drive, or an expanded link folder path - // We already made it an absolute path relative to the physicalPath above - HRESULT hr = Environment::CopyToDirectory(physicalPath, shadowCopyPath, options.QueryCleanShadowCopyDirectory(), shadowCopyBaseDirectory.path(), copiedFileCount); + std::wstring logMessage = format(L"Failed to copy files from %s to shadow copy directory %s. Error: %s", + physicalPath.c_str(), + shadowCopyPath.c_str(), + exWideString.c_str()); - LOG_INFOF(L"Finished copying %d files to shadow copy directory %ls.", copiedFileCount, shadowCopyBaseDirectory.path().c_str()); + LOG_ERRORF(L"%ls", logMessage.c_str()); + EventLog::Error(ASPNETCORE_CONFIGURATION_LOAD_ERROR, + ASPNETCORE_CONFIGURATION_LOAD_ERROR_MSG, + logMessage.c_str()); - if (hr != S_OK) + // TODO: Better substatus code + error.statusCode = 500i16; + error.subStatusCode = 30i16; + + std::string errorMessage = "Failed to copy to shadow copy directory"; + auto exceptionCode = ex.code().value(); + if (exceptionCode == ERROR_ACCESS_DENIED || exceptionCode == ERROR_PATH_NOT_FOUND) { - return std::wstring(); + errorMessage = "No permissions to shadow copy directory"; + } + + error.generalErrorType = format("ASP.NET Core app failed to start - %s", errorMessage.c_str()); + error.errorReason = format("Ensure the application pool process model has write permissions to the shadow copy directory %ls", + shadowCopyPath.c_str()); + if (options.QueryShowDetailedErrors()) + { + error.detailedErrorContent = ex.what(); } + return std::wstring(); } + LOG_INFOF(L"Finished copying %d files to shadow copy directory %ls.", copiedFileCount, shadowCopyBaseDirectory.path().c_str()); return shadowCopyPath; } diff --git a/src/Servers/IIS/AspNetCoreModuleV2/AspNetCore/applicationinfo.h b/src/Servers/IIS/AspNetCoreModuleV2/AspNetCore/applicationinfo.h index a54107eb8bee..4869a74b7124 100644 --- a/src/Servers/IIS/AspNetCoreModuleV2/AspNetCore/applicationinfo.h +++ b/src/Servers/IIS/AspNetCoreModuleV2/AspNetCore/applicationinfo.h @@ -80,7 +80,7 @@ class APPLICATION_INFO: NonCopyable TryCreateApplication(IHttpContext& pHttpContext, const ShimOptions& options, ErrorContext& error); std::filesystem::path - HandleShadowCopy(const ShimOptions& options, IHttpContext& pHttpContext); + HandleShadowCopy(const ShimOptions& options, IHttpContext& pHttpContext, ErrorContext& error); IHttpServer &m_pServer; HandlerResolver &m_handlerResolver; diff --git a/src/Servers/IIS/AspNetCoreModuleV2/CommonLib/Environment.cpp b/src/Servers/IIS/AspNetCoreModuleV2/CommonLib/Environment.cpp index 8112de09b6e6..acb18fef5b68 100644 --- a/src/Servers/IIS/AspNetCoreModuleV2/CommonLib/Environment.cpp +++ b/src/Servers/IIS/AspNetCoreModuleV2/CommonLib/Environment.cpp @@ -168,7 +168,23 @@ void Environment::CopyToDirectoryInner(const std::filesystem::path& source, cons auto destinationDirEntry = std::filesystem::directory_entry(destination); if (!destinationDirEntry.exists()) { - CreateDirectory(destination.wstring().c_str(), nullptr); + auto ret = CreateDirectory(destination.wstring().c_str(), nullptr); + if (!ret) + { + // TODO: macro this or not? + std::string msg = format("Failed to create destination directory: %s (source: %s)", + destination.string().c_str(), + source.string().c_str()); + auto ex = std::system_error(GetLastError(), std::system_category(), msg); + try { + throw ex; + } + catch (...) + { + OBSERVE_CAUGHT_EXCEPTION(); + throw; + } + } } for (auto& path : std::filesystem::directory_iterator(source)) @@ -187,13 +203,29 @@ void Environment::CopyToDirectoryInner(const std::filesystem::path& source, cons continue; } } - + auto sourcePathString = path.path().wstring(); + auto destinationPathString = destinationPath.wstring(); + auto ret = CopyFile(sourcePathString.c_str(), destinationPathString.c_str(), FALSE); + if (!ret) + { + std::string msg = format("Failed to copy file %s to %s", + sourcePathString.c_str(), + destinationPathString.c_str()); + auto ex = std::system_error(GetLastError(), std::system_category(), msg); + try { + throw ex; + } + catch (...) + { + OBSERVE_CAUGHT_EXCEPTION(); + throw; + } + } copiedFileCount++; - CopyFile(path.path().wstring().c_str(), destinationPath.wstring().c_str(), FALSE); } else if (path.is_directory()) { - auto sourceInnerDirectory = path.path(); + auto& sourceInnerDirectory = path.path(); if (sourceInnerDirectory.wstring().rfind(directoryToIgnore, 0) != 0) { diff --git a/src/Servers/IIS/AspNetCoreModuleV2/CommonLib/exceptions.h b/src/Servers/IIS/AspNetCoreModuleV2/CommonLib/exceptions.h index f4cd95c7f07d..3a8ec5a06630 100644 --- a/src/Servers/IIS/AspNetCoreModuleV2/CommonLib/exceptions.h +++ b/src/Servers/IIS/AspNetCoreModuleV2/CommonLib/exceptions.h @@ -9,6 +9,7 @@ #include "debugutil.h" #include "StringHelpers.h" #include "InvalidOperationException.h" +#include "ConfigurationLoadException.h" #include "ntassert.h" #include "NonCopyable.h" #include "EventTracing.h" @@ -197,6 +198,11 @@ __declspec(noinline) inline HRESULT CaughtExceptionHResult(LOCATION_ARGUMENTS_ON ReportException(LOCATION_CALL exception); return exception.GetResult(); } + catch (const ConfigurationLoadException& exception) + { + ReportException(LOCATION_CALL exception); + return HRESULT_FROM_WIN32(ERROR_UNHANDLED_EXCEPTION); + } catch (const InvalidOperationException& exception) { ReportException(LOCATION_CALL exception); @@ -224,6 +230,10 @@ __declspec(noinline) inline std::wstring CaughtExceptionToString() { return exception.as_wstring(); } + catch (const ConfigurationLoadException& exception) + { + return exception.get_message(); + } catch (const std::system_error& exception) { return to_wide_string(exception.what(), CP_ACP); diff --git a/src/Servers/IIS/IIS/test/IIS.ShadowCopy.Tests/ShadowCopyTests.cs b/src/Servers/IIS/IIS/test/IIS.ShadowCopy.Tests/ShadowCopyTests.cs index 386579b54b18..dcadf7a71476 100644 --- a/src/Servers/IIS/IIS/test/IIS.ShadowCopy.Tests/ShadowCopyTests.cs +++ b/src/Servers/IIS/IIS/test/IIS.ShadowCopy.Tests/ShadowCopyTests.cs @@ -1,29 +1,97 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; +using System.Security.AccessControl; +using System.Security.Principal; using Microsoft.AspNetCore.InternalTesting; using Microsoft.AspNetCore.Server.IIS.FunctionalTests.Utilities; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Server.IIS.FunctionalTests; [Collection(PublishedSitesCollection.Name)] -public class ShadowCopyTests : IISFunctionalTestBase +public class ShadowCopyTests(PublishedSitesFixture fixture) : IISFunctionalTestBase(fixture) { - public ShadowCopyTests(PublishedSitesFixture fixture) : base(fixture) + + public bool IsDirectoryEmpty(string path) { + return !Directory.EnumerateFileSystemEntries(path).Any(); + } + + [ConditionalFact] + public async Task ShadowCopy_FailsWithUsefulExceptionMessage_WhenNoPermissionsToShadowCopyDirectory() + { + // Arrange + using var shadowCopyDirectory = TempDirectory.CreateWithNoPermissions(Logger); + var deploymentParameters = Fixture.GetBaseDeploymentParameters(); + deploymentParameters.HandlerSettings["enableShadowCopy"] = "true"; + deploymentParameters.HandlerSettings["shadowCopyDirectory"] = shadowCopyDirectory.DirectoryPath; + + var deploymentResult = await DeployAsync(deploymentParameters); + + // Act + var response = await deploymentResult.HttpClient.GetAsync("Wow!"); + + // Assert + Assert.Equal(System.Net.HttpStatusCode.InternalServerError, response.StatusCode); + Assert.True(response.Content.Headers.ContentLength > 0); + Assert.Equal("text/html", response.Content.Headers.ContentType.MediaType); + + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Access is denied", content, StringComparison.InvariantCultureIgnoreCase); + Assert.Contains(shadowCopyDirectory.DirectoryPath, content, StringComparison.InvariantCultureIgnoreCase); + + shadowCopyDirectory.RestoreAllPermissions(); + // If failed to copy then the shadowCopyDirectory should be empty + Assert.True(IsDirectoryEmpty(shadowCopyDirectory.DirectoryPath), "Expected shadow copy directory to be empty"); + } + + [ConditionalFact] + public async Task ShadowCopy_FailsWithUsefulExceptionMessage_WhenNoWritePermissionsToShadowCopyDirectory() + { + // Arrange + using var shadowCopyDirectory = TempDirectory.CreateWithNoWritePermissions(Logger); + var deploymentParameters = Fixture.GetBaseDeploymentParameters(); + deploymentParameters.HandlerSettings["enableShadowCopy"] = "true"; + deploymentParameters.HandlerSettings["shadowCopyDirectory"] = shadowCopyDirectory.DirectoryPath; + + var deploymentResult = await DeployAsync(deploymentParameters); + + // Act + var response = await deploymentResult.HttpClient.GetAsync("Wow!"); + + // Assert + Assert.Equal(System.Net.HttpStatusCode.InternalServerError, response.StatusCode); + Assert.True(response.Content.Headers.ContentLength > 0); + Assert.Equal("text/html", response.Content.Headers.ContentType.MediaType); + + var content = await response.Content.ReadAsStringAsync(); + Assert.True(content.Contains("Failed to create destination directory: ", StringComparison.InvariantCultureIgnoreCase) || + content.Contains("Failed to copy file ", StringComparison.InvariantCultureIgnoreCase), + "Expected exception message for failure to copy to shadow copy directory"); + Assert.Contains(shadowCopyDirectory.DirectoryPath, content, StringComparison.InvariantCultureIgnoreCase); + + shadowCopyDirectory.RestoreAllPermissions(); + // If failed to copy then the shadowCopyDirectory should be empty + Assert.True(IsDirectoryEmpty(shadowCopyDirectory.DirectoryPath), "Expected shadow copy directory to be empty"); } [ConditionalFact] public async Task ShadowCopyDoesNotLockFiles() { - using var directory = TempDirectory.Create(); + // Arrange + using var shadowCopyDirectory = TempDirectory.Create(); var deploymentParameters = Fixture.GetBaseDeploymentParameters(); deploymentParameters.HandlerSettings["enableShadowCopy"] = "true"; - deploymentParameters.HandlerSettings["shadowCopyDirectory"] = directory.DirectoryPath; + deploymentParameters.HandlerSettings["shadowCopyDirectory"] = shadowCopyDirectory.DirectoryPath; var deploymentResult = await DeployAsync(deploymentParameters); + + // Act var response = await deploymentResult.HttpClient.GetAsync("Wow!"); + // Assert Assert.True(response.IsSuccessStatusCode); var directoryInfo = new DirectoryInfo(deploymentResult.ContentRoot); @@ -42,14 +110,18 @@ public async Task ShadowCopyDoesNotLockFiles() [ConditionalFact] public async Task ShadowCopyRelativeInSameDirectoryWorks() { - var directoryName = Path.GetRandomFileName(); + // Arrange + var shadowCopyDirectoryName = Path.GetRandomFileName(); var deploymentParameters = Fixture.GetBaseDeploymentParameters(); deploymentParameters.HandlerSettings["enableShadowCopy"] = "true"; - deploymentParameters.HandlerSettings["shadowCopyDirectory"] = directoryName; + deploymentParameters.HandlerSettings["shadowCopyDirectory"] = shadowCopyDirectoryName; var deploymentResult = await DeployAsync(deploymentParameters); + + // Act var response = await deploymentResult.HttpClient.GetAsync("Wow!"); + // Assert Assert.True(response.IsSuccessStatusCode); var directoryInfo = new DirectoryInfo(deploymentResult.ContentRoot); @@ -59,7 +131,7 @@ public async Task ShadowCopyRelativeInSameDirectoryWorks() fileInfo.Delete(); } - var tempDirectoryPath = Path.Combine(deploymentResult.ContentRoot, directoryName); + var tempDirectoryPath = Path.Combine(deploymentResult.ContentRoot, shadowCopyDirectoryName); foreach (var dirInfo in directoryInfo.GetDirectories()) { if (!tempDirectoryPath.Equals(dirInfo.FullName)) @@ -72,18 +144,22 @@ public async Task ShadowCopyRelativeInSameDirectoryWorks() [ConditionalFact] public async Task ShadowCopyRelativeOutsideDirectoryWorks() { - using var directory = TempDirectory.Create(); + // Arrange + using var shadowCopyDirectory = TempDirectory.Create(); var deploymentParameters = Fixture.GetBaseDeploymentParameters(); deploymentParameters.HandlerSettings["enableShadowCopy"] = "true"; - deploymentParameters.HandlerSettings["shadowCopyDirectory"] = $"..\\{directory.DirectoryInfo.Name}"; - deploymentParameters.ApplicationPath = directory.DirectoryPath; + deploymentParameters.HandlerSettings["shadowCopyDirectory"] = $"..\\{shadowCopyDirectory.DirectoryInfo.Name}"; + deploymentParameters.ApplicationPath = shadowCopyDirectory.DirectoryPath; var deploymentResult = await DeployAsync(deploymentParameters); + + // Act var response = await deploymentResult.HttpClient.GetAsync("Wow!"); - // Check if directory can be deleted. + // Check if content root shadowCopyDirectory can be deleted. // Can't delete the folder but can delete all content in it. + // Assert Assert.True(response.IsSuccessStatusCode); var directoryInfo = new DirectoryInfo(deploymentResult.ContentRoot); @@ -105,17 +181,22 @@ public async Task ShadowCopyRelativeOutsideDirectoryWorks() [ConditionalFact] public async Task ShadowCopySingleFileChangedWorks() { - using var directory = TempDirectory.Create(); + // Arrange + using var shadowCopyDirectory = TempDirectory.Create(); var deploymentParameters = Fixture.GetBaseDeploymentParameters(); deploymentParameters.HandlerSettings["enableShadowCopy"] = "true"; - deploymentParameters.HandlerSettings["shadowCopyDirectory"] = directory.DirectoryPath; + deploymentParameters.HandlerSettings["shadowCopyDirectory"] = shadowCopyDirectory.DirectoryPath; var deploymentResult = await DeployAsync(deploymentParameters); - DirectoryCopy(deploymentResult.ContentRoot, directory.DirectoryPath, copySubDirs: true); + DirectoryCopy(deploymentResult.ContentRoot, shadowCopyDirectory.DirectoryPath, copySubDirs: true); + // Act var response = await deploymentResult.HttpClient.GetAsync("Wow!"); + // Assert Assert.True(response.IsSuccessStatusCode); + + // Arrange // Rewrite file var dirInfo = new DirectoryInfo(deploymentResult.ContentRoot); @@ -129,8 +210,9 @@ public async Task ShadowCopySingleFileChangedWorks() } } var fileContents = File.ReadAllBytes(dllPath); - File.WriteAllBytes(dllPath, fileContents); + // Act & Assert + File.WriteAllBytes(dllPath, fileContents); await deploymentResult.AssertRecycledAsync(); response = await deploymentResult.HttpClient.GetAsync("Wow!"); @@ -140,10 +222,11 @@ public async Task ShadowCopySingleFileChangedWorks() [ConditionalFact] public async Task ShadowCopyDeleteFolderDuringShutdownWorks() { - using var directory = TempDirectory.Create(); + // Arrange + using var shadowCopyDirectory = TempDirectory.Create(); var deploymentParameters = Fixture.GetBaseDeploymentParameters(); deploymentParameters.HandlerSettings["enableShadowCopy"] = "true"; - deploymentParameters.HandlerSettings["shadowCopyDirectory"] = directory.DirectoryPath; + deploymentParameters.HandlerSettings["shadowCopyDirectory"] = shadowCopyDirectory.DirectoryPath; var deploymentResult = await DeployAsync(deploymentParameters); var deleteDirPath = Path.Combine(deploymentResult.ContentRoot, "wwwroot/deletethis"); @@ -156,12 +239,15 @@ public async Task ShadowCopyDeleteFolderDuringShutdownWorks() AddAppOffline(deploymentResult.ContentRoot); await AssertAppOffline(deploymentResult); + // Act + // Delete folder + file after app is shut down // Testing specific path on startup where we compare the app directory contents with the shadow copy directory Directory.Delete(deleteDirPath, recursive: true); RemoveAppOffline(deploymentResult.ContentRoot); + // Assert await deploymentResult.AssertRecycledAsync(); response = await deploymentResult.HttpClient.GetAsync("Wow!"); @@ -171,23 +257,29 @@ public async Task ShadowCopyDeleteFolderDuringShutdownWorks() [ConditionalFact] public async Task ShadowCopyE2EWorksWithFolderPresent() { - using var directory = TempDirectory.Create(); + // Arrange + using var shadowCopyDirectory = TempDirectory.Create(); var deploymentParameters = Fixture.GetBaseDeploymentParameters(); deploymentParameters.HandlerSettings["enableShadowCopy"] = "true"; - deploymentParameters.HandlerSettings["shadowCopyDirectory"] = directory.DirectoryPath; + deploymentParameters.HandlerSettings["shadowCopyDirectory"] = shadowCopyDirectory.DirectoryPath; var deploymentResult = await DeployAsync(deploymentParameters); - DirectoryCopy(deploymentResult.ContentRoot, Path.Combine(directory.DirectoryPath, "0"), copySubDirs: true); + DirectoryCopy(deploymentResult.ContentRoot, Path.Combine(shadowCopyDirectory.DirectoryPath, "0"), copySubDirs: true); + // Act var response = await deploymentResult.HttpClient.GetAsync("Wow!"); + + // Assert Assert.True(response.IsSuccessStatusCode); + // Arrange using var secondTempDir = TempDirectory.Create(); // copy back and forth to cause file change notifications. DirectoryCopy(deploymentResult.ContentRoot, secondTempDir.DirectoryPath, copySubDirs: true); DirectoryCopy(secondTempDir.DirectoryPath, deploymentResult.ContentRoot, copySubDirs: true); + // Act & Assert await deploymentResult.AssertRecycledAsync(); response = await deploymentResult.HttpClient.GetAsync("Wow!"); @@ -197,6 +289,7 @@ public async Task ShadowCopyE2EWorksWithFolderPresent() [ConditionalFact] public async Task ShadowCopyE2EWorksWithOldFoldersPresent() { + // Arrange using var directory = TempDirectory.Create(); var deploymentParameters = Fixture.GetBaseDeploymentParameters(); deploymentParameters.HandlerSettings["enableShadowCopy"] = "true"; @@ -206,15 +299,20 @@ public async Task ShadowCopyE2EWorksWithOldFoldersPresent() // Start with 1 to exercise the incremental logic DirectoryCopy(deploymentResult.ContentRoot, Path.Combine(directory.DirectoryPath, "1"), copySubDirs: true); + // Act var response = await deploymentResult.HttpClient.GetAsync("Wow!"); + + // Assert Assert.True(response.IsSuccessStatusCode); + // Arrange using var secondTempDir = TempDirectory.Create(); // copy back and forth to cause file change notifications. DirectoryCopy(deploymentResult.ContentRoot, secondTempDir.DirectoryPath, copySubDirs: true); DirectoryCopy(secondTempDir.DirectoryPath, deploymentResult.ContentRoot, copySubDirs: true); + // Act & Assert response = await deploymentResult.HttpClient.GetAsync("Wow!"); Assert.False(Directory.Exists(Path.Combine(directory.DirectoryPath, "0")), "Expected 0 shadow copy directory to be skipped"); @@ -227,8 +325,9 @@ public async Task ShadowCopyE2EWorksWithOldFoldersPresent() // This shutdown should trigger a copy to the next highest directory, which will be 2 await deploymentResult.AssertRecycledAsync(); - Assert.True(Directory.Exists(Path.Combine(directory.DirectoryPath, "2")), "Expected 2 shadow copy directory"); + Assert.True(Directory.Exists(Path.Combine(directory.DirectoryPath, "2")), "Expected 2 shadow copy directory to exist"); + // Act & Assert response = await deploymentResult.HttpClient.GetAsync("Wow!"); Assert.True(response.IsSuccessStatusCode); } @@ -237,28 +336,32 @@ public async Task ShadowCopyE2EWorksWithOldFoldersPresent() [QuarantinedTest("https://github.com/dotnet/aspnetcore/issues/58106")] public async Task ShadowCopyCleansUpOlderFolders() { - using var directory = TempDirectory.Create(); + // Arrange + using var shadowCopyDirectory = TempDirectory.Create(); var deploymentParameters = Fixture.GetBaseDeploymentParameters(); deploymentParameters.HandlerSettings["enableShadowCopy"] = "true"; - deploymentParameters.HandlerSettings["shadowCopyDirectory"] = directory.DirectoryPath; + deploymentParameters.HandlerSettings["shadowCopyDirectory"] = shadowCopyDirectory.DirectoryPath; var deploymentResult = await DeployAsync(deploymentParameters); // Start with a bunch of junk - DirectoryCopy(deploymentResult.ContentRoot, Path.Combine(directory.DirectoryPath, "1"), copySubDirs: true); - DirectoryCopy(deploymentResult.ContentRoot, Path.Combine(directory.DirectoryPath, "3"), copySubDirs: true); - DirectoryCopy(deploymentResult.ContentRoot, Path.Combine(directory.DirectoryPath, "10"), copySubDirs: true); + DirectoryCopy(deploymentResult.ContentRoot, Path.Combine(shadowCopyDirectory.DirectoryPath, "1"), copySubDirs: true); + DirectoryCopy(deploymentResult.ContentRoot, Path.Combine(shadowCopyDirectory.DirectoryPath, "3"), copySubDirs: true); + DirectoryCopy(deploymentResult.ContentRoot, Path.Combine(shadowCopyDirectory.DirectoryPath, "10"), copySubDirs: true); + // Act & Assert var response = await deploymentResult.HttpClient.GetAsync("Wow!"); Assert.True(response.IsSuccessStatusCode); + // Arrange using var secondTempDir = TempDirectory.Create(); // copy back and forth to cause file change notifications. DirectoryCopy(deploymentResult.ContentRoot, secondTempDir.DirectoryPath, copySubDirs: true); DirectoryCopy(secondTempDir.DirectoryPath, deploymentResult.ContentRoot, copySubDirs: true); + // Act & Assert response = await deploymentResult.HttpClient.GetAsync("Wow!"); - Assert.False(Directory.Exists(Path.Combine(directory.DirectoryPath, "0")), "Expected 0 shadow copy directory to be skipped"); + Assert.False(Directory.Exists(Path.Combine(shadowCopyDirectory.DirectoryPath, "0")), "Expected 0 shadow copy directory to be skipped"); // Depending on timing, this could result in a shutdown failure, but sometimes it succeeds, handle both situations if (!response.IsSuccessStatusCode) @@ -269,36 +372,42 @@ public async Task ShadowCopyCleansUpOlderFolders() // This shutdown should trigger a copy to the next highest directory, which will be 11 await deploymentResult.AssertRecycledAsync(); - Assert.True(Directory.Exists(Path.Combine(directory.DirectoryPath, "11")), "Expected 11 shadow copy directory"); + Assert.True(Directory.Exists(Path.Combine(shadowCopyDirectory.DirectoryPath, "11")), "Expected 11 shadow copy directory"); + // Act & Assert response = await deploymentResult.HttpClient.GetAsync("Wow!"); Assert.True(response.IsSuccessStatusCode); // Verify old directories were cleaned up - Assert.False(Directory.Exists(Path.Combine(directory.DirectoryPath, "1")), "Expected 1 shadow copy directory to be deleted"); - Assert.False(Directory.Exists(Path.Combine(directory.DirectoryPath, "3")), "Expected 3 shadow copy directory to be deleted"); + Assert.False(Directory.Exists(Path.Combine(shadowCopyDirectory.DirectoryPath, "1")), "Expected 1 shadow copy directory to be deleted"); + Assert.False(Directory.Exists(Path.Combine(shadowCopyDirectory.DirectoryPath, "3")), "Expected 3 shadow copy directory to be deleted"); } [ConditionalFact] public async Task ShadowCopyIgnoresItsOwnDirectoryWithRelativePathSegmentWhenCopying() { - using var directory = TempDirectory.Create(); + // Arrange + using var shadowCopyDirectory = TempDirectory.Create(); var deploymentParameters = Fixture.GetBaseDeploymentParameters(); deploymentParameters.HandlerSettings["enableShadowCopy"] = "true"; deploymentParameters.HandlerSettings["shadowCopyDirectory"] = "./ShadowCopy/../ShadowCopy/"; var deploymentResult = await DeployAsync(deploymentParameters); - DirectoryCopy(deploymentResult.ContentRoot, Path.Combine(directory.DirectoryPath, "0"), copySubDirs: true); + DirectoryCopy(deploymentResult.ContentRoot, Path.Combine(shadowCopyDirectory.DirectoryPath, "0"), copySubDirs: true); + // Act & Assert var response = await deploymentResult.HttpClient.GetAsync("Wow!"); Assert.True(response.IsSuccessStatusCode); + // Arrange using var secondTempDir = TempDirectory.Create(); + // Act // copy back and forth to cause file change notifications. DirectoryCopy(deploymentResult.ContentRoot, secondTempDir.DirectoryPath, copySubDirs: true); DirectoryCopy(secondTempDir.DirectoryPath, deploymentResult.ContentRoot, copySubDirs: true, ignoreDirectory: "ShadowCopy"); + // Assert await deploymentResult.AssertRecycledAsync(); Assert.True(Directory.Exists(Path.Combine(deploymentResult.ContentRoot, "ShadowCopy"))); @@ -312,15 +421,19 @@ public async Task ShadowCopyIgnoresItsOwnDirectoryWithRelativePathSegmentWhenCop [ConditionalFact] public async Task ShadowCopyIgnoresItsOwnDirectoryWhenCopying() { - using var directory = TempDirectory.Create(); + // Arrange + using var shadowCopyDirectory = TempDirectory.Create(); var deploymentParameters = Fixture.GetBaseDeploymentParameters(); deploymentParameters.HandlerSettings["enableShadowCopy"] = "true"; deploymentParameters.HandlerSettings["shadowCopyDirectory"] = "./ShadowCopy"; var deploymentResult = await DeployAsync(deploymentParameters); - DirectoryCopy(deploymentResult.ContentRoot, Path.Combine(directory.DirectoryPath, "0"), copySubDirs: true); + DirectoryCopy(deploymentResult.ContentRoot, Path.Combine(shadowCopyDirectory.DirectoryPath, "0"), copySubDirs: true); + // Act var response = await deploymentResult.HttpClient.GetAsync("Wow!"); + + // Assert Assert.True(response.IsSuccessStatusCode); using var secondTempDir = TempDirectory.Create(); @@ -339,8 +452,113 @@ public async Task ShadowCopyIgnoresItsOwnDirectoryWhenCopying() Assert.True(response.IsSuccessStatusCode); } + public static int RunCommand(string command, string arguments, ILogger logger, string logPrefix = null) + { + // TODO: Move somewhere else helper class + var startInfo = new ProcessStartInfo + { + FileName = command, + Arguments = arguments, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true + }; + using var process = new Process { StartInfo = startInfo }; + process.StartAndCaptureOutAndErrToLogger(logPrefix ?? command, logger); + process.WaitForExit(TimeSpan.FromSeconds(5)); + return process.ExitCode; + } + + internal static void RemoveAllPermissions(DirectoryInfo directoryInfo) + { + var directorySecurity = directoryInfo.GetAccessControl(); + + // Take ownership before removing permissions + var currentUser = WindowsIdentity.GetCurrent().User; + directorySecurity.SetOwner(currentUser); + directoryInfo.SetAccessControl(directorySecurity); + + // Remove all existing permissions + var emptyPermissions = new DirectorySecurity(); + emptyPermissions.SetOwner(currentUser); + emptyPermissions.SetAccessRuleProtection(true, false); // Disable inheritance, remove all ACEs + directoryInfo.SetAccessControl(emptyPermissions); + } + + internal static void RemoveWritePermissions(DirectoryInfo directoryInfo) + { + var directorySecurity = directoryInfo.GetAccessControl(); + // Deny Write access for Everyone + var everyone = new SecurityIdentifier(WellKnownSidType.WorldSid, null); + + var denyRule = new FileSystemAccessRule( + everyone, + FileSystemRights.Write | FileSystemRights.Delete, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, + AccessControlType.Deny); + + directorySecurity.AddAccessRule(denyRule); + directoryInfo.SetAccessControl(directorySecurity); + } + + public class TempDirectoryRestrictedPermissions : TempDirectory + { + private bool _hasPermissions; + + protected ILogger Logger { get; init; } + + public TempDirectoryRestrictedPermissions(DirectoryInfo directoryInfo, ILogger logger, bool canRead) : base(directoryInfo) + { + Logger = logger; + if (canRead) + { + RemoveWritePermissions(directoryInfo); + } + else + { + RemoveAllPermissions(directoryInfo); + } + } + + public void RestoreAllPermissions() + { + if (_hasPermissions) + { + return; + } + + RestoreAllPermissionsInner(); + + _hasPermissions = true; + } + + private void RestoreAllPermissionsInner() + { + var res = RunCommand("takeown", $"/F \"{DirectoryPath}\" /R /D Y", Logger, "Takeown1"); + res += RunCommand("icacls", $"\"{DirectoryPath}\" /grant Administrators:F /T", Logger, "Takeown2"); + res += RunCommand("icacls", $"\"{DirectoryPath}\" /inheritance:e /T", Logger, "Takeown3"); + res += RunCommand("icacls", $"\"{DirectoryPath}\" /reset /T", Logger, "Takeown4"); + + if (res != 0) + { + Logger.LogError("Failed to restore permissions for directory {DirectoryPath}. Takeown result: {TakeownResult}", DirectoryPath, res); + } + } + + public override void Dispose() + { + RestoreAllPermissions(); + base.Dispose(); + } + } + public class TempDirectory : IDisposable { + public string DirectoryPath { get; } + public DirectoryInfo DirectoryInfo { get; } + public static TempDirectory Create() { var directoryPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); @@ -348,17 +566,27 @@ public static TempDirectory Create() return new TempDirectory(directoryInfo); } - public TempDirectory(DirectoryInfo directoryInfo) + public static TempDirectoryRestrictedPermissions CreateWithNoPermissions(ILogger logger) { - DirectoryInfo = directoryInfo; + var directoryPath = Path.Combine(Path.GetTempPath(), $"ShadowCopy{Path.GetRandomFileName()}"); + var directoryInfo = Directory.CreateDirectory(directoryPath); + return new TempDirectoryRestrictedPermissions(directoryInfo, logger, false); + } - DirectoryPath = directoryInfo.FullName; + public static TempDirectoryRestrictedPermissions CreateWithNoWritePermissions(ILogger logger) + { + var directoryPath = Path.Combine(Path.GetTempPath(), $"ShadowCopy{Path.GetRandomFileName()}"); + var directoryInfo = Directory.CreateDirectory(directoryPath); + return new TempDirectoryRestrictedPermissions(directoryInfo, logger, true); } - public string DirectoryPath { get; } - public DirectoryInfo DirectoryInfo { get; } + public TempDirectory(DirectoryInfo directoryInfo) + { + DirectoryPath = directoryInfo.FullName; + DirectoryInfo = directoryInfo; + } - public void Dispose() + public virtual void Dispose() { DeleteDirectory(DirectoryPath); } diff --git a/src/Servers/IIS/IntegrationTesting.IIS/src/IISDeployer.cs b/src/Servers/IIS/IntegrationTesting.IIS/src/IISDeployer.cs index f3db8e12c866..129b68ba28d1 100644 --- a/src/Servers/IIS/IntegrationTesting.IIS/src/IISDeployer.cs +++ b/src/Servers/IIS/IntegrationTesting.IIS/src/IISDeployer.cs @@ -86,10 +86,9 @@ public override Task DeployAsync() // For now, only support using published output DeploymentParameters.PublishApplicationBeforeDeployment = true; // Move ASPNETCORE_DETAILEDERRORS to web config env variables - if (IISDeploymentParameters.EnvironmentVariables.ContainsKey(DetailedErrorsEnvironmentVariable)) + if (IISDeploymentParameters.EnvironmentVariables.TryGetValue(DetailedErrorsEnvironmentVariable, out var value)) { - IISDeploymentParameters.WebConfigBasedEnvironmentVariables[DetailedErrorsEnvironmentVariable] = - IISDeploymentParameters.EnvironmentVariables[DetailedErrorsEnvironmentVariable]; + IISDeploymentParameters.WebConfigBasedEnvironmentVariables[DetailedErrorsEnvironmentVariable] = value; IISDeploymentParameters.EnvironmentVariables.Remove(DetailedErrorsEnvironmentVariable); }