diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fe58311d75..6f510ce659a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased + +* Fix importing a path containing a `?` character on Windows ([#989](https://github.com/evanw/esbuild/issues/989)) + + On Windows, the `?` character is not allowed in path names. This causes esbuild to fail to import paths containing this character. This is usually fine because people don't put `?` in their file names for this reason. However, the import paths for some ancient CSS code contains the `?` character as a hack to work around a bug in Internet Explorer: + + ```css + @font-face { + src: + url("./icons.eot?#iefix") format('embedded-opentype'), + url("./icons.woff2") format('woff2'), + url("./icons.woff") format('woff'), + url("./icons.ttf") format('truetype'), + url("./icons.svg#icons") format('svg'); + } + ``` + + The intent is for the bundler to ignore the `?#iefix` part. However, there may actually be a file called `icons.eot?#iefix` on the file system so esbuild checks the file system for both `icons.eot?#iefix` and `icons.eot`. This check was triggering this issue. With this release, an invalid path is considered the same as a missing file so bundling code like this should now work on Windows. + ## 0.9.3 * Fix path resolution with the `exports` field for scoped packages diff --git a/internal/fs/fs_real.go b/internal/fs/fs_real.go index 8a8e46e0b6c..0089e93bddd 100644 --- a/internal/fs/fs_real.go +++ b/internal/fs/fs_real.go @@ -131,7 +131,7 @@ func (fs *realFS) ReadDirectory(dir string) (DirEntries, error) { } // Cache miss: read the directory entries - names, err := readdir(dir) + names, err := fs.readdir(dir) entries := DirEntries{dir, make(map[string]*Entry)} // Unwrap to get the underlying error @@ -184,19 +184,7 @@ func (fs *realFS) ReadFile(path string) (string, error) { BeforeFileOpen() defer AfterFileClose() buffer, err := ioutil.ReadFile(path) - - // Unwrap to get the underlying error - if pathErr, ok := err.(*os.PathError); ok { - err = pathErr.Unwrap() - } - - // Windows returns ENOTDIR here even though nothing we've done yet has asked - // for a directory. This really means ENOENT on Windows. Return ENOENT here - // so callers that check for ENOENT will successfully detect this file as - // missing. - if err == syscall.ENOTDIR { - err = syscall.ENOENT - } + err = fs.canonicalizeError(err) // Allocate the string once fileContents := string(buffer) @@ -282,23 +270,11 @@ func (fs *realFS) Rel(base string, target string) (string, bool) { return "", false } -func readdir(dirname string) ([]string, error) { +func (fs *realFS) readdir(dirname string) ([]string, error) { BeforeFileOpen() defer AfterFileClose() f, err := os.Open(dirname) - - // Unwrap to get the underlying error - if pathErr, ok := err.(*os.PathError); ok { - err = pathErr.Unwrap() - } - - // Windows returns ENOTDIR here even though nothing we've done yet has asked - // for a directory. This really means ENOENT on Windows. Return ENOENT here - // so callers that check for ENOENT will successfully detect this directory - // as missing. - if err == syscall.ENOTDIR { - return nil, syscall.ENOENT - } + err = fs.canonicalizeError(err) // Stop now if there was an error if err != nil { @@ -319,6 +295,34 @@ func readdir(dirname string) ([]string, error) { return entries, err } +func (fs *realFS) canonicalizeError(err error) error { + // Unwrap to get the underlying error + if pathErr, ok := err.(*os.PathError); ok { + err = pathErr.Unwrap() + } + + // This has been copied from golang.org/x/sys/windows + const ERROR_INVALID_NAME syscall.Errno = 123 + + // Windows is much more restrictive than Unix about file names. If a file name + // is invalid, it will return ERROR_INVALID_NAME. Treat this as ENOENT (i.e. + // "the file does not exist") so that the resolver continues trying to resolve + // the path on this failure instead of aborting with an error. + if fs.fp.isWindows && err == ERROR_INVALID_NAME { + err = syscall.ENOENT + } + + // Windows returns ENOTDIR here even though nothing we've done yet has asked + // for a directory. This really means ENOENT on Windows. Return ENOENT here + // so callers that check for ENOENT will successfully detect this file as + // missing. + if err == syscall.ENOTDIR { + err = syscall.ENOENT + } + + return err +} + func (fs *realFS) kind(dir string, base string) (symlink string, kind EntryKind) { entryPath := fs.fp.join([]string{dir, base}) @@ -401,7 +405,7 @@ func (fs *realFS) WatchData() WatchData { case stateDirHasEntries: paths[path] = func() bool { - names, err := readdir(path) + names, err := fs.readdir(path) if err != nil || len(names) != len(data.dirEntries) { return true } diff --git a/scripts/end-to-end-tests.js b/scripts/end-to-end-tests.js index 583c222997b..b1d7abde41c 100644 --- a/scripts/end-to-end-tests.js +++ b/scripts/end-to-end-tests.js @@ -69,6 +69,17 @@ }), ) + // Test resolving paths with a question mark (an invalid path on Windows) + tests.push( + test(['entry.js', '--bundle', '--outfile=node.js'], { + 'entry.js': ` + import x from "./file.js?ignore-me" + if (x !== 123) throw 'fail' + `, + 'file.js': `export default 123`, + }), + ) + // Tests for symlinks // // Note: These are disabled on Windows because they fail when run with GitHub