directory icon indicating copy to clipboard operation
directory copied to clipboard

`prependCurrentDirectory` strips trailing whitespace on Windows

Open tbidne opened this issue 10 months ago • 2 comments

Hello,

I encountered the following surprising (to me) behavior. Consider:

{-# LANGUAGE QuasiQuotes #-}

module Main (main)

import System.OsPath (OsPath, osp, (</>))
import System.Directory.Internal qualified as IDir

main :: IO ()
main = putStrLn . show =<< IDir.prependCurrentDirectory relOsPath

-- "a/rel/path   "
relOsPath :: OsPath
relOsPath = [osp|a|] </> [osp|rel|] </> [osp|path   |]

This prints:

"/<root-path>/a/rel/path   " -- unix
"/<root-path>/a/rel/path"    -- windows

This is the latest directory-1.3.9.0 and filepath-1.5.4.0.

I discovered this when a property test expected a filename to be preserved after calling makeAbsolute (which calls prependCurrentDirectory). On unix this test passed, but on windows it failed because the generated path had its trailing whitespace stripped.

My question, is this expected? I don't understand windows paths well enough to know what's going on here. The docs mention that some applications will strip whitespace when actually saving files, but it's not clear to me what part of directory's API is doing this. I attempted to investigate this here, though I was not able to pinpoint an exact cause (I do not have a windows machine, so I am relying on github's CI).

The examples are essentially:

Rel:                            print "a/rel/path   "
Rel normalise:                  print (OsP.normalise "a/rel/path   ")
Rel simplify:                   print (Internal.simplify "a/rel/path   ")

/ </> (root </> a/rel/path   ): print ("/" </> ("root" </> "a/rel/path   "))
/root </> a/rel/path   :        print (("/" </> "root") </> "a/rel/path   ")

Abs:                            print "/a/rel/path   "
Abs normalise:                  print (OsP.normalise "/a/rel/path   ")
Abs simplify:                   print (Internal.simplify "/a/rel/path   ")

Manual prepend:                 print ((</> "a/rel/path   ") <$> Internal.getCurrentDirectoryInternal)
My prepend:                     print (myPrependCurrentDirectory "a/rel/path   ")
Dir prepend:                    print (Internal.prependCurrentDirectory "a/rel/path   ")
Abs:                            print (Dir.makeAbsolute "a/rel/path   ")

And the output is (with links to CI output, though they're probably not publicly available):

-- ubuntu: https://github.com/tbidne/dir-abs-whitespace/actions/runs/13776102934/job/38525559164
Rel:                            "a/rel/path   "
Rel normalise:                  "a/rel/path   "
Rel simplify:                   "a/rel/path   "
                                
/ </> (root </> a/rel/path   ): "/root/a/rel/path   "
/root </> a/rel/path   :        "/root/a/rel/path   "
                                
Abs:                            "/a/rel/path   "
Abs normalise:                  "/a/rel/path   "
Abs simplify:                   "/a/rel/path   "
                                
Manual prepend:                 "/home/runner/work/dir-abs-whitespace/dir-abs-whitespace/a/rel/path   "
My prepend:                     "/home/runner/work/dir-abs-whitespace/dir-abs-whitespace/a/rel/path   "
Dir prepend:                    "/home/runner/work/dir-abs-whitespace/dir-abs-whitespace/a/rel/path   "
Abs:                            "/home/runner/work/dir-abs-whitespace/dir-abs-whitespace/a/rel/path   "
-- windows: https://github.com/tbidne/dir-abs-whitespace/actions/runs/13776102934/job/38525559428
Rel:                            "a\rel\path   "
Rel normalise:                  "a\rel\path   "
Rel simplify:                   "a\rel\path   "
                                
/ </> (root </> a/rel/path   ): "\root\a\rel\path   "
/root </> a/rel/path   :        "\root\a\rel\path   "
                                
Abs:                            "\a\rel\path   "
Abs normalise:                  "\a\rel\path   "
Abs simplify:                   "\a\rel\path   "
                                
Manual prepend:                 "D:\a\dir-abs-whitespace\dir-abs-whitespace\a\rel\path   "
My prepend:                     "D:\a\dir-abs-whitespace\dir-abs-whitespace\a\rel\path   "
Dir prepend:                    "D:\a\dir-abs-whitespace\dir-abs-whitespace\a\rel\path"
Abs:                            "D:\a\dir-abs-whitespace\dir-abs-whitespace\a\rel\path"

Even more bizarre, the "My prepend" example is just directory's Internal.prependCurrentDirectory directly copied, so I'm not sure where the discrepancy is. This code for this is here.

Thanks!

tbidne avatar Mar 10 '25 23:03 tbidne

Thanks for the detailed investigation. Note that Internal.Windows has a different implementation for Internal.prependCurrentDirectory: https://github.com/haskell/directory/blob/f95c5ea19e01d9e15c01f8fae797c943eb8549df/System/Directory/Internal/Windows.hsc#L497

In contrast, myPrependCurrentDirectory is using the Internal.Posix implementation: https://github.com/haskell/directory/blob/f95c5ea19e01d9e15c01f8fae797c943eb8549df/System/Directory/Internal/Posix.hsc#L190-L194


This behavior appears to be a quirk of the Win32 API GetFullPathName. Here is a minimum repro:

// test.c
#include <windows.h>
#include <stdio.h>

int main(void)
{
    wchar_t buf[512];
    if (!GetFullPathNameW(L"a\\rel\\path   ", sizeof(buf) / sizeof(*buf), buf, NULL)) {
        wprintf(L"GetFullPathName(...) failed with %lu\n", GetLastError());
    }
    wprintf(L"GetFullPathName(...) = '%ls'\n", buf);
    return 0;
}
$ x86_64-w64-mingw32-gcc test.c && ./a.exe
GetFullPathName(...) = 'C:\my_current_directory\a\rel\path'

It looks like other folks have encountered the same issue in Python before: https://bugs.python.org/issue18221

Rufflewind avatar Mar 14 '25 21:03 Rufflewind

Thanks for solving it! My main issue was that I did not understand if this is expected, and I suppose the answer to that question is "yes", in the sense that this is an upstream issue. It appears that filenames with trailing spaces are "invalid", even though it is entirely possible to create them.

You can't delete a file if the file name includes an invalid name. For example, the file name has a trailing space

source

ASCII Space (0x20) characters at the beginning or end of a file or folder name are removed by the Object Manager upon creation.

ASCII Period (0x2E) characters at the end of a file or folder name are removed by the Object Manager upon creation.

All other leading or trailing whitespace characters are retained by the Object Manager.

source

Imo this is slightly unclear as to what exactly is retained (everything else here?).

In any case, this issue can be closed as far as I am concerned. Alternatively, I can open a PR to document this on makeAbsolute, canonicalizePath, and prependCurrentDirectory, which I think are the only affected functions, if you like.

tbidne avatar Mar 17 '25 20:03 tbidne