std::filesystem Implementation Corner Cases

2019-12-22 13:24
On this years [Meeting C++](https://meetingcpp.com/2019/) I had the chance to talk with a lot of people and it was very inspirational. With some I talked about [ghc::filesystem](https://github.com/gulrak/filesystem), my implementation of a C++17 `std::filesystem` compatible library. Part of this discussions was about some corner cases I found during my tests, and I was trying to remember the details when I was having the chance to talk to [@NicoJosuttis](https://twitter.com/NicoJosuttis). He among others was interested in these issues, as they might be rooted in unclear passages of the standard.

[Update 2020-02-08: A post on stackoverflow.com showed an additional corner case, read more about it in part 2.]

Meeting C++

In summer 2018 I started on changing my private C++ filesystem abstraction into a more std::filesystem conforming structure. Main motivation was the low chance of an official implementation on my primary platform, macOS, anytime soon. The first feature-complete public release supporting C++11, C++14 and C++17 on macOS, Windows and Linux was in September 2018.

Early on, I started writing lots of tests and wrote my test-suite in a way, that I could run my tests against an existing std::filesystem implementation, where one is available. The test code is based on Catch2 macros. Currently, using the valuable services of Travis-CI and Appveyor, ghc::filesystem is tested on GCC 5 to GCC 9, Clang 5 to Clang 9, Xcode 9/10/11 and Visual Studio 2015/2017/2019. The tests are also run against std::filesystem in GCC 8.3 and GCC 9.3 with libstdc++ and Clang 7.1/8.0/9.0 with libc++ and MSVC 19.16 and MSVC 19.23.

Important note:

With this post I want to share some corner cases I found, where my tests showed different behaviour for the tested std::filesystem implementation in C++17 mode.

All tests pass on ghc::filesystem because they are from its test suite.

I have by no means the authority nor knowledge of all mails, forums, chats and other channels outside of the standard that have lead the developers to their implementation, and I might have missed an LWG-issue that is relevant here or simply missed something in the standard, so I’m not saying mine is the way to do things, and I already changed some behaviour to follow other implementations, as I’m interested in a common sense of what should happen. So I don’t want to blame anyone for anything, but some results are still irritating for me, so I keep my implementation the way it is, until I have better arguments or understanding to do otherwise.

Any opinions on any of the following “issues” is very welcome! Also new/missing corner cases are welcome to get a more complete picture!

You can reach me via twitter by @gulrak or drop me a ✉ mail.


List of Implementation Corner Cases


path native format observers [fs.path.native.obs]

GCC 8.1, 8.2, 8.3:

FAILED:
  CHECK( fs::u8path("\xc3\xa4/\xe2\x82\xac\xf0\x9d\x84\x9e").u16string() == std::u16string(u"\u00E4/\u20AC\U0001D11E") )
due to unexpected exception with messages:
  filesystem error: Cannot convert character sequence: Illegal byte sequence

I had observed this with GCC 8.1/8.2 on Ubuntu 18.04 and macOS, and GCC 8.3.0 on Wandbox. The compiler doesn’t recognize the valid Unicode codepoint U+1D11E. This was fixed with GCC issue 90281).

GCC 9.2:

FAILED:
  CHECK( fs::u8path("\xc3\xa4/\xe2\x82\xac").wstring() == std::wstring(L"ä/€") )
due to unexpected exception with message:
  filesystem error: Cannot convert character sequence: Invalid or incomplete
  multibyte or wide character  

This is observed on an Homebrew install of GCC-9 on macOS and on Travis-CI Linux GCC 9.2, and with this Godbolt example. The std::filesystem::wstring() is the problem here, and std::filesystem::u32string() works as expected and the returned std::u32string is the expected 3 char32_t long.


path generic format observers [fs.path.generic.obs]

GCC 9.2:

FAILED:
  CHECK( fs::u8path("\xc3\xa4/\xe2\x82\xac").generic_wstring() == std::wstring(L"ä/€") )
due to unexpected exception with message:
  filesystem error: Cannot convert character sequence: Invalid or incomplete
  multibyte or wide character

This is observed on an Homebrew install of GCC-9 on macOS and on Travis-CI Linux GCC 9.2, and with this Godbolt example. The std::filesystem::generic_wstring() is the problem here, and std::filesystem::generic_u32string() works as expected and the returned std::u32string is the expected 3 char32_t long.


path iterators [fs.path.itr]

MSVC 19.16, 19.23:

FAILED:
  CHECK( "/,foo,bar" == iterateResult(fs::path("///foo/bar")) )
with expansion:
  "/,foo,bar" == "///,foo,bar"

The used helper iterateResult is this:

static std::string iterateResult(const fs::path& path)
{
    std::ostringstream result;
    for (fs::path::const_iterator i = path.begin(); i != path.end(); ++i) {
        if (i != path.begin()) {
            result << ",";
        }
        result << i->generic_string();
    }
    return result.str();
}

GCC, Clang and my implementation all return / for the /// part, only MSVC decides to return ///. As Windows is supporting / as seperator too, even if it’s not the preferred seperator, I’m not sure about this and I didn’t mimic this behaviour on Windows.


path inserter and extractor [fs.path.io]

MSVC 19.16, 19.23:

With an std::ostringstream filled by:

std::ostringstream os;
os << fs::path("/root/foo bar");

we get this failure:

FAILED:
  CHECK( os.str() == "\"\\\\root\\\\foo bar\"" )
with expansion:
  ""/root/foo bar"" == ""\\root\\foo bar""

