Skip to content

System.IO.DirectoryInfo CreateSubdirectory method fails when DirectoryInfo refers to a root directory #49284

@trajanmcgill

Description

@trajanmcgill

Description

The below code fails to create a subdirectory and throws an exception.

using System;
using System.IO;

namespace CreateSubdirectoryTest
{
    class Program
    {
        static void Main(string[] args)
        {
            DirectoryInfo directory = new DirectoryInfo("T:\\"); // Or any other root directory, including / on Linux.
            directory.CreateSubdirectory("foo");
        }
    }
}

Expected:
T:\foo is created (or /foo, if doing the equivalent on Linux).

Actual:

System.ArgumentException: The directory specified, 'foo', is not a subdirectory of 'T:'. (Parameter 'path')

Configuration

.NET Version: 5.0.3
OS: Windows 10 or Linux

Other information

The exception message is a very odd one for the given circumstance, which led me to explore
runtime/src/libraries/System.IO.FileSystem/src/System/IO/DirectoryInfo.cs,
where I found the following definition of CreateSubdirectory():

public DirectoryInfo CreateSubdirectory(string path)
{
    if (path == null)
        throw new ArgumentNullException(nameof(path));
    if (PathInternal.IsEffectivelyEmpty(path.AsSpan()))
        throw new ArgumentException(SR.Argument_PathEmpty, nameof(path));
    if (Path.IsPathRooted(path))
        throw new ArgumentException(SR.Arg_Path2IsRooted, nameof(path));

    string newPath = Path.GetFullPath(Path.Combine(FullPath, path));

    ReadOnlySpan<char> trimmedNewPath = Path.TrimEndingDirectorySeparator(newPath.AsSpan());
    ReadOnlySpan<char> trimmedCurrentPath = Path.TrimEndingDirectorySeparator(FullPath.AsSpan());

    // We want to make sure the requested directory is actually under the subdirectory.
    if (trimmedNewPath.StartsWith(trimmedCurrentPath, PathInternal.StringComparison)
        // Allow the exact same path, but prevent allowing "..\FooBar" through when the directory is "Foo"
        && ((trimmedNewPath.Length == trimmedCurrentPath.Length) || PathInternal.IsDirectorySeparator(newPath[trimmedCurrentPath.Length])))
    {
        FileSystem.CreateDirectory(newPath);
        return new DirectoryInfo(newPath);
    }

    // We weren't nested
    throw new ArgumentException(SR.Format(SR.Argument_InvalidSubPath, path, FullPath), nameof(path));
}

I believe the above is guaranteed to fail for any path (e.g., foo) if FullPath is something like T:\ or / because:

  1. newPath will be T:\foo (Linux: /foo)
  2. trimmedNewPath will be T:\foo (/foo)
  3. trimmedCurrentPath will be T:\ (/) <-- the outcome of this step is the key thing I think the above code fails to anticipate.
  4. The trimmedNewPath.StartsWith() condition is fulfilled, but:
  5. trimmedCurrentPath.Length == 3, and newPath[3] == 'f' (or, for the Linux root path, the length is 1, which results in the same: the tested character of newPath being 'f'), so the PathInternal.IsDirectorySeparator() test against that character fails, allowing execution to pass to throwing an exception.

The issue is that for a directory such as T:\ or /, Path.TrimEndingDirectorySeparator() does not remove the trailing separator, because it is not just a separator; it is actually the path.

Suggested Solution

It looks to me like what the above code is trying to accomplish is to verify that the full starting path is either identical to or a substring of the finished new full path and make sure that the new part is tacked on on the other side of a directory separator from the old part.

Rather than trimming the directory separator from the original full path, then testing for the new one to start with the original followed by a directory separator, perhaps it would be more accurate to add a directory separator to the original if missing, and then simply test that the new full path starts with [original full path including separator]. That would also have the benefit of making the purpose of the if condition easier to understand by making its expression much simpler.

Metadata

Metadata

Labels

area-System.IOin-prThere is an active PR which will close this issue when it is merged

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions