diff --git a/src/Umbraco.Core/IO/IIOHelper.cs b/src/Umbraco.Core/IO/IIOHelper.cs index 5a0f5cab45..8c84526268 100644 --- a/src/Umbraco.Core/IO/IIOHelper.cs +++ b/src/Umbraco.Core/IO/IIOHelper.cs @@ -21,6 +21,14 @@ namespace Umbraco.Core.IO /// string MapPath(string path); + /// + /// Returns true if the path has a root, and is considered fully qualified for the OS it is on + /// See https://github.com/dotnet/runtime/blob/30769e8f31b20be10ca26e27ec279cd4e79412b9/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs#L281 for the .NET Standard 2.1 version of this + /// + /// The path to check + /// True if the path is fully qualified, false otherwise + bool IsPathFullyQualified(string path); + /// /// Verifies that the current filepath matches a directory where the user is allowed to edit a file. /// @@ -45,7 +53,7 @@ namespace Umbraco.Core.IO /// A value indicating whether the filepath is valid. bool VerifyFileExtension(string filePath, IEnumerable validFileExtensions); - bool PathStartsWith(string path, string root, char separator); + bool PathStartsWith(string path, string root, params char[] separators); void EnsurePathExists(string path); diff --git a/src/Umbraco.Core/IO/IOHelper.cs b/src/Umbraco.Core/IO/IOHelper.cs index 08241d28f4..e7f0586a2c 100644 --- a/src/Umbraco.Core/IO/IOHelper.cs +++ b/src/Umbraco.Core/IO/IOHelper.cs @@ -4,12 +4,13 @@ using System.Globalization; using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.InteropServices; using Umbraco.Core.Hosting; using Umbraco.Core.Strings; namespace Umbraco.Core.IO { - public class IOHelper : IIOHelper + public abstract class IOHelper : IIOHelper { private readonly IHostingEnvironment _hostingEnvironment; @@ -29,7 +30,7 @@ namespace Umbraco.Core.IO if (virtualPath.StartsWith("~")) retval = virtualPath.Replace("~", _hostingEnvironment.ApplicationVirtualPath); - if (virtualPath.StartsWith("/") && virtualPath.StartsWith(_hostingEnvironment.ApplicationVirtualPath) == false) + if (virtualPath.StartsWith("/") && !PathStartsWith(virtualPath, _hostingEnvironment.ApplicationVirtualPath)) retval = _hostingEnvironment.ApplicationVirtualPath + "/" + virtualPath.TrimStart('/'); return retval; @@ -64,19 +65,18 @@ namespace Umbraco.Core.IO { if (path == null) throw new ArgumentNullException(nameof(path)); - // Check if the path is already mapped - if ((path.Length >= 2 && path[1] == Path.VolumeSeparatorChar) - || path.StartsWith(@"\\")) //UNC Paths start with "\\". If the site is running off a network drive mapped paths will look like "\\Whatever\Boo\Bar" + // Check if the path is already mapped - TODO: This should be switched to Path.IsPathFullyQualified once we are on Net Standard 2.1 + if (Path.IsPathRooted(path) && + (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || // Linux paths are fully qualified as long as they are rooted, Windows paths can be rooted but not fully qualified + ((path.Length >= 2 && path[1] == Path.VolumeSeparatorChar) || path.StartsWith(@"\\") //UNC Paths start with "\\". If the site is running off a network drive mapped paths will look like "\\Whatever\Boo\Bar" + ))) { return path; } - // Check that we even have an HttpContext! otherwise things will fail anyways - // http://umbraco.codeplex.com/workitem/30946 - if (_hostingEnvironment.IsHosted) { - var result = (!string.IsNullOrEmpty(path) && (path.StartsWith("~") || path.StartsWith(_hostingEnvironment.ApplicationVirtualPath))) + var result = (!string.IsNullOrEmpty(path) && (path.StartsWith("~") || PathStartsWith(path, _hostingEnvironment.ApplicationVirtualPath))) ? _hostingEnvironment.MapPathWebRoot(path) : _hostingEnvironment.MapPathWebRoot("~/" + path.TrimStart('/')); @@ -91,6 +91,14 @@ namespace Umbraco.Core.IO return retval; } + /// + /// Returns true if the path has a root, and is considered fully qualified for the OS it is on + /// See https://github.com/dotnet/runtime/blob/30769e8f31b20be10ca26e27ec279cd4e79412b9/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs#L281 for the .NET Standard 2.1 version of this + /// + /// The path to check + /// True if the path is fully qualified, false otherwise + public abstract bool IsPathFullyQualified(string path); + /// /// Verifies that the current filepath matches a directory where the user is allowed to edit a file. @@ -121,7 +129,7 @@ namespace Umbraco.Core.IO // not going to fix everything today var mappedRoot = MapPath(_hostingEnvironment.ApplicationVirtualPath); - if (filePath.StartsWith(mappedRoot) == false) + if (!PathStartsWith(filePath, mappedRoot)) filePath = _hostingEnvironment.MapPathContentRoot(filePath); // yes we can (see above) @@ -131,10 +139,10 @@ namespace Umbraco.Core.IO foreach (var dir in validDirs) { var validDir = dir; - if (validDir.StartsWith(mappedRoot) == false) + if (!PathStartsWith(validDir, mappedRoot)) validDir = _hostingEnvironment.MapPathContentRoot(validDir); - if (PathStartsWith(filePath, validDir, Path.DirectorySeparatorChar)) + if (PathStartsWith(filePath, validDir)) return true; } @@ -153,16 +161,7 @@ namespace Umbraco.Core.IO return ext != null && validFileExtensions.Contains(ext.TrimStart('.')); } - public bool PathStartsWith(string path, string root, char separator) - { - // either it is identical to root, - // or it is root + separator + anything - - if (path.StartsWith(root, StringComparison.OrdinalIgnoreCase) == false) return false; - if (path.Length == root.Length) return true; - if (path.Length < root.Length) return false; - return path[root.Length] == separator; - } + public abstract bool PathStartsWith(string path, string root, params char[] separators); public void EnsurePathExists(string path) { @@ -181,7 +180,7 @@ namespace Umbraco.Core.IO if (path.IsFullPath()) { var rootDirectory = MapPath("~"); - var relativePath = path.ToLowerInvariant().Replace(rootDirectory.ToLowerInvariant(), string.Empty); + var relativePath = PathStartsWith(path, rootDirectory) ? path.Substring(rootDirectory.Length) : path; path = relativePath; } diff --git a/src/Umbraco.Core/IO/IOHelperLinux.cs b/src/Umbraco.Core/IO/IOHelperLinux.cs new file mode 100644 index 0000000000..2c2e778740 --- /dev/null +++ b/src/Umbraco.Core/IO/IOHelperLinux.cs @@ -0,0 +1,28 @@ +using System; +using System.IO; +using System.Linq; +using Umbraco.Core.Hosting; + +namespace Umbraco.Core.IO +{ + public class IOHelperLinux : IOHelper + { + public IOHelperLinux(IHostingEnvironment hostingEnvironment) : base(hostingEnvironment) + { + } + + public override bool IsPathFullyQualified(string path) => Path.IsPathRooted(path); + + public override bool PathStartsWith(string path, string root, params char[] separators) + { + // either it is identical to root, + // or it is root + separator + anything + + if (separators == null || separators.Length == 0) separators = new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }; + if (!path.StartsWith(root, StringComparison.Ordinal)) return false; + if (path.Length == root.Length) return true; + if (path.Length < root.Length) return false; + return separators.Contains(path[root.Length]); + } + } +} diff --git a/src/Umbraco.Core/IO/IOHelperOSX.cs b/src/Umbraco.Core/IO/IOHelperOSX.cs new file mode 100644 index 0000000000..90d96998c3 --- /dev/null +++ b/src/Umbraco.Core/IO/IOHelperOSX.cs @@ -0,0 +1,29 @@ +using System; +using System.IO; +using System.Linq; +using Umbraco.Core.Hosting; + + +namespace Umbraco.Core.IO +{ + public class IOHelperOSX : IOHelper + { + public IOHelperOSX(IHostingEnvironment hostingEnvironment) : base(hostingEnvironment) + { + } + + public override bool IsPathFullyQualified(string path) => Path.IsPathRooted(path); + + public override bool PathStartsWith(string path, string root, params char[] separators) + { + // either it is identical to root, + // or it is root + separator + anything + + if (separators == null || separators.Length == 0) separators = new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }; + if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase)) return false; + if (path.Length == root.Length) return true; + if (path.Length < root.Length) return false; + return separators.Contains(path[root.Length]); + } + } +} diff --git a/src/Umbraco.Core/IO/IOHelperWindows.cs b/src/Umbraco.Core/IO/IOHelperWindows.cs new file mode 100644 index 0000000000..3cffc27751 --- /dev/null +++ b/src/Umbraco.Core/IO/IOHelperWindows.cs @@ -0,0 +1,55 @@ +using System; +using System.IO; +using System.Linq; +using Umbraco.Core.Hosting; + + +namespace Umbraco.Core.IO +{ + public class IOHelperWindows : IOHelper + { + public IOHelperWindows(IHostingEnvironment hostingEnvironment) : base(hostingEnvironment) + { + } + + public override bool IsPathFullyQualified(string path) + { + // TODO: This implementation is taken from the .NET Standard 2.1 implementation. We should switch to using Path.IsPathFullyQualified once we are on .NET Standard 2.1 + + if (path.Length < 2) + { + // It isn't fixed, it must be relative. There is no way to specify a fixed + // path with one character (or less). + return false; + } + + if (path[0] == Path.DirectorySeparatorChar || path[0] == Path.AltDirectorySeparatorChar) + { + // There is no valid way to specify a relative path with two initial slashes or + // \? as ? isn't valid for drive relative paths and \??\ is equivalent to \\?\ + return path[1] == '?' || path[1] == Path.DirectorySeparatorChar || path[1] == Path.AltDirectorySeparatorChar; + } + + // The only way to specify a fixed path that doesn't begin with two slashes + // is the drive, colon, slash format- i.e. C:\ + return (path.Length >= 3) + && (path[1] == Path.VolumeSeparatorChar) + && (path[2] == Path.DirectorySeparatorChar || path[2] == Path.AltDirectorySeparatorChar) + // To match old behavior we'll check the drive character for validity as the path is technically + // not qualified if you don't have a valid drive. "=:\" is the "=" file's default data stream. + && ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')); + } + + public override bool PathStartsWith(string path, string root, params char[] separators) + { + // either it is identical to root, + // or it is root + separator + anything + + if (separators == null || separators.Length == 0) separators = new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }; + if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase)) return false; + if (path.Length == root.Length) return true; + if (path.Length < root.Length) return false; + return separators.Contains(path[root.Length]); + } + } +} diff --git a/src/Umbraco.Tests.Common/TestHelperBase.cs b/src/Umbraco.Tests.Common/TestHelperBase.cs index 095879209a..9485865d8d 100644 --- a/src/Umbraco.Tests.Common/TestHelperBase.cs +++ b/src/Umbraco.Tests.Common/TestHelperBase.cs @@ -23,6 +23,7 @@ using Umbraco.Core.Strings; using Umbraco.Web; using Umbraco.Web.Routing; using Umbraco.Tests.Common.Builders; +using System.Runtime.InteropServices; namespace Umbraco.Tests.Common { @@ -86,7 +87,11 @@ namespace Umbraco.Tests.Common get { if (_ioHelper == null) - _ioHelper = new IOHelper(GetHostingEnvironment()); + { + var hostingEnvironment = GetHostingEnvironment(); + _ioHelper = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? new IOHelperLinux(hostingEnvironment) + : (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? (IIOHelper)new IOHelperOSX(hostingEnvironment) : new IOHelperWindows(hostingEnvironment)); + } return _ioHelper; } } diff --git a/src/Umbraco.Web.Common/Extensions/UmbracoCoreServiceCollectionExtensions.cs b/src/Umbraco.Web.Common/Extensions/UmbracoCoreServiceCollectionExtensions.cs index c98cbca39e..348cccb9f1 100644 --- a/src/Umbraco.Web.Common/Extensions/UmbracoCoreServiceCollectionExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/UmbracoCoreServiceCollectionExtensions.cs @@ -373,7 +373,8 @@ namespace Umbraco.Extensions throw new InvalidOperationException($"Could not resolve type {typeof(GlobalSettings)} from the container, ensure {nameof(AddUmbracoConfiguration)} is called before calling {nameof(AddUmbracoCore)}"); hostingEnvironment = new AspNetCoreHostingEnvironment(hostingSettings, webHostEnvironment); - ioHelper = new IOHelper(hostingEnvironment); + ioHelper = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? new IOHelperLinux(hostingEnvironment) + : (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? (IIOHelper)new IOHelperOSX(hostingEnvironment) : new IOHelperWindows(hostingEnvironment)); AddLogger(services, hostingEnvironment, loggingConfiguration, configuration); backOfficeInfo = new AspNetCoreBackOfficeInfo(globalSettings); profiler = GetWebProfiler(hostingEnvironment); diff --git a/src/Umbraco.Web/UmbracoApplicationBase.cs b/src/Umbraco.Web/UmbracoApplicationBase.cs index fe10cfba81..a04cea4fed 100644 --- a/src/Umbraco.Web/UmbracoApplicationBase.cs +++ b/src/Umbraco.Web/UmbracoApplicationBase.cs @@ -58,7 +58,7 @@ namespace Umbraco.Web var hostingEnvironment = new AspNetHostingEnvironment(Options.Create(hostingSettings)); var loggingConfiguration = new LoggingConfiguration( Path.Combine(hostingEnvironment.ApplicationPhysicalPath, "App_Data\\Logs")); - var ioHelper = new IOHelper(hostingEnvironment); + var ioHelper = new IOHelperWindows(hostingEnvironment); var logger = SerilogLogger.CreateWithDefaultConfiguration(hostingEnvironment, loggingConfiguration, new ConfigurationRoot(new List())); var backOfficeInfo = new AspNetBackOfficeInfo(globalSettings, ioHelper, _loggerFactory.CreateLogger(), Options.Create(webRoutingSettings));