The standard describes the effect of the path inserter as: “Equivalent to: os « quoted(p.string<charT, traits>());” and fs::path::string() is one of the native format observers that MSVC returns a backslashed path for. Still the stream inserter outputs the generic format (that Windows API will accept) and this seems inconsistent.


directory entry [fs.dir.entry]

  fs::directory_entry none;
  none.refresh();

This code currently throws an exception on MSVC 19.16, 19.23 and ghc::filesystem while not throwing on all versions of GCC/Clang.

  fs::directory_entry de;
  de.assign("");

This code currently only throws on MSVC 19.16 and ghc::filesystem.

  fs::directory_entry de{"existing_dir/existing_file"};
  de.replace_filename("bar");

This code currently only throws on MSVC 19.16 and ghc::filesystem.

MSVC 19.23:

  auto none = fs::directory_entry();
  CHECK( none.status().type() == fs::file_type::not_found );
FAILED:
  CHECK( none.status().type() == fs::file_type::not_found )
with expansion:
  2 == 1

This is surprizing. I have no idea why this happens, als 2 would be fs::file_type::regular for a directory_entry with an empty path.


absolute [fs.op.absolute]

GCC 8.3, 9.2:

FAILED:
  CHECK( fs::absolute("") == fs::current_path() / "" )
due to unexpected exception with message:
  filesystem error: cannot make absolute path: Invalid argument

GCC is the only one throwing in this case, or sets the error_core in case of fs::absolute(error_code&), besides the standard note: “Implementations are strongly encouraged to not query secondary storage, and not consider !exists(p) an error.” and the example implementation from the standard: “For POSIX-based operating systems, absolute(p) is simply current_path()/p.”.

MSVC 19.16, 19.23:

FAILED:
  CHECK( fs::absolute("") == fs::current_path() / "" )
with expansion:
  "" == "C:\projects\filesystem\build\"

MSVC is returning an empty result.

Others

Clang and my implementation return the suggested result of current_path()/p.


canonical [fs.op.canonical]

Clang 7, 8, 9, MSVC 19.16, 19.23:

FAILED:
  CHECK_THROWS_AS( fs::canonical(""), fs::filesystem_error )
because no exception was thrown where one was expected:

This seems to be a violation of the standard, as 30.10.15.2 (4) specifies "!exists(p) is an error." and the compilers report false for exists(""). My guess is, Clang first calls absolute(p) and after that, p is not empty and exists(absolute("")) is normally true as it is the current directory path.

The GCC implementations treat this as an error, as well as my own.


create_directory, create_directories
[fs.op.create_directory], [fs.op.create_directories]

Given a p with:

  auto p = fs::path{"some_existing_regular_file"};

We get:

GCC 7, MSVC 19.16:

FAILED:
  CHECK_THROWS_AS( fs::create_directories(p), fs::filesystem_error )
because no exception was thrown where one was expected

Clang 7, 8, 9 and MSVC 19.16:

FAILED:
  CHECK_THROWS_AS( fs::create_directory(p), fs::filesystem_error )
because no exception was thrown where one was expected

This was not well defined with the original standard and the LWG#2935 suggested this behaviour. The more intuitive approach to directory creation of treating a file with that name as an error is also advocated by the newer paper WG21 P1164R0, the revison P1161R1 was agreed upon on Kona 2019 meeting see merge and MSVC and GCC by now switched to following its proposal (GCC #86910).


equivalent [fs.op.equivalent]

GCC 8.x, 9.2:

  CHECK_THROWS_AS( fs::equivalent("foo", "foo3"), fs::filesystem_error )
because no exception was thrown where one was expected:
  This test expects LWG #2937 result conformance.

  CHECK( ec )
with expansion:
  system:0
with message:
  This test expects LWG #2937 result conformance.

  CHECK_THROWS_AS( fs::equivalent("foo3", "foo"), fs::filesystem_error )
because no exception was thrown where one was expected:
  This test expects LWG #2937 result conformance.

  CHECK( ec )
with expansion:
  system:0
with message:
  This test expects LWG #2937 result conformance.

With LWG #2937 was specified, that it is an error if either of the given path objects doesn’t resolve to an existing file and Clang and MSVC follow #2937. My implementation follows this rule too (but there is a define at the top, where this can be disabled).


weakly_canonical [fs.op.weakly_canonical]

Clang 7, 8, 9 and GCC 8.3, 9.2:

FAILED:
  CHECK( fs::weakly_canonical("") == "." )
with expansion:
  "/home/travis/build/gulrak/filesystem/build"
  ==
  "."

Clang and GCC are returning fs::current_path() for fs::canonical("") so it is to be expected that they return the same for fs::weakly_canonical(""). MSVC and my implementation are returning . as the normalized empty path ([fs.def.normal.form], 8).


Raw Test Issue Table

I wrote a script generating an html table with the issues ordered by category and compiler and I try to reupload a new one, if something relevant changes. I’ll link to it only for completeness:

Test-Report


Thanks!

I would like to thank Jonathan Wakely, whom I had contact with through my last GCCs issues, for taking me serious and for pointing me to the right directions a few times. He was always helpful and quick in fixing actual issues I found and reported.

I also want to thank Nico Josuttis for his WG21 P1164R0 to clear up the create_directory/create_directories issue, his efforts to make the standard clearer for normal developers and his C++17 - The Complete Guide.

And thanks to all those I talked to on Meeting C++ but can not list here, I hope to meet many of you next year.