diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt new file mode 100644 index 0000000000..21f25610a0 --- /dev/null +++ b/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,18 @@ +.NET System Web Adapters uses third-party libraries or other resources that may be +distributed under licenses different than the .NET System Web Adapters software. + +In the event that we accidentally failed to list a required notice, please +bring it to our attention. Post an issue or email us: + + dotnet@microsoft.com + +The attached notices are provided for information only. + +License notice for .NET Framework +------------------------------- + +Copyright (c) Microsoft Corporation +Licenses under the MIT License + +Available at +https://github.com/microsoft/referencesource/blob/master/LICENSE.txt \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/ExcludedAttributes.txt b/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/ExcludedAttributes.txt new file mode 100644 index 0000000000..4437f6a625 --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/ExcludedAttributes.txt @@ -0,0 +1 @@ +T:System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/GenerateApis.targets b/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/GenerateApis.targets index 1d9eb01a8d..8a80ea65e8 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/GenerateApis.targets +++ b/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/GenerateApis.targets @@ -8,9 +8,10 @@ <__Generate Condition="$(GenerateTypeForwards) OR $(GenerateStandard)">true $(MSBuildThisFileDirectory)\Header.txt + $(MSBuildThisFileDirectory)\ExcludedAttributes.txt $(MSBuildThisFileDirectory)\TypeForwards.Framework.cs $(MSBuildThisFileDirectory)\Ref.Standard.cs - -excludeApiList $(MSBuildThisFileDirectory)ExcludedApis.txt -throw "$(PlatformNotSupportedMessage)" -HeaderFile "$(GenAPIHeaderFile)" + -excludeApiList $(MSBuildThisFileDirectory)ExcludedApis.txt -throw "$(PlatformNotSupportedMessage)" -HeaderFile "$(GenAPIHeaderFile)" -excludeAttributesList "$(GenAPIExcludedAttributes)" $(GenAPIAdditionalParameters) -writer TypeForwards diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/Ref.Standard.cs b/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/Ref.Standard.cs index 08dd233207..92dc75546b 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/Ref.Standard.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/Ref.Standard.cs @@ -442,6 +442,22 @@ public enum ReadEntityBodyMode Classic = 1, None = 0, } + public static partial class VirtualPathUtility + { + public static string AppendTrailingSlash(string virtualPath) { throw new System.PlatformNotSupportedException("Only supported when running on ASP.NET Core or System.Web");} + public static string Combine(string basePath, string relativePath) { throw new System.PlatformNotSupportedException("Only supported when running on ASP.NET Core or System.Web");} + public static string GetDirectory(string virtualPath) { throw new System.PlatformNotSupportedException("Only supported when running on ASP.NET Core or System.Web");} + public static string GetExtension(string virtualPath) { throw new System.PlatformNotSupportedException("Only supported when running on ASP.NET Core or System.Web");} + public static string GetFileName(string virtualPath) { throw new System.PlatformNotSupportedException("Only supported when running on ASP.NET Core or System.Web");} + public static bool IsAbsolute(string virtualPath) { throw new System.PlatformNotSupportedException("Only supported when running on ASP.NET Core or System.Web");} + public static bool IsAppRelative(string virtualPath) { throw new System.PlatformNotSupportedException("Only supported when running on ASP.NET Core or System.Web");} + public static string MakeRelative(string fromPath, string toPath) { throw new System.PlatformNotSupportedException("Only supported when running on ASP.NET Core or System.Web");} + public static string RemoveTrailingSlash(string virtualPath) { throw new System.PlatformNotSupportedException("Only supported when running on ASP.NET Core or System.Web");} + public static string ToAbsolute(string virtualPath) { throw new System.PlatformNotSupportedException("Only supported when running on ASP.NET Core or System.Web");} + public static string ToAbsolute(string virtualPath, string applicationPath) { throw new System.PlatformNotSupportedException("Only supported when running on ASP.NET Core or System.Web");} + public static string ToAppRelative(string virtualPath) { throw new System.PlatformNotSupportedException("Only supported when running on ASP.NET Core or System.Web");} + public static string ToAppRelative(string virtualPath, string applicationPath) { throw new System.PlatformNotSupportedException("Only supported when running on ASP.NET Core or System.Web");} + } } namespace System.Web.Caching { diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/TypeForwards.Framework.cs b/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/TypeForwards.Framework.cs index f0e0cf2c6d..92d6700e46 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/TypeForwards.Framework.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters/Generated/TypeForwards.Framework.cs @@ -42,6 +42,7 @@ [assembly:System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Web.HttpUnhandledException))] [assembly:System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Web.ISubscriptionToken))] [assembly:System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Web.ReadEntityBodyMode))] +[assembly:System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Web.VirtualPathUtility))] [assembly:System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Web.Caching.Cache))] [assembly:System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Web.Caching.CacheDependency))] [assembly:System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Web.Caching.CacheItemPriority))] diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters/Microsoft.AspNetCore.SystemWebAdapters.csproj b/src/Microsoft.AspNetCore.SystemWebAdapters/Microsoft.AspNetCore.SystemWebAdapters.csproj index 15b817bdee..0f16c1b999 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters/Microsoft.AspNetCore.SystemWebAdapters.csproj +++ b/src/Microsoft.AspNetCore.SystemWebAdapters/Microsoft.AspNetCore.SystemWebAdapters.csproj @@ -33,7 +33,6 @@ - diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters/Utilities/StringUtil.cs b/src/Microsoft.AspNetCore.SystemWebAdapters/Utilities/StringUtil.cs new file mode 100644 index 0000000000..e2b14f68d7 --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters/Utilities/StringUtil.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Web.Util; + +/// +/// Provides utility methods for string operations. +/// +/// +/// Portions of this code are based on code originally Copyright (c) 1999 Microsoft Corporation +/// System.Web.Util.StringUtil at https://github.com/microsoft/referencesource/blob/master/System.Web/Util/StringUtil.cs +/// These files are released under an MIT licence according to https://github.com/microsoft/referencesource#license +/// +internal static class StringUtil +{ + /* + * Determines if the first string starts with the second string, ignoring case. + * Fast, non-culture aware. + */ + internal static bool StringStartsWithIgnoreCase(string s1, string s2) + { + if (string.IsNullOrEmpty(s1) || string.IsNullOrEmpty(s2)) return false; + if (s2.Length > s1.Length) return false; + return s1.StartsWith(s2, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters/Utilities/UrlPath.cs b/src/Microsoft.AspNetCore.SystemWebAdapters/Utilities/UrlPath.cs new file mode 100644 index 0000000000..73a34401ab --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters/Utilities/UrlPath.cs @@ -0,0 +1,511 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace System.Web.Util; + +/// +/// Provides utility methods for handling url and virtual paths. +/// +/// +/// Portions of this code are based on code originally Copyright (c) 1999 Microsoft Corporation +/// System.Web.Util.UrlPath code at https://github.com/microsoft/referencesource/blob/master/System.Web/Util/UrlPath.cs +/// These files are released under an MIT licence according to https://github.com/microsoft/referencesource#license +/// +internal static class UrlPath +{ + internal const char AppRelativeCharacter = '~'; + internal const string AppRelativeCharacterString = "~/"; + private const string Invalid_vpath = "'{0}' is not a valid virtual path."; + private const string Physical_path_not_allowed = "'{0}' is a physical path, but a virtual path was expected."; + private const string Cannot_exit_up_top_directory = "Cannot use a leading .. to exit above the top directory."; + internal const string Path_must_be_rooted = "The virtual path '{0}' is not rooted."; + + internal static bool HasTrailingSlash(string virtualPath) => virtualPath[^1] == '/'; + + internal static bool IsRooted(string basepath) => string.IsNullOrEmpty(basepath) || basepath[0] == '/' || basepath[0] == '\\'; + + // Checks if virtual path contains a protocol, which is referred to as a scheme in the + // URI spec. + private static bool HasScheme(string virtualPath) + { + // URIs have the format :, e.g. mailto:user@ms.com, + // http://server/, nettcp://server/, etc. The cannot contain slashes. + // The virtualPath passed to this method may be absolute or relative. Although + // ':' is only allowed in the if it is encoded, the + // virtual path that we're receiving here may be decoded, so it is impossible + // for us to determine if virtualPath has a scheme. We will be conservative + // and err on the side of assuming it has a scheme when we cannot tell for certain. + // To do this, we first check for ':'. If not found, then it doesn't have a scheme. + // If ':' is found, then as long as we find a '/' before the ':', it cannot be + // a scheme because schemes don't contain '/'. Otherwise, we will assume it has a + // scheme. + var indexOfColon = virtualPath.IndexOf(':'); + if (indexOfColon == -1) + return false; + var indexOfSlash = virtualPath.IndexOf('/'); + return indexOfSlash == -1 || indexOfColon < indexOfSlash; + } + + private static bool IsDirectorySeparatorChar(char ch) => ch == '\\' || ch == '/'; + + private static bool IsUncSharePath(string path) + { + // e.g \\server\share\foo or //server/share/foo + if (path.Length > 2 && IsDirectorySeparatorChar(path[0]) && IsDirectorySeparatorChar(path[1])) + return true; + return false; + } + + private static bool IsAbsolutePhysicalPath(string path) + { + if (path == null || path.Length < 3) return false; + + // e.g c:\foo + if (path[1] == ':' && IsDirectorySeparatorChar(path[2])) return true; + + // e.g \\server\share\foo or //server/share/foo + return IsUncSharePath(path); + } + + internal static void CheckValidVirtualPath(string path) + { + + // Check if it looks like a physical path (UNC shares and C:) + if (IsAbsolutePhysicalPath(path)) + { + throw new HttpException(string.Format(Physical_path_not_allowed, path)); + } + + // Virtual path can't have colons. + var iqs = path.IndexOf('?'); + if (iqs >= 0) + { + path = path[..iqs]; + } + if (HasScheme(path)) + { + throw new HttpException(string.Format(Invalid_vpath, path)); + } + } + + internal static string Combine(string appPath, string basepath, string relative) + { + string path; + + if (string.IsNullOrEmpty(relative)) + { + throw new ArgumentNullException(nameof(relative)); + } + if (string.IsNullOrEmpty(basepath)) + { + throw new ArgumentNullException(nameof(basepath)); + } + + if (basepath[0] == AppRelativeCharacter && basepath.Length == 1) + { + // If it's "~", change it to "~/" + basepath = AppRelativeCharacterString; + } + else + { + // If the base path includes a file name, get rid of it before combining + var lastSlashIndex = basepath.LastIndexOf('/'); + Debug.Assert(lastSlashIndex >= 0); + if (lastSlashIndex < basepath.Length - 1) + { + basepath = basepath[..(lastSlashIndex + 1)]; + } + } + + // Make sure it's a virtual path + Util.UrlPath.CheckValidVirtualPath(relative); + + if (Util.UrlPath.IsRooted(relative)) + { + path = relative; + } + else + { + // If the path is exactly "~", just return the app root path + if (relative.Length == 1 && relative[0] == AppRelativeCharacter) + return appPath; + + // If the relative path starts with "~/" or "~\", treat it as app root + // relative + if (IsAppRelativePath(relative)) + { + if (appPath.Length > 1) + path = string.Concat(appPath, "/", relative.AsSpan(2)); + else + path = string.Concat("/", relative.AsSpan(2)); + } + else + { + path = SimpleCombine(basepath, relative); + } + } + + return Reduce(path); + } + + // This simple version of combine should only be used when the relative + // path is known to be relative. It's more efficient, but doesn't do any + // sanity checks. + internal static string SimpleCombine(string basepath, string relative) + { + Debug.Assert(!string.IsNullOrEmpty(basepath)); + Debug.Assert(!string.IsNullOrEmpty(relative)); + Debug.Assert(relative[0] != '/'); + + if (HasTrailingSlash(basepath)) + return basepath + relative; + else + return basepath + "/" + relative; + } + + internal static string Reduce(string path) + { + // ignore query string + string? queryString = null; + if (!string.IsNullOrEmpty(path)) + { + var iqs = path.IndexOf('?'); + if (iqs >= 0) + { + queryString = path[iqs..]; + path = path[..iqs]; + } + } + + // Take care of backslashes and duplicate slashes + path = FixVirtualPathSlashes(path); + + path = ReduceVirtualPath(path); + + return queryString != null ? path + queryString : path; + } + + // Change backslashes to forward slashes, and remove duplicate slashes + internal static string FixVirtualPathSlashes(string virtualPath) + { + // Make sure we don't have any back slashes + virtualPath = virtualPath.Replace('\\', '/'); + + // Replace any double forward slashes + for (; ; ) + { + var newPath = virtualPath.Replace("//", "/"); + + // If it didn't do anything, we're done + if (newPath == (object)virtualPath) + break; + + // We need to loop again to take care of triple (or more) slashes + virtualPath = newPath; + } + + return virtualPath; + } + + internal static string MakeVirtualPathAppAbsolute(string virtualPath) + { + return MakeVirtualPathAppAbsolute(virtualPath, HttpRuntime.AppDomainAppVirtualPath); + } + + // If a virtual path is app relative (i.e. starts with ~/), change it to + // start with the actuall app path. + // E.g. ~/Sub/foo.aspx --> /MyApp/Sub/foo.aspx + internal static string MakeVirtualPathAppAbsolute(string virtualPath, string applicationPath) + { + // If the path is exactly "~", just return the app root path + if (virtualPath.Length == 1 && virtualPath[0] == AppRelativeCharacter) + return applicationPath; + + // If the virtual path starts with "~/" or "~\", replace with the app path + // relative + if (virtualPath.Length >= 2 && virtualPath[0] == AppRelativeCharacter && + (virtualPath[1] == '/' || virtualPath[1] == '\\')) + { + + if (applicationPath.Length > 1) + { + Debug.Assert(HasTrailingSlash(applicationPath)); + return string.Concat(applicationPath, virtualPath.AsSpan(2)); + } + else + return string.Concat("/", virtualPath.AsSpan(2)); + } + + // Don't allow relative paths, since they cannot be made App Absolute + if (!Util.UrlPath.IsRooted(virtualPath)) + throw new ArgumentOutOfRangeException(nameof(virtualPath)); + + // Return it unchanged + return virtualPath; + } + + internal static bool VirtualPathStartsWithVirtualPath(string virtualPath1, string virtualPath2) + { + if (virtualPath1 == null) + { + throw new ArgumentNullException(nameof(virtualPath1)); + } + + if (virtualPath2 == null) + { + throw new ArgumentNullException(nameof(virtualPath2)); + } + + // if virtualPath1 as a string doesn't start with virtualPath2 as s string, then no for sure + if (!StringUtil.StringStartsWithIgnoreCase(virtualPath1, virtualPath2)) + { + return false; + } + + var virtualPath2Length = virtualPath2.Length; + + // same length - same path + if (virtualPath1.Length == virtualPath2Length) + { + return true; + } + + // Special case for apps rooted at the root. + if (virtualPath2Length == 1) + { + Debug.Assert(virtualPath2[0] == '/'); + return true; + } + + // If virtualPath2 ends with a '/', it's definitely a child + if (virtualPath2[virtualPath2Length - 1] == '/') + return true; + + // If it doesn't, make sure the next char in virtualPath1 is a '/'. + // e.g. /app1 vs /app11 + if (virtualPath1[virtualPath2Length] != '/') + { + return false; + } + + // passed all checks + return true; + } + + internal static bool IsAppRelativePath(string? virtualPath) + { + if (virtualPath is null) return false; + var len = virtualPath.Length; + + // Empty string case + if (len == 0) return false; + + // It must start with ~ + if (virtualPath[0] != AppRelativeCharacter) return false; + + // Single character case: "~" + if (len == 1) return true; + + // If it's longer, checks if it starts with "~/" or "~\" + return virtualPath[1] == '\\' || virtualPath[1] == '/'; + } + + // Same as Reduce, but for a virtual path that is known to be well formed + internal static string ReduceVirtualPath(string path) + { + + var length = path.Length; + int examine; + + // quickly rule out situations in which there are no . or .. + + for (examine = 0; ; examine++) + { + examine = path.IndexOf('.', examine); + if (examine < 0) + return path; + + if ((examine == 0 || path[examine - 1] == '/') + && (examine + 1 == length || path[examine + 1] == '/' || + path[examine + 1] == '.' && (examine + 2 == length || path[examine + 2] == '/'))) + break; + } + + // OK, we found a . or .. so process it: + + List list = new(); + var sb = new StringBuilder(); + int start; + examine = 0; + + for (; ; ) + { + start = examine; + examine = path.IndexOf('/', start + 1); + + if (examine < 0) + examine = length; + + if (examine - start <= 3 && + (examine < 1 || path[examine - 1] == '.') && + (start + 1 >= length || path[start + 1] == '.')) + { + if (examine - start == 3) + { + if (list.Count == 0) + throw new HttpException(Cannot_exit_up_top_directory); + + // We're about to backtrack onto a starting '~', which would yield + // incorrect results. Instead, make the path App Absolute, and call + // Reduce on that. + if (list.Count == 1 && IsAppRelativePath(path)) + { + Debug.Assert(sb.Length == 1); + return ReduceVirtualPath(Util.UrlPath.MakeVirtualPathAppAbsolute(path)); + } + + sb.Length = list[^1]; + list.RemoveRange(list.Count - 1, 1); + } + } + else + { + list.Add(sb.Length); + + sb.Append(path, start, examine - start); + } + + if (examine == length) + break; + } + + var result = sb.ToString(); + + // If we end up with en empty string, turn it into either "/" or "." + if (result.Length == 0) + { + if (length > 0 && path[0] == '/') + result = @"/"; + else + result = "."; + } + + return result; + } + + // We use file: protocol instead of http:, so that Uri.MakeRelative behaves + // in a case insensitive way + private const string dummyProtocolAndServer = "file://foo"; + private static readonly char[] s_slashChars = new char[] { '\\', '/' }; + + internal static string MakeRelative(string fromPath, string toPath) + { + // If either path is app relative (~/...), make it absolute, since the Uri + // class wouldn't know how to deal with it. + fromPath = Util.UrlPath.MakeVirtualPathAppAbsolute(fromPath); + toPath = Util.UrlPath.MakeVirtualPathAppAbsolute(toPath); + + // Make sure both virtual paths are rooted + if (!Util.UrlPath.IsRooted(fromPath)) + throw new ArgumentException(string.Format(Path_must_be_rooted, fromPath)); + if (!Util.UrlPath.IsRooted(toPath)) + throw new ArgumentException(string.Format(Path_must_be_rooted, toPath)); + + // Remove the query string, so that System.Uri doesn't corrupt it + string? queryString = null; + if (!string.IsNullOrEmpty(toPath)) + { + var iqs = toPath.IndexOf('?'); + if (iqs >= 0) + { + queryString = toPath[iqs..]; + toPath = toPath[..iqs]; + } + } + + // Uri's need full url's so, we use a dummy root + Uri fromUri = new(dummyProtocolAndServer + fromPath); + Uri toUri = new(dummyProtocolAndServer + toPath); + + string relativePath; + + // If to and from points to identical path (excluding query and fragment), just use them instead + // of returning an empty string. + if (fromUri.Equals(toUri)) + { + var iPos = toPath.LastIndexOfAny(s_slashChars); + + if (iPos >= 0) + { + // If it's the same directory, simply return "./" + // Browsers should interpret "./" as the original directory. + if (iPos == toPath.Length - 1) + { + relativePath = "./"; + } + else + { + relativePath = toPath[(iPos + 1)..]; + } + } + else + { + relativePath = toPath; + } + } + else + { + // To avoid deprecation warning. It says we should use MakeRelativeUri instead (which returns a Uri), + // but we wouldn't gain anything from it. The way we use MakeRelative is hacky anyway (fake protocol, ...), + // and I don't want to take the chance of breaking something with this change. +#pragma warning disable 0618 + relativePath = fromUri.MakeRelative(toUri); +#pragma warning restore 0618 + } + + // Note that we need to re-append the query string and fragment (e.g. #anchor) + return relativePath + queryString + toUri.Fragment; + } + + internal static string? GetFileName(string virtualPath) + { + if (virtualPath is not null) + { + var length = virtualPath.Length; + for (var i = length; --i >= 0;) + { + var ch = virtualPath[i]; + if (ch == '/') + return virtualPath.Substring(i + 1, length - i - 1); + } + } + return virtualPath; + } + + [return: NotNullIfNotNull("virtualPath")] + internal static string? GetExtension(string? virtualPath) + { + if (virtualPath is null) return null; + + var length = virtualPath.Length; + for (var i = length; --i >= 0;) + { + var ch = virtualPath[i]; + if (ch == '.') + { + if (i != length - 1) + return virtualPath[i..length]; + else + return string.Empty; + } + if (ch == '/') + break; + } + return string.Empty; + } +} diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters/VirtualPathUtility.cs b/src/Microsoft.AspNetCore.SystemWebAdapters/VirtualPathUtility.cs new file mode 100644 index 0000000000..fe9169fb0c --- /dev/null +++ b/src/Microsoft.AspNetCore.SystemWebAdapters/VirtualPathUtility.cs @@ -0,0 +1,218 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace System.Web; + +/// +/// Provides utility methods for common virtual path operations. +/// +/// +/// Portions of this code are based on code originally Copyright (c) 1999 Microsoft Corporation +/// System.Web.Util.UrlPath code at https://github.com/microsoft/referencesource/blob/master/System.Web/Util/UrlPath.cs +/// System.Web.Util.StringUtil at https://github.com/microsoft/referencesource/blob/master/System.Web/Util/StringUtil.cs +/// These files are released under an MIT licence according to https://github.com/microsoft/referencesource#license +/// +public static class VirtualPathUtility +{ + + #region "Error strings" + private const string Empty_path_has_no_directory = "Empty path has no directory."; + #endregion + + /// Appends the literal slash mark (/) to the end of the virtual path, if one does not already exist. + /// The modified virtual path. + /// The virtual path to append the slash mark to. + [return: NotNullIfNotNull("virtualPath")] + public static string? AppendTrailingSlash(string? virtualPath) + { + if (virtualPath == null) return null; + + var l = virtualPath.Length; + if (l == 0) return virtualPath; + + if (virtualPath[l - 1] != '/') + virtualPath += '/'; + + return virtualPath; + } + + /// Combines a base path and a relative path. + /// The combined and . + /// The base path. + /// The relative path. + /// + /// is a physical path.-or- includes one or more colons. + /// + /// is null or an empty string.-or- is null or an empty string. + public static string Combine(string basePath, string relativePath) + { + return Util.UrlPath.Combine(HttpRuntime.AppDomainAppVirtualPath, basePath, relativePath); + } + + /// Returns the directory portion of a virtual path. + /// The directory referenced in the virtual path. + /// The virtual path. + /// + /// is not rooted. - or - is null or an empty string. + public static string? GetDirectory(string virtualPath) + { + if (string.IsNullOrEmpty(virtualPath)) + throw new ArgumentNullException(nameof(virtualPath), Empty_path_has_no_directory); + + if (virtualPath[0] != '/' && virtualPath[0] != Util.UrlPath.AppRelativeCharacter) + throw new ArgumentException(string.Format(Util.UrlPath.Path_must_be_rooted, virtualPath), nameof(virtualPath)); + + if ((virtualPath[0] == Util.UrlPath.AppRelativeCharacter && virtualPath.Length == 1) || virtualPath == Util.UrlPath.AppRelativeCharacterString) return "/"; + if (virtualPath.Length == 1) return null; + + var slashIndex = virtualPath.LastIndexOf('/'); + + // This could happen if the input looks like "~abc" + if (slashIndex < 0) + throw new ArgumentException(string.Format(Util.UrlPath.Path_must_be_rooted, virtualPath), nameof(virtualPath)); + + return virtualPath[..(slashIndex + 1)]; + } + + /// Retrieves the extension of the file that is referenced in the virtual path. + /// The file name extension string literal, including the period (.), null, or an empty string (""). + /// The virtual path. + /// + /// contains one or more characters that are not valid, as defined in . + public static string? GetExtension(string virtualPath) + { + if (string.IsNullOrEmpty(virtualPath)) throw new ArgumentNullException(nameof(virtualPath)); + + return Util.UrlPath.GetExtension(virtualPath); + } + + /// Retrieves the file name of the file that is referenced in the virtual path. + /// The file name literal after the last directory character in ; otherwise, the last directory name, if the last character of is a directory or volume separator character. + /// The virtual path. + /// + /// contains one or more characters that are not valid, as defined in . + public static string? GetFileName(string virtualPath) + { + if (string.IsNullOrEmpty(virtualPath)) throw new ArgumentNullException(nameof(virtualPath)); + if (!IsAppRelative(virtualPath) && !Util.UrlPath.IsRooted(virtualPath)) throw new ArgumentException($"The relative virtual path '{virtualPath}' is not allowed here.", nameof(virtualPath)); + return Util.UrlPath.GetFileName(virtualPath); + } + + /// Returns a Boolean value indicating whether the specified virtual path is absolute; that is, it starts with a literal slash mark (/). + /// true if is an absolute path and is not null or an empty string (""); otherwise, false. + /// The virtual path to check. + /// + /// is null. + public static bool IsAbsolute(string virtualPath) + { + if (string.IsNullOrEmpty(virtualPath)) throw new ArgumentNullException(nameof(virtualPath)); + return Util.UrlPath.IsRooted(virtualPath); + } + + /// Returns a Boolean value indicating whether the specified virtual path is relative to the application. + /// true if is relative to the application; otherwise, false. + /// The virtual path to check. + /// + /// is null. + public static bool IsAppRelative(string virtualPath) + { + if (string.IsNullOrEmpty(virtualPath)) throw new ArgumentNullException(nameof(virtualPath)); + return Util.UrlPath.IsAppRelativePath(virtualPath); + } + + /// Returns the relative virtual path from one virtual path containing the root operator (the tilde [~]) to another. + /// The relative virtual path from to . + /// The starting virtual path to return the relative virtual path from. + /// The ending virtual path to return the relative virtual path to. + /// + /// is not rooted.- or - is not rooted. + public static string MakeRelative(string fromPath, string toPath) => Util.UrlPath.MakeRelative(fromPath, toPath); + + /// Removes a trailing slash mark (/) from a virtual path. + /// A virtual path without a trailing slash mark, if the virtual path is not already the root directory ("/"); otherwise, null. + /// The virtual path to remove any trailing slash mark from. + public static string? RemoveTrailingSlash(string virtualPath) + { + if (string.IsNullOrEmpty(virtualPath)) return null; + var l = virtualPath.Length; + if (l <= 1 || virtualPath[l - 1] != '/') return virtualPath; + return virtualPath[..(l - 1)]; + } + + /// Converts a virtual path to an application absolute path. + /// The absolute path representation of the specified virtual path. + /// The virtual path to convert to an application-relative path. + /// + /// is not rooted. + /// A leading double period (..) is used to exit above the top directory. + public static string ToAbsolute(string virtualPath) + { + if (Util.UrlPath.IsRooted(virtualPath)) return virtualPath; + if (IsAppRelative(virtualPath)) return Util.UrlPath.ReduceVirtualPath(Util.UrlPath.MakeVirtualPathAppAbsolute(virtualPath)); + throw new ArgumentException($"The relative virtual path '{virtualPath}' is not allowed here.", nameof(virtualPath)); + } + + /// Converts a virtual path to an application absolute path using the specified application path. + /// The absolute virtual path representation of . + /// The virtual path to convert to an application-relative path. + /// The application path to use to convert to a relative path. + /// + /// is not rooted. + /// A leading double period (..) is used in the application path to exit above the top directory. + public static string ToAbsolute(string virtualPath, string applicationPath) + { + if (string.IsNullOrEmpty(applicationPath)) throw new ArgumentNullException(nameof(applicationPath)); + if (!Util.UrlPath.IsRooted(applicationPath)) throw new ArgumentException($"The relative virtual path '{virtualPath}' is not allowed here.", nameof(applicationPath)); + if (Util.UrlPath.IsRooted(virtualPath)) return virtualPath; + var appPath = AppendTrailingSlash(applicationPath); + if (IsAppRelative(virtualPath)) return Util.UrlPath.ReduceVirtualPath(Util.UrlPath.MakeVirtualPathAppAbsolute(virtualPath, appPath)); + throw new ArgumentException($"The relative virtual path '{virtualPath}' is not allowed here.", nameof(virtualPath)); + } + + /// Converts a virtual path to an application-relative path using the application virtual path that is in the property. + /// The application-relative path representation of . + /// The virtual path to convert to an application-relative path. + /// + /// is null. + public static string ToAppRelative(string virtualPath) => ToAppRelative(virtualPath, HttpRuntime.AppDomainAppVirtualPath); + + /// Converts a virtual path to an application-relative path using a specified application path. + /// The application-relative path representation of . + /// The virtual path to convert to an application-relative path. + /// The application path to use to convert to a relative path. + public static string ToAppRelative(string virtualPath, string applicationPath) + { + var appPath = AppendTrailingSlash(applicationPath); + if (virtualPath == null) + throw new ArgumentNullException(nameof(virtualPath)); + + var appPathLength = appPath.Length; + var virtualPathLength = virtualPath.Length; + + // If virtualPath is the same as the app path, but without the ending slash, + // treat it as if it were truly the app path (VSWhidbey 495949) + if (virtualPathLength == appPathLength - 1) + { + if (Util.StringUtil.StringStartsWithIgnoreCase(appPath, virtualPath)) + return Util.UrlPath.AppRelativeCharacterString; + } + + if (!Util.UrlPath.VirtualPathStartsWithVirtualPath(virtualPath, appPath)) + return virtualPath; + + // If they are the same, just return "~/" + if (virtualPathLength == appPathLength) + return Util.UrlPath.AppRelativeCharacterString; + + // Special case for apps rooted at the root: + if (appPathLength == 1) + return Util.UrlPath.AppRelativeCharacter + virtualPath; + + return Util.UrlPath.AppRelativeCharacter + virtualPath[(appPathLength - 1)..]; + } +} diff --git a/test/Microsoft.AspNetCore.SystemWebAdapters.Tests/VirtualPathUtilityTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.Tests/VirtualPathUtilityTests.cs new file mode 100644 index 0000000000..3a9fd8d196 --- /dev/null +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.Tests/VirtualPathUtilityTests.cs @@ -0,0 +1,226 @@ +using System; +using System.Web; +using Xunit; + +namespace Microsoft.AspNetCore.SystemWebAdapters.Tests +{ + public class VirtualPathUtilityTests + { + public VirtualPathUtilityTests() => HttpRuntime.Current = new DefaultHttpRuntime(); + + internal class DefaultHttpRuntime : IHttpRuntime + { + public string AppDomainAppVirtualPath => "/"; + } + + [InlineData("/", "/")] + [InlineData("~/", "/")] + [InlineData("~/test/../other", "/other")] + [Theory] + public void ToAbsolute(string virtualPath, string expected) + { + Assert.Equal(expected, (string?)VirtualPathUtility.ToAbsolute(virtualPath, "/")); + } + + [Fact] + public void ToAbsoluteError() + { + // This does not match the documentation + // https://docs.microsoft.com/en-us/dotnet/api/system.web.virtualpathutility.toabsolute?view=netframework-4.8 + // but it does match the actual framework + Assert.Throws(() => VirtualPathUtility.ToAbsolute("hello", "/")); + Assert.Throws(() => VirtualPathUtility.ToAbsolute("../../test", "/")); + Assert.Throws(() => VirtualPathUtility.ToAbsolute("~hello", "/")); + Assert.Throws(() => VirtualPathUtility.ToAbsolute("~/../../test", "/")); + Assert.Throws(() => VirtualPathUtility.ToAbsolute("~/hello", null!)); + Assert.Throws(() => VirtualPathUtility.ToAbsolute("~/hello", "")); + Assert.Throws(() => VirtualPathUtility.ToAbsolute("~/hello", "world")); + } + + [InlineData("/", "~/")] + [InlineData("~/", "~/")] + [Theory] + public void ToAppRelative(string virtualPath, string expected) + { + Assert.Equal(expected, (string?)VirtualPathUtility.ToAppRelative(virtualPath, "/")); + } + + + [InlineData("~/test", "hello", "~/hello")] + [InlineData("~/test/world", "hello", "~/test/hello")] + [InlineData("~/test/world", "../hello", "~/hello")] + [Theory] + public void Combine(string basePath, string relativePath, string expected) + { + Assert.Equal(expected, (string?)VirtualPathUtility.Combine(basePath, relativePath)); + } + + [Fact] + public void CombineError() + { + Assert.Throws(() => VirtualPathUtility.Combine("~/", "../../")); + } + + [InlineData("", "")] // This isn't mentioned in the docs but matches the behaviour of ASP.NET 4.x + [InlineData(null, null)] // This isn't mentioned in the docs but matches the behaviour of ASP.NET 4.x + [InlineData("/", "/")] + [InlineData("/test", "/test/")] + [InlineData("test", "test/")] + [Theory] + public void AppendTrailingSlash(string virtualPath, string expected) + { + Assert.Equal(expected, (string?)VirtualPathUtility.AppendTrailingSlash(virtualPath)); + } + + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("/", "/")] + [InlineData("/test/", "/test")] + [Theory] + public void RemoveTrailingSlash(string virtualPath, string expected) + { + Assert.Equal(expected, (string?)VirtualPathUtility.RemoveTrailingSlash(virtualPath)); + } + + // These are conditional so that these tests can be run against net48. + // The GetDirectory function doesn't work unless for app relative paths unless running as a web application. +#if NET6_0_OR_GREATER + [InlineData("~", "/")] + [InlineData("~/", "/")] + [InlineData("~/test", "~/")] + [InlineData("~/test/world.jpg", "~/test/")] +#endif + [InlineData("/", null)] + [InlineData("/test", "/")] + [InlineData("/test/world.jpg", "/test/")] + [Theory] + public void GetDirectory(string virtualPath, string expected) + { + Assert.Equal(expected, (string?)VirtualPathUtility.GetDirectory(virtualPath)); + } + + [Fact] + public void GetDirectoryError() + { + Assert.Throws(() => VirtualPathUtility.GetDirectory(null!)); + Assert.Throws(() => VirtualPathUtility.GetDirectory("")); + Assert.Throws(() => VirtualPathUtility.GetDirectory("test")); + Assert.Throws(() => VirtualPathUtility.GetDirectory("test/world.jpg")); + } + + [InlineData("/", "")] + [InlineData("/test", "")] + [InlineData("test", "")] + [InlineData("/test/world.jpg", ".jpg")] + [InlineData("test/world.jpg", ".jpg")] + [Theory] + public void GetExtension(string virtualPath, string expected) + { + Assert.Equal(expected, (string?)VirtualPathUtility.GetExtension(virtualPath)); + } + + [Fact] + public void GetExtensionError() + { + Assert.Throws(() => VirtualPathUtility.GetExtension(null!)); + Assert.Throws(() => VirtualPathUtility.GetExtension("")); + } + + [InlineData("/", "")] + [InlineData("/test", "test")] + [InlineData("/test/world.jpg", "world.jpg")] + [InlineData("~/", "")] + [InlineData("~/test", "test")] + [InlineData("~/test/world.jpg", "world.jpg")] + [Theory] + public void GetFileName(string virtualPath, string expected) + { + Assert.Equal(expected, (string?)VirtualPathUtility.GetFileName(virtualPath)); + } + + [Fact] + public void GetFileNameError() + { + Assert.Throws(() => VirtualPathUtility.GetFileName(null!)); + Assert.Throws(() => VirtualPathUtility.GetFileName("")); + Assert.Throws(() => VirtualPathUtility.GetFileName("test")); + Assert.Throws(() => VirtualPathUtility.GetFileName("test/world.jpg")); + } + + [InlineData("~", false)] + [InlineData("/", true)] + [InlineData("~/", false)] + [InlineData("/test", true)] + [InlineData("test", false)] + [InlineData("/test/world.jpg", true)] + [InlineData("test/world.jpg", false)] + [InlineData("~/test", false)] + [InlineData("~/test/world.jpg", false)] + [Theory] + public void IsAbsolute(string virtualPath, bool expected) + { + Assert.Equal(expected, VirtualPathUtility.IsAbsolute(virtualPath)); + } + + [Fact] + public void IsAbsoluteError() + { + Assert.Throws(() => VirtualPathUtility.IsAbsolute(null!)); + Assert.Throws(() => VirtualPathUtility.IsAbsolute("")); + } + + [InlineData("~", true)] + [InlineData("/", false)] + [InlineData("~/", true)] + [InlineData("/test", false)] + [InlineData("test", false)] + [InlineData("/test/world.jpg", false)] + [InlineData("test/world.jpg", false)] + [InlineData("~/test", true)] + [InlineData("~/test/world.jpg", true)] + [Theory] + public void IsAppRelative(string virtualPath, bool expected) + { + Assert.Equal(expected, VirtualPathUtility.IsAppRelative(virtualPath)); + } + + [Fact] + public void IsAppRelativeError() + { + Assert.Throws(() => VirtualPathUtility.IsAppRelative(null!)); + Assert.Throws(() => VirtualPathUtility.IsAppRelative("")); + } + + // These are conditional so that these tests can be run against net48. + // The MakeRelative function doesn't work unless for app relative paths unless running as a web application. +#if NET6_0_OR_GREATER + [InlineData("~/home/index", "~/api/weather/get", "../api/weather/get")] + [InlineData("~/home/index", "~/other/index", "../other/index")] + [InlineData("~/home/index", "~/home/other", "other")] + [InlineData("~/home/index", "/api/weather/get", "../api/weather/get")] + [InlineData("/home/index", "~/other/index", "../other/index")] + [InlineData("", "~/", "./")] + [InlineData("~/", "", "")] + [InlineData("", "/", "./")] + [InlineData("/", "", "")] +#endif + [InlineData("/home/index", "/api/weather/get", "../api/weather/get")] + [InlineData("/home/index", "/other/index", "../other/index")] + [InlineData("/home/index", "/home/other", "other")] + [InlineData("/directory1/file1.aspx", "/directory2/file2.aspx", "../directory2/file2.aspx")] + [Theory] + public void MakeRelative(string fromPath, string toPath, string expected) + { + Assert.Equal(expected, VirtualPathUtility.MakeRelative(fromPath, toPath)); + } + + [Fact] + public void MakeRelativeError() + { + Assert.Throws(() => VirtualPathUtility.MakeRelative("~/", null!)); + Assert.Throws(() => VirtualPathUtility.MakeRelative(null!, "~/")); + Assert.Throws(() => VirtualPathUtility.MakeRelative("~/hello/", "test")); + Assert.Throws(() => VirtualPathUtility.MakeRelative("test", "~/hello/")); + } + } +}