diff --git a/.github/workflows/build-and-test-plugin-vscode.yml b/.github/workflows/build-and-test-plugin-vscode.yml index 54390521f3..1213b1ce02 100644 --- a/.github/workflows/build-and-test-plugin-vscode.yml +++ b/.github/workflows/build-and-test-plugin-vscode.yml @@ -66,10 +66,40 @@ jobs: cmake --build build-tools --config Debug --target quick-lint-js-build-tools - name: C++ configure + shell: "python3 {0}" run: | - env | grep '^CMAKE\|^QUICK_LINT_JS' | sort - cmake ${CMAKE_C_COMPILER:+-DCMAKE_C_COMPILER="${CMAKE_C_COMPILER}"} ${CMAKE_CXX_COMPILER:+-DCMAKE_CXX_COMPILER="${CMAKE_CXX_COMPILER}"} -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=NO -DCMAKE_C_FLAGS="${CMAKE_C_FLAGS}" -DCMAKE_CXX_FLAGS="${CMAKE_CXX_FLAGS}" -DCMAKE_EXE_LINKER_FLAGS="${CMAKE_EXE_LINKER_FLAGS}" -DCMAKE_SHARED_LINKER_FLAGS="${CMAKE_SHARED_LINKER_FLAGS}" -DQUICK_LINT_JS_ENABLE_VSCODE=YES -DCMAKE_POSITION_INDEPENDENT_CODE=YES -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=YES ${{ fromJSON('["", "-DQUICK_LINT_JS_USE_BUILD_TOOLS=${PWD}/build-tools"]')[matrix.os.cross_compiling] }} ${CMAKE_EXTRA_FLAGS} -S . -B build - shell: bash + import os + import shlex + import subprocess + + def var(name): + return os.environ.get(name, '') + + command = [ + "cmake", + "-DCMAKE_BUILD_TYPE=Release", + "-DBUILD_TESTING=NO", + "-DQUICK_LINT_JS_ENABLE_VSCODE=YES", + "-DCMAKE_POSITION_INDEPENDENT_CODE=YES", + "-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=YES", + "-S", ".", "-B", "build", + f"-DCMAKE_C_FLAGS={var('CMAKE_C_FLAGS')}", + f"-DCMAKE_CXX_FLAGS={var('CMAKE_CXX_FLAGS')}", + f"-DCMAKE_EXE_LINKER_FLAGS={var('CMAKE_EXE_LINKER_FLAGS')}", + f"-DCMAKE_SHARED_LINKER_FLAGS={var('CMAKE_SHARED_LINKER_FLAGS')}", + ] + c_compiler = var('CMAKE_C_COMPILER') + if c_compiler: command.append(f"-DCMAKE_C_COMPILER={c_compiler}") + cxx_compiler = var('CMAKE_CXX_COMPILER') + if cxx_compiler: command.append(f"-DCMAKE_CXX_COMPILER={cxx_compiler}") + if "${{ matrix.os.cross_compiling }}": + command.append(f"-DQUICK_LINT_JS_USE_BUILD_TOOLS={os.getcwd()}/build-tools") + command.extend(var('CMAKE_EXTRA_FLAGS').split()) + + print(" ".join(shlex.quote(arg) for arg in command), flush=True) + result = subprocess.run(command) + exit(result.returncode) + - name: C++ build run: cmake --build build --config Release --target quick-lint-js-vscode-node quick-lint-js-vscode-node-licenses - name: C++ install diff --git a/.github/workflows/build-static.yml b/.github/workflows/build-static.yml index 124cc8478f..b98668e38f 100644 --- a/.github/workflows/build-static.yml +++ b/.github/workflows/build-static.yml @@ -67,10 +67,38 @@ jobs: cmake --build build-tools --config Debug --target quick-lint-js-build-tools - name: configure + shell: "python3 {0}" run: | - env | grep '^CMAKE\|^QUICK_LINT_JS' | sort - cmake ${CMAKE_C_COMPILER:+-DCMAKE_C_COMPILER="${CMAKE_C_COMPILER}"} ${CMAKE_CXX_COMPILER:+-DCMAKE_CXX_COMPILER="${CMAKE_CXX_COMPILER}"} -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=${{ matrix.toolchain.test }} -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=YES -DCMAKE_C_FLAGS="${CMAKE_C_FLAGS}" -DCMAKE_CXX_FLAGS="${CMAKE_CXX_FLAGS}" -DCMAKE_EXE_LINKER_FLAGS="${CMAKE_EXE_LINKER_FLAGS}" -DCMAKE_SHARED_LINKER_FLAGS="${CMAKE_SHARED_LINKER_FLAGS}" ${{ fromJSON('["", "-DQUICK_LINT_JS_USE_BUILD_TOOLS=${PWD}/build-tools"]')[matrix.toolchain.cross_compiling] }} ${CMAKE_EXTRA_FLAGS} -S . -B build - shell: bash + import os + import shlex + import subprocess + + def var(name): + return os.environ.get(name, '') + + command = [ + "cmake", + "-DCMAKE_BUILD_TYPE=Release", + "-DBUILD_TESTING=${{ matrix.toolchain.test }}", + "-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=YES", + "-S", ".", "-B", "build", + f"-DCMAKE_C_FLAGS={var('CMAKE_C_FLAGS')}", + f"-DCMAKE_CXX_FLAGS={var('CMAKE_CXX_FLAGS')}", + f"-DCMAKE_EXE_LINKER_FLAGS={var('CMAKE_EXE_LINKER_FLAGS')}", + f"-DCMAKE_SHARED_LINKER_FLAGS={var('CMAKE_SHARED_LINKER_FLAGS')}", + ] + c_compiler = var('CMAKE_C_COMPILER') + if c_compiler: command.append(f"-DCMAKE_C_COMPILER={c_compiler}") + cxx_compiler = var('CMAKE_CXX_COMPILER') + if cxx_compiler: command.append(f"-DCMAKE_CXX_COMPILER={cxx_compiler}") + if "${{ matrix.toolchain.cross_compiling }}": + command.append(f"-DQUICK_LINT_JS_USE_BUILD_TOOLS={os.getcwd()}/build-tools") + command.extend(var('CMAKE_EXTRA_FLAGS').split()) + + print(" ".join(shlex.quote(arg) for arg in command), flush=True) + result = subprocess.run(command) + exit(result.returncode) + - name: build run: cmake --build build --config Release - name: test diff --git a/dist/arch/PKGBUILD-dev b/dist/arch/PKGBUILD-dev index 5603d6f8d2..526dff9513 100644 --- a/dist/arch/PKGBUILD-dev +++ b/dist/arch/PKGBUILD-dev @@ -10,7 +10,7 @@ pkgrel=1 pkgdesc="Find bugs in JavaScript programs" arch=(aarch64 arm armv6h armv7h i686 pentium4 x86_64) url="https://quick-lint-js.com/" -license=(Apache GPL3) +license=(Apache-2.0 GPL-3.0-or-later) depends=(gcc-libs glibc hicolor-icon-theme) makedepends=(cmake gcc git ninja) checkdepends=(icu) diff --git a/dist/arch/PKGBUILD-git b/dist/arch/PKGBUILD-git index ed4151dee4..115f363ec1 100644 --- a/dist/arch/PKGBUILD-git +++ b/dist/arch/PKGBUILD-git @@ -10,7 +10,7 @@ pkgrel=1 pkgdesc="Find bugs in JavaScript programs" arch=(aarch64 arm armv6h armv7h i686 pentium4 x86_64) url="https://quick-lint-js.com/" -license=(Apache GPL3) +license=(Apache-2.0 GPL-3.0-or-later) depends=(gcc-libs glibc hicolor-icon-theme) makedepends=(cmake gcc git ninja) checkdepends=(icu) diff --git a/dist/arch/PKGBUILD-release b/dist/arch/PKGBUILD-release index eb15b7191c..20e9a0a62b 100644 --- a/dist/arch/PKGBUILD-release +++ b/dist/arch/PKGBUILD-release @@ -10,7 +10,7 @@ pkgrel=1 pkgdesc="Find bugs in JavaScript programs" arch=(aarch64 arm armv6h armv7h i686 pentium4 x86_64) url="https://quick-lint-js.com/" -license=(Apache GPL3) +license=(Apache-2.0 GPL-3.0-or-later) depends=(gcc-libs glibc hicolor-icon-theme) makedepends=(cmake gcc ninja) checkdepends=(icu) diff --git a/dist/arch/lint.sh b/dist/arch/lint.sh index 839a5d6963..e394e746e4 100755 --- a/dist/arch/lint.sh +++ b/dist/arch/lint.sh @@ -11,10 +11,21 @@ cd "$(dirname "${0}")" errors="$(mktemp)" trap 'rm -f "${errors}"' EXIT +# HACK(strager): Disable the symlink check. The debug package +# references files in the main package +# (usr/lib/debug/.build-id/7d/de35aceb40462c945841b0d88b87fdfab87ea5 +# points to ../../../../bin/quick-lint-js), but because namcap lints +# each package separately, namcap doesn't see the file from the main +# package when linting the debug package. +# +# HACK(strager): Disable the emptydir check. The debug package, +# created automatically with OPTIONS=(debug strip), has an empty +# directory (usr/src/debug/quick-lint-js-dev/quick-lint-js/build). +# # HACK(strager): Disable the unusedsodepends check. With -Wl,--gc-sections, the # check fails on libm. Even with -Wl,--as-needed, the linker keeps the NEEDED # entry, so I don't know how to work around the libm dependency. -namcap --exclude=unusedsodepends PKGBUILD-dev PKGBUILD-git PKGBUILD-release ./quick-lint-js-*.pkg.tar.zst |& tee "${errors}" +namcap --exclude=emptydir,symlink,unusedsodepends PKGBUILD-dev PKGBUILD-git PKGBUILD-release ./quick-lint-js-*.pkg.tar.zst |& tee "${errors}" if [ -s "${errors}" ]; then printf 'error: namcap reported an error\n' >&2 exit 1 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index edd93b8219..a19a6edea5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,12 @@ Semantic Versioning. * TypeScript: `(): RT=>null` (with no spaces in `>=>`) now parses correctly. (Fixed by [vegerot][].) +* Fixed [E0718][] falsely diagnosing valid code. ([#1192][], [#1199][]) +* quick-lint-js no longer crashes in the presence of symbolic links and + directory junctions on Windows. ([#1182][]) +* Fixed a read buffer overflow (possibly leading to a crash) when checking + whether short identifiers containing Unicode escape sequences are keywords. + (x86 and x86_64 only.) ([#1191][]) ## 3.1.0 (2024-01-10) @@ -1416,7 +1422,11 @@ Beta release. [#1168]: https://github.com/quick-lint/quick-lint-js/pull/1168 [#1171]: https://github.com/quick-lint/quick-lint-js/issues/1171 [#1180]: https://github.com/quick-lint/quick-lint-js/issues/1180 +[#1182]: https://github.com/quick-lint/quick-lint-js/issues/1182 +[#1191]: https://github.com/quick-lint/quick-lint-js/issues/1191 +[#1192]: https://github.com/quick-lint/quick-lint-js/issues/1192 [#1194]: https://github.com/quick-lint/quick-lint-js/issues/1194 +[#1199]: https://github.com/quick-lint/quick-lint-js/issues/1199 [E0001]: https://quick-lint-js.com/errors/E0001/ [E0003]: https://quick-lint-js.com/errors/E0003/ diff --git a/plugin/vscode/package.json b/plugin/vscode/package.json index f485ec84bd..3c496e7ade 100644 --- a/plugin/vscode/package.json +++ b/plugin/vscode/package.json @@ -80,7 +80,7 @@ "test": "node test/run-vscode-tests.js" }, "devDependencies": { - "@vscode/test-electron": "2.3.0", + "@vscode/test-electron": "2.3.9", "colors": "1.4.0", "prettier": "2.8.4", "vsce": "2.15.0" diff --git a/plugin/vscode/yarn.lock b/plugin/vscode/yarn.lock index 5eeac1097e..60ec01d5fc 100644 --- a/plugin/vscode/yarn.lock +++ b/plugin/vscode/yarn.lock @@ -7,15 +7,15 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== -"@vscode/test-electron@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@vscode/test-electron/-/test-electron-2.3.0.tgz#de0ba2f5d36546a83cd481b458cbdbb7cc0f7049" - integrity sha512-fwzA9RtazH1GT/sckYlbxu6t5e4VaMXwCVtyLv4UAG0hP6NTfnMaaG25XCfWqlVwFhBMcQXHBCy5dmz2eLUnkw== +"@vscode/test-electron@2.3.9": + version "2.3.9" + resolved "https://registry.yarnpkg.com/@vscode/test-electron/-/test-electron-2.3.9.tgz#f61181392634b408411e4302aef6e1cd2dd41474" + integrity sha512-z3eiChaCQXMqBnk2aHHSEkobmC2VRalFQN0ApOAtydL172zXGxTwGrRtviT5HnUB+Q+G3vtEYFtuQkYqBzYgMA== dependencies: http-proxy-agent "^4.0.1" https-proxy-agent "^5.0.0" jszip "^3.10.1" - semver "^7.3.8" + semver "^7.5.2" agent-base@6: version "6.0.2" @@ -676,13 +676,20 @@ semver@^5.1.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== -semver@^7.3.5, semver@^7.3.8: +semver@^7.3.5: version "7.3.8" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== dependencies: lru-cache "^6.0.0" +semver@^7.5.2: + version "7.6.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" + integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== + dependencies: + lru-cache "^6.0.0" + setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index afb1acb118..727c548432 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -533,6 +533,7 @@ quick_lint_js_add_library( quick-lint-js/fe/language-debug.cpp quick-lint-js/fe/lex-debug.cpp quick-lint-js/i18n/po-parser-debug.cpp + quick-lint-js/io/file-path-debug.cpp quick-lint-js/lsp/lsp-location-debug.cpp quick-lint-js/port/char8-debug.cpp ) diff --git a/src/quick-lint-js/configuration/change-detecting-filesystem-win32.cpp b/src/quick-lint-js/configuration/change-detecting-filesystem-win32.cpp index 98a54b680c..5bdd267420 100644 --- a/src/quick-lint-js/configuration/change-detecting-filesystem-win32.cpp +++ b/src/quick-lint-js/configuration/change-detecting-filesystem-win32.cpp @@ -101,7 +101,7 @@ Change_Detecting_Filesystem_Win32::~Change_Detecting_Filesystem_Win32() { Result Change_Detecting_Filesystem_Win32::canonicalize_path(const std::string& path) { - return quick_lint_js::canonicalize_path(path); + return quick_lint_js::canonicalize_path(path, this); } Result @@ -122,6 +122,39 @@ Change_Detecting_Filesystem_Win32::read_file(const Canonical_Path& path) { return *std::move(r); } +void Change_Detecting_Filesystem_Win32::on_canonicalize_child_of_directory( + const char*) { + // We don't use char paths on Windows. + QLJS_UNIMPLEMENTED(); +} + +void Change_Detecting_Filesystem_Win32::on_canonicalize_child_of_directory( + const wchar_t* path) { + // TODO(strager): Only watch parents of symlinks and of the target file. For + // example: + // + // Given a symlink C:\foo\bar.txt pointing to D:\baz\qix.txt, + // and assuming read_file("C:\\foo\\bar.txt"), then we should create oplocks + // for only the following directories: + // + // C:\foo + // D:\baz + // + // But today, we create oplocks for C:\, C:\foo, D:\, and D:\baz. This is + // inefficient. + bool ok = this->watch_directory(path); + if (!ok) { + std::optional narrow_path = wstring_to_mbstring(path); + if (!narrow_path.has_value()) { + QLJS_UNIMPLEMENTED(); + } + this->watch_errors_.emplace_back(Watch_IO_Error{ + .path = narrow_path->c_str(), + .io_error = Windows_File_IO_Error{::GetLastError()}, + }); + } +} + bool Change_Detecting_Filesystem_Win32::handle_event( ::OVERLAPPED* overlapped, ::DWORD number_of_bytes_transferred, ::DWORD error) { @@ -179,9 +212,13 @@ bool Change_Detecting_Filesystem_Win32::watch_directory( if (!wpath.has_value()) { QLJS_UNIMPLEMENTED(); } + return this->watch_directory(wpath->c_str()); +} +bool Change_Detecting_Filesystem_Win32::watch_directory( + const wchar_t* directory) { Windows_Handle_File directory_handle(::CreateFileW( - wpath->c_str(), /*dwDesiredAccess=*/GENERIC_READ, + directory, /*dwDesiredAccess=*/GENERIC_READ, /*dwShareMode=*/FILE_SHARE_DELETE | FILE_SHARE_READ | FILE_SHARE_WRITE, /*lpSecurityAttributes=*/nullptr, /*dwCreationDisposition=*/OPEN_EXISTING, @@ -215,9 +252,9 @@ bool Change_Detecting_Filesystem_Win32::watch_directory( } QLJS_DEBUG_LOG( - "note: Directory handle %#llx: %s: Directory identity changed\n", + "note: Directory handle %#llx: %ls: Directory identity changed\n", reinterpret_cast(old_dir->directory_handle.get()), - directory.c_str()); + directory); this->cancel_watch(std::move(watched_directory_it->second)); watched_directory_it->second = std::move(new_dir); } @@ -249,6 +286,10 @@ bool Change_Detecting_Filesystem_Win32::watch_directory( DWORD error = ::GetLastError(); if (error == ERROR_IO_PENDING) { // run_io_thread will handle the oplock breaking. + QLJS_DEBUG_LOG( + "note: Watching directory with handle %#llx: %ls\n", + reinterpret_cast(dir->directory_handle.get()), + directory); } else { // FIXME(strager): Should we close the directory handle? return false; @@ -297,7 +338,7 @@ void Change_Detecting_Filesystem_Win32::handle_oplock_broke_event( // // https://docs.microsoft.com/en-us/windows/win32/api/winioctl/ni-winioctl-fsctl_request_oplock QLJS_DEBUG_LOG( - "note: Directory handle %#llx: %s: Oplock broke\n", + "note: Directory handle %#llx: %ls: Oplock broke\n", reinterpret_cast(dir->directory_handle.get()), directory_it->first.c_str()); QLJS_ASSERT(number_of_bytes_transferred == sizeof(dir->oplock_response)); diff --git a/src/quick-lint-js/configuration/change-detecting-filesystem.h b/src/quick-lint-js/configuration/change-detecting-filesystem.h index 0e04113d43..99b6516ede 100644 --- a/src/quick-lint-js/configuration/change-detecting-filesystem.h +++ b/src/quick-lint-js/configuration/change-detecting-filesystem.h @@ -153,7 +153,8 @@ extern ::DWORD mock_win32_force_directory_file_id_error; extern ::DWORD mock_win32_force_directory_ioctl_error; // Not thread-safe. -class Change_Detecting_Filesystem_Win32 : public Configuration_Filesystem { +class Change_Detecting_Filesystem_Win32 : public Configuration_Filesystem, + Canonicalize_Observer { public: explicit Change_Detecting_Filesystem_Win32( Windows_Handle_File_Ref io_completion_port, ::ULONG_PTR completion_key); @@ -164,6 +165,9 @@ class Change_Detecting_Filesystem_Win32 : public Configuration_Filesystem { Result read_file( const Canonical_Path&) override; + void on_canonicalize_child_of_directory(const char*) override; + void on_canonicalize_child_of_directory(const wchar_t*) override; + Windows_Handle_File_Ref io_completion_port() const { return this->io_completion_port_; } @@ -200,6 +204,7 @@ class Change_Detecting_Filesystem_Win32 : public Configuration_Filesystem { // Calls SetLastError and returns false on failure. bool watch_directory(const Canonical_Path&); + bool watch_directory(const wchar_t* path); void cancel_watch(std::unique_ptr&&); @@ -210,7 +215,7 @@ class Change_Detecting_Filesystem_Win32 : public Configuration_Filesystem { Windows_Handle_File_Ref io_completion_port_; ::ULONG_PTR completion_key_; - Hash_Map> + Hash_Map> watched_directories_; std::vector> cancelling_watched_directories_; diff --git a/src/quick-lint-js/container/hash.h b/src/quick-lint-js/container/hash.h index 8357a5d3fd..9b1740e61c 100644 --- a/src/quick-lint-js/container/hash.h +++ b/src/quick-lint-js/container/hash.h @@ -57,6 +57,15 @@ template <> struct Hasher : Hasher {}; #endif +template <> +struct Hasher { + std::size_t operator()(std::wstring_view s) const { + return std::hash()(s); + } +}; +template <> +struct Hasher : Hasher {}; + template struct Hasher> { template diff --git a/src/quick-lint-js/fe/lex.cpp b/src/quick-lint-js/fe/lex.cpp index 72c41b7a93..5c5be9a91d 100644 --- a/src/quick-lint-js/fe/lex.cpp +++ b/src/quick-lint-js/fe/lex.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -1832,9 +1833,15 @@ Lexer::Parsed_Identifier Lexer::parse_identifier_slow( } } + String8_View normalized_view = normalized.release_to_string_view(); + + // Add padding bytes required by Keyword_Lexer. This should not be considered + // part of the returned string. + normalized.resize(normalized.size() + Keyword_Lexer::padding_size); + return Parsed_Identifier{ .after = input, - .normalized = normalized.release_to_string_view(), + .normalized = normalized_view, .escape_sequences = escape_sequences, }; } diff --git a/src/quick-lint-js/io/file-canonical.cpp b/src/quick-lint-js/io/file-canonical.cpp index 9eee6132d9..aa546b0172 100644 --- a/src/quick-lint-js/io/file-canonical.cpp +++ b/src/quick-lint-js/io/file-canonical.cpp @@ -11,8 +11,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -31,6 +33,7 @@ #if QLJS_HAVE_WINDOWS_H #include +#include #include #endif @@ -303,7 +306,7 @@ class Path_Canonicalizer_Base { directory, does_not_exist, other, - symlink, + symlink_or_reparse_point, }; quick_lint_js::Result load_cwd() { @@ -387,9 +390,9 @@ class Path_Canonicalizer_Base { } break; - case File_Type::symlink: { + case File_Type::symlink_or_reparse_point: { quick_lint_js::Result r = - this->derived().resolve_symlink(); + this->derived().resolve_symlink_or_reparse_point(); if (!r.ok()) return r.propagate(); break; } @@ -425,6 +428,9 @@ class Path_Canonicalizer_Base { path_to_process_ = path_to_process_.substr(next_component_index); } + // TODO(strager): Have canonicalize_path accept an allocator. + Monotonic_Allocator allocator_{"Path_Canonicalizer_Base"}; + Canonicalize_Observer *observer_; Path_String_View original_path_; @@ -500,7 +506,7 @@ class POSIX_Path_Canonicalizer return failed_result(POSIX_File_IO_Error{errno}); } if (S_ISLNK(s.st_mode)) { - return File_Type::symlink; + return File_Type::symlink_or_reparse_point; } if (S_ISDIR(s.st_mode)) { return File_Type::directory; @@ -508,7 +514,8 @@ class POSIX_Path_Canonicalizer return File_Type::other; } - quick_lint_js::Result resolve_symlink() { + quick_lint_js::Result + resolve_symlink_or_reparse_point() { symlink_depth_ += 1; if (symlink_depth_ >= symlink_depth_limit_) { return failed_result(Canonicalizing_Path_IO_Error{ @@ -583,51 +590,22 @@ class Windows_Path_Canonicalizer quick_lint_js::Result process_start_of_path() { - std::wstring temp(path_to_process_); - - // The PathCch functions only support '\' as a directory separator. Convert - // all '/'s into '\'s. - for (wchar_t &c : temp) { - if (c == L'/') { - c = L'\\'; - } + // FIXME(strager): Do we need to copy to add the null terminator? If the + // null terminator is guaranteed to already exists, we should remove this + // copy. + std::wstring path(path_to_process_); + + // HACK(strager): PathCchSkipRoot and PathAllocCanonicalize don't support + // \??\ but does support \\?\. Convert \??\ to \\?\. + if (starts_with(std::wstring_view(path), LR"(\??\)"sv)) { + path[1] = L'\\'; } - wchar_t *root_end; - HRESULT result = ::PathCchSkipRoot(temp.data(), &root_end); - switch (result) { - case S_OK: - // Path is absolute. - QLJS_ASSERT(root_end != temp.data()); - - path_to_process_ = path_to_process_.substr(root_end - temp.data()); - skip_to_next_component(); - - // Drop '\' from 'C:\' if present. - if (root_end[-1] == L'\\') { - --root_end; - } - canonical_.assign(temp.data(), root_end); - - need_root_slash_ = true; - break; - - case HRESULT_FROM_WIN32(ERROR_INVALID_PARAMETER): { - // Path is invalid or is relative. Assume that it is relative. - quick_lint_js::Result r = load_cwd(); - if (!r.ok()) { - return failed_result(Canonicalizing_Path_IO_Error{ - .canonicalizing_path = Path_String(this->path_to_process_), - .io_error = r.error(), - }); - } - break; - } - - default: - QLJS_UNIMPLEMENTED(); - break; - } + Simplified_Path simplified_path = + simplify_path_and_make_absolute(&this->allocator_, path.c_str()); + this->canonical_ = simplified_path.root; + this->path_to_process_ = simplified_path.relative; + this->need_root_slash_ = true; return {}; } @@ -663,7 +641,7 @@ class Windows_Path_Canonicalizer return failed_result(Windows_File_IO_Error{error}); } if (attributes & FILE_ATTRIBUTE_REPARSE_POINT) { - return File_Type::symlink; + return File_Type::symlink_or_reparse_point; } if (attributes & FILE_ATTRIBUTE_DIRECTORY) { return File_Type::directory; @@ -671,9 +649,120 @@ class Windows_Path_Canonicalizer return File_Type::other; } - quick_lint_js::Result resolve_symlink() { - // TODO(strager): Support symlinks on Windows. - QLJS_UNIMPLEMENTED(); + quick_lint_js::Result + resolve_symlink_or_reparse_point() { + symlink_depth_ += 1; + if (symlink_depth_ >= symlink_depth_limit_) { + return failed_result(Canonicalizing_Path_IO_Error{ + .canonicalizing_path = canonical_, + .io_error = Windows_File_IO_Error{ERROR_CANT_RESOLVE_FILENAME}, + }); + } + + Windows_Handle_File file( + ::CreateFileW(canonical_.c_str(), FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + /*lpSecurityAttributes=*/nullptr, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + /*hTemplateFile=*/nullptr)); + if (!file.valid()) { + return failed_result(Canonicalizing_Path_IO_Error{ + .canonicalizing_path = canonical_, + .io_error = Windows_File_IO_Error{::GetLastError()}, + }); + } + + Monotonic_Allocator memory("resolve_symlink_or_reparse_point"); + Vector reparse_data_raw("reparse_data", &memory); + // See NOTE[reparse-point-null-terminator] for why we add sizeof(wchar_t). + reparse_data_raw.resize(MAXIMUM_REPARSE_DATA_BUFFER_SIZE + sizeof(wchar_t)); + + ::DWORD bytes_returned; + if (!::DeviceIoControl( + file.get(), FSCTL_GET_REPARSE_POINT, + /*lpInBuffer=*/nullptr, /*nInBufferSize=*/0, + /*lpOutBuffer=*/reparse_data_raw.data(), + /*nOutBufferSize=*/narrow_cast<::DWORD>(reparse_data_raw.size()), + &bytes_returned, /*lpOverlapped=*/nullptr)) { + return failed_result(Canonicalizing_Path_IO_Error{ + .canonicalizing_path = canonical_, + .io_error = Windows_File_IO_Error{::GetLastError()}, + }); + } + file.close(); + + ::REPARSE_DATA_BUFFER *reparse_data = + reinterpret_cast<::REPARSE_DATA_BUFFER *>(reparse_data_raw.data()); + switch (reparse_data->ReparseTag) { + // NOTE(strager): Mount points are also known as junctions. + case IO_REPARSE_TAG_MOUNT_POINT: { + // TODO(#1200): Make '..' after following the junction escape the + // junction. + + auto &mount_point = reparse_data->MountPointReparseBuffer; + std::wstring &new_readlink_buffer = + readlink_buffers_[1 - used_readlink_buffer_]; + // NOTE[reparse-point-null-terminator]: The path is not null-terminated. + std::wstring_view target( + &mount_point + .PathBuffer[mount_point.SubstituteNameOffset / sizeof(wchar_t)], + mount_point.SubstituteNameLength / sizeof(wchar_t)); + + // NOTE(strager): Mount point targets are always absolute. + new_readlink_buffer.reserve(target.size() + path_to_process_.size()); + new_readlink_buffer = target; + new_readlink_buffer += path_to_process_; + path_to_process_ = new_readlink_buffer; + // After assigning to path_to_process_, + // readlink_buffers_[used_readlink_buffer_] is no longer in use. + swap_readlink_buffers(); + + Result r = process_start_of_path(); + if (!r.ok()) return r.propagate(); + + break; + } + + case IO_REPARSE_TAG_SYMLINK: { + auto &symlink = reparse_data->SymbolicLinkReparseBuffer; + std::wstring &new_readlink_buffer = + readlink_buffers_[1 - used_readlink_buffer_]; + // NOTE[reparse-point-null-terminator]: The path is not null-terminated. + std::wstring_view symlink_target( + &symlink.PathBuffer[symlink.SubstituteNameOffset / sizeof(wchar_t)], + symlink.SubstituteNameLength / sizeof(wchar_t)); + + if (symlink.Flags & SYMLINK_FLAG_RELATIVE) { + canonical_ = parent_path(std::move(canonical_)); + new_readlink_buffer.reserve(canonical_.size() + 1 + + symlink_target.size() + + path_to_process_.size()); + new_readlink_buffer.clear(); + new_readlink_buffer += canonical_; + new_readlink_buffer += L'\\'; + new_readlink_buffer += symlink_target; + } else { + new_readlink_buffer.reserve(symlink_target.size() + + path_to_process_.size()); + new_readlink_buffer = symlink_target; + } + new_readlink_buffer += path_to_process_; + path_to_process_ = new_readlink_buffer; + // After assigning to path_to_process_, + // readlink_buffers_[used_readlink_buffer_] is no longer in use. + swap_readlink_buffers(); + + Result r = process_start_of_path(); + if (!r.ok()) return r.propagate(); + + break; + } + + default: + QLJS_UNIMPLEMENTED(); + break; + } + return {}; } diff --git a/src/quick-lint-js/io/file-path-debug.cpp b/src/quick-lint-js/io/file-path-debug.cpp new file mode 100644 index 0000000000..8a4da6f4a2 --- /dev/null +++ b/src/quick-lint-js/io/file-path-debug.cpp @@ -0,0 +1,42 @@ +// Copyright (C) 2020 Matthew "strager" Glazar +// See end of file for extended copyright information. + +#include +#include +#include +#include + +namespace quick_lint_js { +#if defined(_WIN32) +std::ostream& operator<<(std::ostream& out, Simplified_Path path) { + auto write_field = [&](const char* name, std::wstring_view s) -> void { + out << " ." << name << " = \"" << wstring_to_mbstring(s).value() + << "\",\n"; + }; + out << "Simplified_Path{\n"; + write_field("full_path", path.full_path); + write_field("root", path.root); + write_field("relative", path.relative); + out << "}"; + return out; +} +#endif +} + +// quick-lint-js finds bugs in JavaScript programs. +// Copyright (C) 2020 Matthew "strager" Glazar +// +// This file is part of quick-lint-js. +// +// quick-lint-js is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// quick-lint-js is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with quick-lint-js. If not, see . diff --git a/src/quick-lint-js/io/file-path.cpp b/src/quick-lint-js/io/file-path.cpp index 0340799cda..b1597995ae 100644 --- a/src/quick-lint-js/io/file-path.cpp +++ b/src/quick-lint-js/io/file-path.cpp @@ -2,6 +2,7 @@ // See end of file for extended copyright information. #include +#include #include #include #include @@ -34,20 +35,22 @@ void remove_trailing_slashes(std::string_view& path) { } #if QLJS_HAVE_WINDOWS_H -std::wstring wide_path_with_backslashes(const std::string& path) { - std::optional wpath = mbstring_to_wstring(path.c_str()); - if (!wpath.has_value()) { - QLJS_UNIMPLEMENTED(); - } - +void force_backslashes_in_path(std::wstring& path) { // The PathCch functions only support '\' as a directory separator. Convert // all '/'s into '\'s. - for (wchar_t& c : *wpath) { + for (wchar_t& c : path) { if (c == L'/') { c = L'\\'; } } +} +std::wstring wide_path_with_backslashes(const std::string& path) { + std::optional wpath = mbstring_to_wstring(path.c_str()); + if (!wpath.has_value()) { + QLJS_UNIMPLEMENTED(); + } + force_backslashes_in_path(*wpath); return std::move(*wpath); } @@ -79,6 +82,8 @@ std::string parent_path(std::string&& path) { #if QLJS_HAVE_DIRNAME return ::dirname(path.data()); #elif QLJS_HAVE_WINDOWS_H + // See also the std::wstring overload of parent_path. + HRESULT result; if (path == R"(\\?\)"sv || path == R"(\\?)"sv) { @@ -129,6 +134,45 @@ std::string parent_path(std::string&& path) { #endif } +#if QLJS_HAVE_WINDOWS_H +std::wstring parent_path(std::wstring&& path) { + // See also the std::string overload of parent_path. + + HRESULT result; + + if (path == LR"(\\?\)"sv || path == LR"(\\?)"sv) { + // Invalid path. Leave as-is. + return path; + } + + force_backslashes_in_path(path); + safely_remove_trailing_backslashes(path); + + result = ::PathCchRemoveFileSpec(path.data(), path.size() + 1); + switch (result) { + case S_OK: + break; + case S_FALSE: + // Path is a root path already. + break; + case HRESULT_FROM_WIN32(ERROR_INVALID_PARAMETER): + // Path is invalid. + QLJS_UNIMPLEMENTED(); + break; + default: + QLJS_UNIMPLEMENTED(); + break; + } + + path.resize(std::wcslen(path.data())); + if (path.empty()) { + return L"."; + } + + return path.substr(0, path.size()); +} +#endif + std::string_view path_file_name(std::string_view path) { #if QLJS_HAVE_WINDOWS_H { @@ -160,6 +204,78 @@ std::string_view path_file_name(std::string_view path) { } return path.substr(last_slash_index + 1); } + +#if defined(_WIN32) +Simplified_Path simplify_path_and_make_absolute(Monotonic_Allocator* allocator, + const wchar_t* path) { + Span absolute_path_buffer; + if (path[0] == L'\\' && path[1] == L'\\' && path[2] == L'?' && + path[3] == L'\\') { + // ::GetFullPathNameW mangles \\?\ paths, but we want \\?\ paths to be + // untouched. Also, ::PathCchSkipRoot treats \ and / the same, but they + // differ for \\?\ paths. Handle \\?\ paths specially. + absolute_path_buffer = allocator->new_objects_copy( + Span(path, std::wcslen(path) + 1)); + + const wchar_t* root_end = std::find(absolute_path_buffer.begin() + 4, + absolute_path_buffer.end() - 1, L'\\'); + + const wchar_t* relative_start = + *root_end == L'\\' ? root_end + 1 : root_end; + + return Simplified_Path{ + .full_path = absolute_path_buffer.data(), + .root = make_string_view(absolute_path_buffer.data(), root_end), + .relative = + make_string_view(relative_start, absolute_path_buffer.end() - 1), + }; + } + + if (path[0] == L'\0') { + // ::GetFullPathNameW returns 0 if path is empty, causing us to + // underallocate. ::PathCchSkipRoot also fails if path is empty. Avoid + // problems by special-casing empty inputs. + Span full_path = + allocator->new_objects_copy(Span({L'\0'})); + return Simplified_Path{ + .full_path = full_path.data(), + .root = std::wstring_view(), + .relative = std::wstring_view(), + }; + } + + ::DWORD absolute_path_buffer_size = + ::GetFullPathNameW(path, 0, nullptr, nullptr); + QLJS_ALWAYS_ASSERT(absolute_path_buffer_size > 0); + absolute_path_buffer = allocator->allocate_uninitialized_span( + absolute_path_buffer_size); + ::DWORD absolute_path_length = ::GetFullPathNameW( + path, absolute_path_buffer_size, absolute_path_buffer.data(), nullptr); + QLJS_ALWAYS_ASSERT(absolute_path_length < absolute_path_buffer_size); + QLJS_ALWAYS_ASSERT(absolute_path_buffer[absolute_path_length] == L'\0'); + absolute_path_buffer = + absolute_path_buffer.subspan(0, absolute_path_length + 1); + + const wchar_t* relative_start; + ::HRESULT result = + ::PathCchSkipRoot(absolute_path_buffer.data(), &relative_start); + if (result != S_OK) { + QLJS_UNIMPLEMENTED(); + } + const wchar_t* root_end = relative_start; + if (root_end != path && root_end[-1] == L'\\') { + // Don't include the trailing '\'. + root_end -= 1; + } + + return Simplified_Path{ + .full_path = absolute_path_buffer.data(), + .root = make_string_view(absolute_path_buffer.data(), root_end), + .relative = + make_string_view(relative_start, absolute_path_buffer.end() - 1), + }; +} +#endif } // quick-lint-js finds bugs in JavaScript programs. diff --git a/src/quick-lint-js/io/file-path.h b/src/quick-lint-js/io/file-path.h index 9747fd716a..75a5570580 100644 --- a/src/quick-lint-js/io/file-path.h +++ b/src/quick-lint-js/io/file-path.h @@ -3,9 +3,12 @@ #pragma once +#include +#include #include #include #include +#include #if defined(_WIN32) #define QLJS_PREFERRED_PATH_DIRECTORY_SEPARATOR "\\" @@ -20,8 +23,47 @@ namespace quick_lint_js { std::string parent_path(std::string&&); +#if defined(_WIN32) +std::wstring parent_path(std::wstring&&); +#endif std::string_view path_file_name(std::string_view); + +#if defined(_WIN32) +struct Simplified_Path { + // Null-terminated absolute path. + wchar_t* full_path; + + // Root portion of the path. Substring of full_path. Does not contain a + // trailing '\'. + std::wstring_view root; + + // Relative portion of the path. Substring of full_path. Does not start with a + // leading '\'. + std::wstring_view relative; + + friend std::ostream& operator<<(std::ostream&, Simplified_Path); +}; + +// Simplify (resolve '.' and '..') and make the path absolute (based on the +// current working directories). +// +// This function should not change the path according to ::CreateFileW and other +// Win32 APIs. +// +// * Preserves at most one trailing '\'. +// * Combines redundant '\' characters. +// * Expands relative paths into absolute paths using the process's current +// working directory and the process's per-drive working directories. +// * Does not resolve symlinks, junctions, shortcuts, etc. +// * Does not check validity of the path. +// * Does not check for existence of directories and files in the path. +// * Does not convert 8.3 names into long names. +// +// Returns pointers into memory allocated by 'allocator'. +Simplified_Path simplify_path_and_make_absolute(Monotonic_Allocator* allocator, + const wchar_t* path); +#endif } // quick-lint-js finds bugs in JavaScript programs. diff --git a/src/quick-lint-js/io/file.cpp b/src/quick-lint-js/io/file.cpp index 0434ce06d4..c990b91ebc 100644 --- a/src/quick-lint-js/io/file.cpp +++ b/src/quick-lint-js/io/file.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -40,6 +41,7 @@ #if QLJS_HAVE_WINDOWS_H #include +#include #endif #if QLJS_HAVE_WINDOWS_H @@ -168,6 +170,25 @@ std::string Write_File_IO_Error::to_string() const { std::exit(1); } +std::string Delete_File_IO_Error::to_string() const { + return "failed to delete "s + this->path + ": "s + this->io_error.to_string(); +} + +[[noreturn]] void Delete_File_IO_Error::print_and_exit() const { + std::fprintf(stderr, "error: %s\n", this->to_string().c_str()); + std::exit(1); +} + +std::string Symlink_IO_Error::to_string() const { + return "failed to create symlink to "s + this->target + " at " + this->path + + ": "s + this->io_error.to_string(); +} + +[[noreturn]] void Symlink_IO_Error::print_and_exit() const { + std::fprintf(stderr, "error: %s\n", this->to_string().c_str()); + std::exit(1); +} + bool operator==(const Read_File_IO_Error &lhs, const Read_File_IO_Error &rhs) { return lhs.path == rhs.path && lhs.io_error == rhs.io_error; } @@ -411,6 +432,215 @@ bool file_ids_equal(const ::FILE_ID_INFO &a, const ::FILE_ID_INFO &b) { std::memcmp(&b.FileId, &a.FileId, sizeof(b.FileId)) == 0; } #endif + +namespace { +Result create_posix_symbolic_link( + const char *path, const char *target, [[maybe_unused]] bool is_directory) { +#if defined(QLJS_FILE_POSIX) + int rc = ::symlink(target, path); + if (rc != 0) { + return failed_result(Symlink_IO_Error{ + .path = path, + .target = target, + .io_error = POSIX_File_IO_Error{errno}, + }); + } + return {}; +#elif defined(QLJS_FILE_WINDOWS) + std::optional wpath = mbstring_to_wstring(path); + std::optional wtarget = mbstring_to_wstring(target); + if (!wpath.has_value() || !wtarget.has_value()) { + return failed_result(Symlink_IO_Error{ + .path = path, + .target = target, + .io_error = Windows_File_IO_Error{ERROR_INVALID_PARAMETER}, + }); + } + + // TODO(strager): Ensure a relative target path creates relative symlinks, not + // absolute symlinks resolved to the current working directory. + // + // FIXME(strager): With SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE, + // ::CreateSymbolicLinkW can fail with ERROR_INVALID_PARAMETER or maybe + // something else. Need to test more Windows versions. + // + // NOTE(strager): ::CreateSymbolicLinkW fails with ERROR_PRIVILEGE_NOT_HELD + // (1314) if SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE is not set. + if (!::CreateSymbolicLinkW( + wpath->c_str(), wtarget->c_str(), + SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE | + (is_directory ? SYMBOLIC_LINK_FLAG_DIRECTORY : 0))) { + return failed_result(Symlink_IO_Error{ + .path = path, + .target = target, + .io_error = Windows_File_IO_Error{::GetLastError()}, + }); + } + + return {}; +#else +#error "Unknown platform" +#endif +} +} + +Result create_posix_directory_symbolic_link( + const char *path, const char *target) { + return create_posix_symbolic_link(path, target, /*is_directory=*/true); +} + +Result create_posix_file_symbolic_link( + const char *path, const char *target) { + return create_posix_symbolic_link(path, target, /*is_directory=*/false); +} + +void create_posix_directory_symbolic_link_or_exit(const char *path, + const char *target) { + Result result = + create_posix_directory_symbolic_link(path, target); + if (!result.ok()) { + result.error().print_and_exit(); + } +} + +void create_posix_file_symbolic_link_or_exit(const char *path, + const char *target) { + Result result = + create_posix_file_symbolic_link(path, target); + if (!result.ok()) { + result.error().print_and_exit(); + } +} + +Result delete_posix_symbolic_link( + const char *path) { +#if defined(QLJS_FILE_POSIX) + int rc = ::unlink(path); + if (rc != 0) { + return failed_result(Delete_File_IO_Error{ + .path = path, + .io_error = POSIX_File_IO_Error{errno}, + }); + } + return {}; +#elif defined(QLJS_FILE_WINDOWS) + std::optional wpath = mbstring_to_wstring(path); + if (!wpath.has_value()) { + return failed_result(Delete_File_IO_Error{ + .path = path, + .io_error = Windows_File_IO_Error{ERROR_INVALID_PARAMETER}, + }); + } + + ::DWORD attributes = ::GetFileAttributesW(wpath->c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES) { + return failed_result(Delete_File_IO_Error{ + .path = path, + .io_error = Windows_File_IO_Error{::GetLastError()}, + }); + } + if (attributes & FILE_ATTRIBUTE_DIRECTORY) { + if (!::RemoveDirectoryW(wpath->c_str())) { + return failed_result(Delete_File_IO_Error{ + .path = path, + .io_error = Windows_File_IO_Error{::GetLastError()}, + }); + } + } else { + if (!::DeleteFileW(wpath->c_str())) { + return failed_result(Delete_File_IO_Error{ + .path = path, + .io_error = Windows_File_IO_Error{::GetLastError()}, + }); + } + } + + return {}; +#else +#error "Unknown platform" +#endif +} + +void delete_posix_symbolic_link_or_exit(const char *path) { + Result result = delete_posix_symbolic_link(path); + if (!result.ok()) { + result.error().print_and_exit(); + } +} + +#if defined(QLJS_FILE_WINDOWS) +void create_windows_junction_or_exit(const char *path, const char *target) { + Monotonic_Allocator memory("create_windows_junction"); + + std::optional wpath = mbstring_to_wstring(path); + std::optional wtarget = mbstring_to_wstring(target); + if (!wpath.has_value() || !wtarget.has_value()) { + std::fprintf(stderr, "fatal: cannot convert path to wide string\n"); + std::abort(); + } + if (!::CreateDirectoryW(wpath->c_str(), /*lpSecurityAttributes=*/nullptr)) { + std::fprintf(stderr, "fatal: CreateDirectoryW failed\n"); + std::abort(); + } + Windows_Handle_File file( + ::CreateFileW(wpath->c_str(), GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + /*lpSecurityAttributes=*/nullptr, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + /*hTemplateFile=*/nullptr)); + + std::size_t reparse_data_data_size = + offsetof( + decltype( + std::declval<::REPARSE_DATA_BUFFER>().MountPointReparseBuffer), + PathBuffer) + + // SubsituteName. +1 for null terminator. + (wtarget->size() + 1) * sizeof(wchar_t) + + // PrintName. Empty string with null terminator. + sizeof(wchar_t); + std::size_t reparse_data_total_size = + offsetof(::REPARSE_DATA_BUFFER, MountPointReparseBuffer) + + reparse_data_data_size; + ::REPARSE_DATA_BUFFER *reparse_data = + reinterpret_cast<::REPARSE_DATA_BUFFER *>(memory.allocate( + reparse_data_total_size, alignof(::REPARSE_DATA_BUFFER))); + reparse_data->ReparseTag = IO_REPARSE_TAG_MOUNT_POINT; + reparse_data->ReparseDataLength = reparse_data_data_size; + reparse_data->Reserved = 0; + + // Write the null-terminated SubstituteName (*wtarget) followed by the + // null-terminated PrintName (L""). + { + wchar_t *path_buffer = reparse_data->MountPointReparseBuffer.PathBuffer; + auto current_offset = [&]() -> ::USHORT { + return narrow_cast<::USHORT>( + (reinterpret_cast(path_buffer) - + reinterpret_cast( + reparse_data->MountPointReparseBuffer.PathBuffer))); + }; + reparse_data->MountPointReparseBuffer.SubstituteNameOffset = + current_offset(); + reparse_data->MountPointReparseBuffer.SubstituteNameLength = + wtarget->size() * sizeof(wchar_t); + path_buffer = std::copy(wtarget->begin(), wtarget->end(), path_buffer); + *path_buffer++ = L'\0'; + + reparse_data->MountPointReparseBuffer.PrintNameOffset = current_offset(); + reparse_data->MountPointReparseBuffer.PrintNameLength = 0; + *path_buffer++ = L'\0'; + } + + if (!::DeviceIoControl(file.get(), FSCTL_SET_REPARSE_POINT, reparse_data, + narrow_cast<::DWORD>(reparse_data_total_size), + /*lpOutBuffer=*/nullptr, + /*nOutBufferSize=*/0, + /*lpBytesReturned=*/nullptr, + /*lpOverlapped=*/nullptr)) { + std::fprintf(stderr, "fatal: DeviceIoControl failed\n"); + std::abort(); + } +} +#endif } #endif diff --git a/src/quick-lint-js/io/file.h b/src/quick-lint-js/io/file.h index a273cab5ac..732e3d7a49 100644 --- a/src/quick-lint-js/io/file.h +++ b/src/quick-lint-js/io/file.h @@ -46,6 +46,23 @@ struct Write_File_IO_Error { [[noreturn]] void print_and_exit() const; }; +struct Symlink_IO_Error { + std::string path; + std::string target; + Platform_File_IO_Error io_error; + + std::string to_string() const; + [[noreturn]] void print_and_exit() const; +}; + +struct Delete_File_IO_Error { + std::string path; + Platform_File_IO_Error io_error; + + std::string to_string() const; + [[noreturn]] void print_and_exit() const; +}; + Result read_file(const std::string &path); Result read_file(const char *path); Result read_file(const char *path, @@ -77,6 +94,31 @@ Result open_file_for_writing( #if QLJS_HAVE_WINDOWS_H bool file_ids_equal(const ::FILE_ID_INFO &, const ::FILE_ID_INFO &); #endif + +// Create a POSIX/UNIX-style symbolic link. +// +// On POSIX platforms like Linux and macOS, calls ::symlink. +// create_posix_directory_symbolic_link and create_posix_file_symbolic_link +// behave identically. +// +// On Windows, calls ::CreateSymbolicLinkW with +// SYMBOLIC_LINK_FLAG_DIRECTORY (for create_posix_directory_symbolic_link) or +// without SYMBOLIC_LINK_FLAG_DIRECTORY (for create_posix_file_symbolic_link). +Result create_posix_directory_symbolic_link( + const char *path, const char *target); +Result create_posix_file_symbolic_link( + const char *path, const char *target); +void create_posix_directory_symbolic_link_or_exit(const char *path, + const char *target); +void create_posix_file_symbolic_link_or_exit(const char *path, + const char *target); + +Result delete_posix_symbolic_link(const char *path); +void delete_posix_symbolic_link_or_exit(const char *path); + +// target must begin with \??\. +void create_windows_junction_or_exit(const char *path, const char *target); + } #endif diff --git a/src/quick-lint-js/io/temporary-directory.cpp b/src/quick-lint-js/io/temporary-directory.cpp index 7791c263d5..95f9358d3c 100644 --- a/src/quick-lint-js/io/temporary-directory.cpp +++ b/src/quick-lint-js/io/temporary-directory.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -354,7 +355,7 @@ Result list_directory( Result list_directory( const char *directory, - Temporary_Function_Ref visit_file) { + Temporary_Function_Ref visit_file) { #if QLJS_HAVE_WINDOWS_H return list_directory_raw(directory, [&](::WIN32_FIND_DATAW &entry) -> void { // TODO(strager): Reduce allocations. @@ -364,9 +365,17 @@ Result list_directory( QLJS_UNIMPLEMENTED(); } if (!is_dot_or_dot_dot(entry_name->c_str())) { - bool is_directory = (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == - FILE_ATTRIBUTE_DIRECTORY; - visit_file(entry_name->c_str(), is_directory); + File_Type_Flags flags = File_Type_Flags::none; + if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == + FILE_ATTRIBUTE_DIRECTORY) { + flags = enum_set_flags(flags, File_Type_Flags::is_directory); + } + if ((entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == + FILE_ATTRIBUTE_REPARSE_POINT) { + flags = enum_set_flags( + flags, File_Type_Flags::is_symbolic_link_or_reparse_point); + } + visit_file(entry_name->c_str(), flags); } }); #elif QLJS_HAVE_DIRENT_H @@ -375,8 +384,9 @@ Result list_directory( if (is_dot_or_dot_dot(entry->d_name)) { return; } - bool is_directory; - if (entry->d_type == DT_UNKNOWN) { + File_Type_Flags flags = File_Type_Flags::none; + switch (entry->d_type) { + case DT_UNKNOWN: { temp_path.clear(); temp_path += directory; temp_path += QLJS_PREFERRED_PATH_DIRECTORY_SEPARATOR; @@ -387,14 +397,31 @@ Result list_directory( if (errno == ENOENT) { return; } - is_directory = false; } else { - is_directory = S_ISDIR(s.st_mode); + if (S_ISDIR(s.st_mode)) { + flags = enum_set_flags(flags, File_Type_Flags::is_directory); + } + if (S_ISLNK(s.st_mode)) { + flags = enum_set_flags( + flags, File_Type_Flags::is_symbolic_link_or_reparse_point); + } } - } else { - is_directory = entry->d_type == DT_DIR; + break; + } + + case DT_DIR: + flags = enum_set_flags(flags, File_Type_Flags::is_directory); + break; + + case DT_LNK: + flags = enum_set_flags( + flags, File_Type_Flags::is_symbolic_link_or_reparse_point); + break; + + default: + break; } - visit_file(entry->d_name, is_directory); + visit_file(entry->d_name, flags); }); #else #error "Unsupported platform" @@ -419,14 +446,16 @@ void list_directory_recursively(const char *directory, std::size_t path_length = this->path.size(); auto visit_child = [&](const char *child_name, - bool is_directory) -> void { + File_Type_Flags flags) -> void { this->path.resize(path_length); this->path += QLJS_PREFERRED_PATH_DIRECTORY_SEPARATOR; this->path += child_name; - if (is_directory) { + if (enum_has_flags(flags, File_Type_Flags::is_directory) && + !enum_has_flags( + flags, File_Type_Flags::is_symbolic_link_or_reparse_point)) { this->recurse(depth + 1); } else { - this->visitor.visit_file(path); + this->visitor.visit_file(path, flags); } }; diff --git a/src/quick-lint-js/io/temporary-directory.h b/src/quick-lint-js/io/temporary-directory.h index f582822b83..fc5310d510 100644 --- a/src/quick-lint-js/io/temporary-directory.h +++ b/src/quick-lint-js/io/temporary-directory.h @@ -38,6 +38,13 @@ void create_directory_or_exit(const std::string& path); Result make_timestamped_directory( std::string_view parent_directory, const char* format); +enum class File_Type_Flags : std::uint8_t { + none = 0, + + is_directory = 1 << 0, + is_symbolic_link_or_reparse_point = 1 << 1, +}; + // Call visit_file for each child of the given directory. // // '.' and '..' are excluded. @@ -48,7 +55,7 @@ Result list_directory( Temporary_Function_Ref visit_file); Result list_directory( const char* directory, - Temporary_Function_Ref visit_file); + Temporary_Function_Ref visit_file); QLJS_WARNING_PUSH // https://gcc.gnu.org/bugzilla/show_bug.cgi?id=69210 @@ -61,7 +68,10 @@ class List_Directory_Visitor { // 'directory' given to list_directory_recursively. // // visit_file is not called for '.' or '..' entries. - virtual void visit_file(const std::string& path) = 0; + // + // On Windows, visit_file is called for directory symbolic links and directory + // reparse points. + virtual void visit_file(const std::string& path, File_Type_Flags flags) = 0; // Called before descending into a directory. virtual void visit_directory_pre(const std::string& path); diff --git a/src/quick-lint-js/port/span.h b/src/quick-lint-js/port/span.h index 2000b5bef9..8dcbedf213 100644 --- a/src/quick-lint-js/port/span.h +++ b/src/quick-lint-js/port/span.h @@ -76,6 +76,15 @@ class Span { bool empty() const { return this->size() == 0; } + Span subspan(Span_Size offset, Span_Size count) { + // TODO(strager): Be lax with offset and count. + QLJS_ASSERT(offset >= 0); + QLJS_ASSERT(offset <= this->size()); + QLJS_ASSERT(count >= 0); + QLJS_ASSERT(count + offset <= this->size()); + return Span(this->begin() + offset, this->begin() + offset + count); + } + friend bool operator==(Span lhs, Span rhs) { return std::equal(lhs.begin(), lhs.end(), rhs.begin(), rhs.end()); } diff --git a/src/quick-lint-js/port/windows.h b/src/quick-lint-js/port/windows.h index c316b5ef54..053c06f2d8 100644 --- a/src/quick-lint-js/port/windows.h +++ b/src/quick-lint-js/port/windows.h @@ -64,6 +64,36 @@ HRESULT WINAPI SetThreadDescription(HANDLE hThread, PCWSTR lpThreadDescription); } #endif +// +// https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_reparse_data_buffer +typedef struct _REPARSE_DATA_BUFFER { + ULONG ReparseTag; + USHORT ReparseDataLength; + USHORT Reserved; + union { + struct { + USHORT SubstituteNameOffset; + USHORT SubstituteNameLength; + USHORT PrintNameOffset; + USHORT PrintNameLength; + ULONG Flags; + WCHAR PathBuffer[1]; + } SymbolicLinkReparseBuffer; + struct { + USHORT SubstituteNameOffset; + USHORT SubstituteNameLength; + USHORT PrintNameOffset; + USHORT PrintNameLength; + WCHAR PathBuffer[1]; + } MountPointReparseBuffer; + struct { + UCHAR DataBuffer[1]; + } GenericReparseBuffer; + } DUMMYUNIONNAME; +} REPARSE_DATA_BUFFER, *PREPARSE_DATA_BUFFER; + +#define SYMLINK_FLAG_RELATIVE 1 + // quick-lint-js finds bugs in JavaScript programs. // Copyright (C) 2020 Matthew "strager" Glazar // diff --git a/test/quick-lint-js/file-matcher.h b/test/quick-lint-js/file-matcher.h index 4ed89f8f65..0c5966204f 100644 --- a/test/quick-lint-js/file-matcher.h +++ b/test/quick-lint-js/file-matcher.h @@ -127,14 +127,16 @@ inline ::testing::AssertionResult assert_same_file( return assert_same_file(lhs_expr, rhs_expr, lhs_path.c_str(), rhs_path); } +// Does not follow symlinks. inline ::testing::AssertionResult assert_file_does_not_exist(const char* expr, const char* path) { bool exists; #if QLJS_HAVE_STD_FILESYSTEM - exists = std::filesystem::exists(std::filesystem::path(path)); + exists = std::filesystem::exists( + std::filesystem::symlink_status(std::filesystem::path(path))); #elif QLJS_HAVE_SYS_STAT_H struct ::stat s; - if (::stat(path, &s) == 0) { + if (::lstat(path, &s) == 0) { exists = true; } else { switch (errno) { diff --git a/test/quick-lint-js/filesystem-test.cpp b/test/quick-lint-js/filesystem-test.cpp index 064c0f13b0..133d399344 100644 --- a/test/quick-lint-js/filesystem-test.cpp +++ b/test/quick-lint-js/filesystem-test.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -35,7 +36,8 @@ namespace quick_lint_js { void delete_directory_recursive(const std::string& path) { struct Delete_Visitor : public List_Directory_Visitor { - void visit_file(const std::string& path) override { + void visit_file(const std::string& path, + [[maybe_unused]] File_Type_Flags flags) override { #if QLJS_HAVE_UNISTD_H int rc = std::remove(path.c_str()); if (rc != 0) { @@ -50,9 +52,20 @@ void delete_directory_recursive(const std::string& path) { path.c_str()); return; } - if (!::DeleteFileW(wpath->c_str())) { - std::fprintf(stderr, "warning: failed to delete %s: %s\n", path.c_str(), - windows_error_message(::GetLastError()).c_str()); + if (enum_has_flags(flags, File_Type_Flags::is_directory)) { + QLJS_ASSERT(enum_has_flags( + flags, File_Type_Flags::is_symbolic_link_or_reparse_point)); + if (!::RemoveDirectoryW(wpath->c_str())) { + std::fprintf( + stderr, "warning: failed to delete directory symlink %s: %s\n", + path.c_str(), windows_error_message(::GetLastError()).c_str()); + } + } else { + if (!::DeleteFileW(wpath->c_str())) { + std::fprintf(stderr, "warning: failed to delete %s: %s\n", + path.c_str(), + windows_error_message(::GetLastError()).c_str()); + } } #else #error "Unsupported platform" diff --git a/test/test-configuration-loader.cpp b/test/test-configuration-loader.cpp index abb9400184..84a0e1b43d 100644 --- a/test/test-configuration-loader.cpp +++ b/test/test-configuration-loader.cpp @@ -1963,8 +1963,6 @@ TEST_F(Test_Configuration_Loader, } #endif -// TODO(strager): Test symlinks on Windows too. -#if QLJS_HAVE_UNISTD_H TEST_F(Test_Configuration_Loader, changing_direct_config_path_symlink_is_detected_as_change) { std::string project_dir = this->make_temporary_directory(); @@ -1974,15 +1972,16 @@ TEST_F(Test_Configuration_Loader, std::string after_config_file = project_dir + "/after.config"; write_file_or_exit(after_config_file, u8R"({"globals": {"after": true}})"_sv); std::string config_symlink = project_dir + "/quick-lint-js.config"; - ASSERT_EQ(::symlink("before.config", config_symlink.c_str()), 0) - << std::strerror(errno); + create_posix_file_symbolic_link_or_exit(config_symlink.c_str(), + "before.config"); Change_Detecting_Configuration_Loader loader; loader.watch_and_load_config_file(config_symlink, /*token=*/&config_symlink); ASSERT_EQ(std::remove(config_symlink.c_str()), 0) << std::strerror(errno); - ASSERT_EQ(::symlink("after.config", config_symlink.c_str()), 0) - << std::strerror(errno); + + create_posix_file_symbolic_link_or_exit(config_symlink.c_str(), + "after.config"); Span changes = loader.detect_changes_and_refresh(); ASSERT_THAT(changes, ElementsAreArray({::testing::_})); @@ -2009,16 +2008,15 @@ TEST_F(Test_Configuration_Loader, std::string after_config_file = project_dir + "/after/quick-lint-js.config"; write_file_or_exit(after_config_file, u8R"({"globals": {"after": true}})"_sv); std::string subdir_symlink = project_dir + "/subdir"; - ASSERT_EQ(::symlink("before", subdir_symlink.c_str()), 0) - << std::strerror(errno); + create_posix_directory_symbolic_link_or_exit(subdir_symlink.c_str(), + "before"); Change_Detecting_Configuration_Loader loader; loader.watch_and_load_config_file(subdir_symlink + "/quick-lint-js.config", /*token=*/nullptr); - ASSERT_EQ(std::remove(subdir_symlink.c_str()), 0) << std::strerror(errno); - ASSERT_EQ(::symlink("after", subdir_symlink.c_str()), 0) - << std::strerror(errno); + delete_posix_symbolic_link_or_exit(subdir_symlink.c_str()); + create_posix_directory_symbolic_link_or_exit(subdir_symlink.c_str(), "after"); Span changes = loader.detect_changes_and_refresh(); ASSERT_THAT(changes, ElementsAreArray({::testing::_})); @@ -2032,7 +2030,6 @@ TEST_F(Test_Configuration_Loader, EXPECT_THAT(loader.detect_changes_and_refresh(), IsEmpty()); } -#endif TEST_F(Test_Configuration_Loader, swapping_parent_directory_with_another_is_detected_as_change) { diff --git a/test/test-file-canonical.cpp b/test/test-file-canonical.cpp index 363ee78b18..7894c645a0 100644 --- a/test/test-file-canonical.cpp +++ b/test/test-file-canonical.cpp @@ -27,10 +27,10 @@ #include #if defined(_WIN32) -// TODO(strager): This should be 1, not 0. Windows allows you to access -// 'c:\file.txt\.', for example. -#define QLJS_FILE_PATH_ALLOWS_FOLLOWING_COMPONENTS 0 +// Windows allows you to access 'c:\file.txt\.', for example. +#define QLJS_FILE_PATH_ALLOWS_FOLLOWING_COMPONENTS 1 #else +// POSIX does not allow you to access '/file.txt/.'. #define QLJS_FILE_PATH_ALLOWS_FOLLOWING_COMPONENTS 0 #endif @@ -262,7 +262,7 @@ TEST_F(Test_File_Canonical, canonical_path_removes_trailing_dot_component) { } TEST_F(Test_File_Canonical, - canonical_path_fails_with_dot_component_after_regular_file) { + canonical_path_fails_with_dot_and_component_after_regular_file) { std::string temp_dir = this->make_temporary_directory(); write_file_or_exit(temp_dir + "/just-a-file", u8""_sv); @@ -281,6 +281,36 @@ TEST_F(Test_File_Canonical, #endif } +#if QLJS_FILE_PATH_ALLOWS_FOLLOWING_COMPONENTS +TEST_F(Test_File_Canonical, canonical_path_removes_dot_after_regular_file) { + std::string temp_dir = this->make_temporary_directory(); + write_file_or_exit(temp_dir + "/just-a-file", u8""_sv); + + std::string input_path = temp_dir + "/just-a-file/."; + Result canonical = + canonicalize_path(input_path); + ASSERT_TRUE(canonical.ok()) << canonical.error().to_string(); + + EXPECT_THAT(std::string(canonical->path()), Not(::testing::EndsWith("/."))); + EXPECT_THAT(std::string(canonical->path()), Not(::testing::EndsWith("\\."))); + EXPECT_SAME_FILE(canonical->path(), input_path); +} +#else +TEST_F(Test_File_Canonical, canonical_path_fails_with_dot_after_regular_file) { + std::string temp_dir = this->make_temporary_directory(); + write_file_or_exit(temp_dir + "/just-a-file", u8""_sv); + + std::string input_path = temp_dir + "/just-a-file/."; + Result canonical = + canonicalize_path(input_path); + ASSERT_FALSE(canonical.ok()); + EXPECT_EQ(canonical.error().input_path, input_path); + EXPECT_THAT(canonical.error().canonicalizing_path, + ::testing::EndsWith("just-a-file")); + EXPECT_EQ(canonical.error().io_error.error, ENOTDIR); +} +#endif + TEST_F(Test_File_Canonical, canonical_path_removes_dot_components_after_missing_path) { std::string temp_dir = this->make_temporary_directory(); @@ -301,28 +331,42 @@ TEST_F(Test_File_Canonical, EXPECT_THAT(std::string(canonical->path()), Not(HasSubstr("\\.\\"))); } -// TODO(strager): This test is wrong if -// QLJS_FILE_PATH_ALLOWS_FOLLOWING_COMPONENTS. +#if QLJS_FILE_PATH_ALLOWS_FOLLOWING_COMPONENTS +TEST_F(Test_File_Canonical, + canonical_path_simplifies_dot_dot_component_after_regular_file) { + std::string temp_dir = this->make_temporary_directory(); + write_file_or_exit(temp_dir + "/just-a-file", u8""_sv); + write_file_or_exit(temp_dir + "/other.txt", u8""_sv); + + std::string input_path = temp_dir + "/just-a-file/../other.txt"; + Result canonical = + canonicalize_path(input_path); + ASSERT_TRUE(canonical.ok()) << canonical.error().to_string(); + + EXPECT_THAT(std::string(canonical->path()), Not(HasSubstr("/../"))); + EXPECT_THAT(std::string(canonical->path()), Not(HasSubstr("\\..\\"))); + EXPECT_THAT(std::string(canonical->path()), Not(HasSubstr("/just-a-file/"))); + EXPECT_THAT(std::string(canonical->path()), + Not(HasSubstr("\\just-a-file\\"))); + EXPECT_SAME_FILE(canonical->path(), temp_dir + "/other.txt"); +} +#else TEST_F(Test_File_Canonical, canonical_path_fails_with_dot_dot_component_after_regular_file) { std::string temp_dir = this->make_temporary_directory(); write_file_or_exit(temp_dir + "/just-a-file", u8""_sv); write_file_or_exit(temp_dir + "/other.txt", u8""_sv); - std::string input_path = temp_dir + "/just-a-file/../other.text"; + std::string input_path = temp_dir + "/just-a-file/../other.txt"; Result canonical = canonicalize_path(input_path); ASSERT_FALSE(canonical.ok()); EXPECT_EQ(canonical.error().input_path, input_path); EXPECT_THAT(canonical.error().canonicalizing_path, ::testing::EndsWith("just-a-file")); -#if QLJS_HAVE_WINDOWS_H - EXPECT_EQ(canonical.error().io_error.error, ERROR_DIRECTORY); -#endif -#if QLJS_HAVE_UNISTD_H EXPECT_EQ(canonical.error().io_error.error, ENOTDIR); -#endif } +#endif #if QLJS_FILE_PATH_ALLOWS_FOLLOWING_COMPONENTS TEST_F(Test_File_Canonical, @@ -335,8 +379,8 @@ TEST_F(Test_File_Canonical, canonicalize_path(input_path); ASSERT_TRUE(canonical.ok()) << canonical.error().to_string(); - EXPECT_FALSE(ends_with(canonical->path(), "/..")) << canonical.path(); - EXPECT_FALSE(ends_with(canonical->path(), "\\..")) << canonical.path(); + EXPECT_THAT(std::string(canonical->path()), Not(::testing::EndsWith("/."))); + EXPECT_THAT(std::string(canonical->path()), Not(::testing::EndsWith("\\."))); EXPECT_THAT(std::string(canonical->path()), Not(HasSubstr("fake-subdir"))); EXPECT_SAME_FILE(canonical->path(), temp_dir + "/just-a-file"); } @@ -353,12 +397,7 @@ TEST_F(Test_File_Canonical, EXPECT_EQ(canonical.error().input_path, input_path); EXPECT_THAT(canonical.error().canonicalizing_path, ::testing::EndsWith("just-a-file")); -#if QLJS_HAVE_WINDOWS_H - EXPECT_EQ(canonical.error().io_error.error, ERROR_DIRECTORY); -#endif -#if QLJS_HAVE_UNISTD_H EXPECT_EQ(canonical.error().io_error.error, ENOTDIR); -#endif } #endif @@ -372,8 +411,8 @@ TEST_F(Test_File_Canonical, canonical_path_with_dot_after_regular_file) { canonicalize_path(input_path); ASSERT_TRUE(canonical.ok()) << canonical.error().to_string(); - EXPECT_FALSE(ends_with(canonical->path(), "/.")) << canonical.path(); - EXPECT_FALSE(ends_with(canonical->path(), "\\.")) << canonical.path(); + EXPECT_THAT(std::string(canonical->path()), Not(::testing::EndsWith("/."))); + EXPECT_THAT(std::string(canonical->path()), Not(::testing::EndsWith("\\."))); EXPECT_SAME_FILE(canonical->path(), temp_dir + "/just-a-file"); } #else @@ -389,12 +428,7 @@ TEST_F(Test_File_Canonical, EXPECT_EQ(canonical.error().input_path, input_path); EXPECT_THAT(canonical.error().canonicalizing_path, ::testing::EndsWith("just-a-file")); -#if QLJS_HAVE_WINDOWS_H - EXPECT_EQ(canonical.error().io_error.error, ERROR_DIRECTORY); -#endif -#if QLJS_HAVE_UNISTD_H EXPECT_EQ(canonical.error().io_error.error, ENOTDIR); -#endif } #endif @@ -518,6 +552,23 @@ TEST_F(Test_File_Canonical, } } +#if QLJS_FILE_PATH_ALLOWS_FOLLOWING_COMPONENTS +TEST_F(Test_File_Canonical, + canonical_path_folds_dot_dot_components_after_missing_path) { + std::string temp_dir = this->make_temporary_directory(); + Result temp_dir_canonical = + canonicalize_path(temp_dir); + ASSERT_TRUE(temp_dir_canonical.ok()) + << temp_dir_canonical.error().to_string(); + write_file_or_exit(temp_dir + "/real.js", u8""_sv); + + Result canonical = + canonicalize_path(temp_dir + "/does-not-exist/../real.js"); + ASSERT_TRUE(canonical.ok()) << canonical.error().to_string(); + + EXPECT_SAME_FILE(canonical->path(), temp_dir + "/real.js"); +} +#else TEST_F(Test_File_Canonical, canonical_path_keeps_dot_dot_components_after_missing_path) { std::string temp_dir = this->make_temporary_directory(); @@ -538,6 +589,7 @@ TEST_F(Test_File_Canonical, ".." QLJS_PREFERRED_PATH_DIRECTORY_SEPARATOR + "real.js"); EXPECT_TRUE(canonical->have_missing_components()); } +#endif TEST_F(Test_File_Canonical, canonical_path_makes_relative_paths_absolute) { std::string temp_dir = this->make_temporary_directory(); @@ -614,15 +666,11 @@ TEST_F(Test_File_Canonical, canonical_path_with_root_as_cwd) { EXPECT_SAME_FILE(canonical->path(), input_path); } -// TODO(strager): Test symlinks on Windows too. -#if QLJS_HAVE_UNISTD_H TEST_F(Test_File_Canonical, canonical_path_resolves_file_absolute_symlinks) { std::string temp_dir = this->make_temporary_directory(); write_file_or_exit(temp_dir + "/realfile", u8""_sv); - ASSERT_EQ(::symlink((temp_dir + "/realfile").c_str(), - (temp_dir + "/linkfile").c_str()), - 0) - << std::strerror(errno); + create_posix_file_symbolic_link_or_exit((temp_dir + "/linkfile").c_str(), + (temp_dir + "/realfile").c_str()); std::string input_path = temp_dir + "/linkfile"; Result canonical = @@ -639,8 +687,8 @@ TEST_F(Test_File_Canonical, canonical_path_resolves_file_absolute_symlinks) { TEST_F(Test_File_Canonical, canonical_path_resolves_file_relative_symlinks) { std::string temp_dir = this->make_temporary_directory(); write_file_or_exit(temp_dir + "/realfile", u8""_sv); - ASSERT_EQ(::symlink("realfile", (temp_dir + "/linkfile").c_str()), 0) - << std::strerror(errno); + create_posix_file_symbolic_link_or_exit((temp_dir + "/linkfile").c_str(), + "realfile"); std::string input_path = temp_dir + "/linkfile"; Result canonical = @@ -658,10 +706,31 @@ TEST_F(Test_File_Canonical, canonical_path_resolves_directory_absolute_symlinks) { std::string temp_dir = this->make_temporary_directory(); create_directory_or_exit(temp_dir + "/realdir"); - ASSERT_EQ(::symlink((temp_dir + "/realdir").c_str(), - (temp_dir + "/linkdir").c_str()), - 0) - << std::strerror(errno); + create_posix_directory_symbolic_link_or_exit((temp_dir + "/linkdir").c_str(), + (temp_dir + "/realdir").c_str()); + write_file_or_exit(temp_dir + "/realdir/temp.js", u8""_sv); + + std::string input_path = temp_dir + "/linkdir/temp.js"; + Result canonical = + canonicalize_path(input_path); + ASSERT_TRUE(canonical.ok()) << canonical.error().to_string(); + + EXPECT_THAT(std::string(canonical->path()), + AnyOf(HasSubstr("/realdir/"), HasSubstr("\\realdir\\"))); + EXPECT_THAT(std::string(canonical->path()), Not(HasSubstr("/linkdir/"))); + EXPECT_THAT(std::string(canonical->path()), Not(HasSubstr("\\linkdir\\"))); + EXPECT_SAME_FILE(canonical->path(), temp_dir + "/realdir/temp.js"); + EXPECT_SAME_FILE(canonical->path(), input_path); +} + +#if defined(_WIN32) +TEST_F(Test_File_Canonical, canonical_path_resolves_directory_junctions) { + std::string temp_dir = this->make_temporary_directory(); + create_directory_or_exit(temp_dir + "/realdir"); + create_windows_junction_or_exit( + (temp_dir + "/linkdir").c_str(), + ("\\??\\" + temp_dir + QLJS_PREFERRED_PATH_DIRECTORY_SEPARATOR "realdir") + .c_str()); write_file_or_exit(temp_dir + "/realdir/temp.js", u8""_sv); std::string input_path = temp_dir + "/linkdir/temp.js"; @@ -676,13 +745,86 @@ TEST_F(Test_File_Canonical, EXPECT_SAME_FILE(canonical->path(), temp_dir + "/realdir/temp.js"); EXPECT_SAME_FILE(canonical->path(), input_path); } +#endif + +#if defined(_WIN32) +TEST_F(Test_File_Canonical, symlink_with_dot_dot_escapes_symlinked_dir) { + // $temp_dir\dir [directory] + // $temp_dir\dir\file.txt [file] + // $temp_dir\dir\subdir [directory] + // $temp_dir\dir\subdir\filelink.txt [symlink] -> ..\file.txt + // $temp_dir\linkdir [junction] -> $temp_dir\dir\subdir + // $temp_dir\file.txt [file] + // + // $temp_dir\linkdir\filelink.txt + // is the same as $temp_dir\dir\subdir\..\file.txt + // is the same as $temp_dir\dir\file.txt + + std::string temp_dir = this->make_temporary_directory(); + create_directory_or_exit(temp_dir + "/dir"); + write_file_or_exit(temp_dir + "/dir/file.txt", u8"$tempdir/dir/file.txt"); + create_directory_or_exit(temp_dir + "/dir/subdir"); + create_posix_file_symbolic_link_or_exit( + (temp_dir + "/dir/subdir/filelink.txt").c_str(), "..\\file.txt"); + create_posix_directory_symbolic_link( + (temp_dir + "/linkdir").c_str(), + "dir" QLJS_PREFERRED_PATH_DIRECTORY_SEPARATOR "subdir"); + write_file_or_exit(temp_dir + "/file.txt", u8"$tempdir/file.txt"); + + std::string input_path = temp_dir + "/linkdir/filelink.txt"; + Result canonical = + canonicalize_path(input_path); + ASSERT_TRUE(canonical.ok()) << canonical.error().to_string(); + + EXPECT_SAME_FILE(canonical->path(), temp_dir + "/dir/subdir/../file.txt"); + EXPECT_SAME_FILE(canonical->path(), temp_dir + "/dir/file.txt"); +} +#endif + +#if defined(_WIN32) +// TODO(#1200): Fix canonicalize_file when it encounters junctions. +TEST_F(Test_File_Canonical, DISABLED_symlink_with_dot_dot_escapes_junction) { + // $temp_dir\dir [directory] + // $temp_dir\dir\file.txt [file] + // $temp_dir\dir\subdir [directory] + // $temp_dir\dir\subdir\filelink.txt [symlink] -> ..\file.txt + // $temp_dir\linkdir [junction] -> $temp_dir\dir\subdir + // $temp_dir\file.txt [file] + // + // $temp_dir\linkdir\filelink.txt + // is the same as $temp_dir\linkdir\..\file.txt + // is the same as $temp_dir\file.txt + + std::string temp_dir = this->make_temporary_directory(); + create_directory_or_exit(temp_dir + "/dir"); + write_file_or_exit(temp_dir + "/dir/file.txt", u8"$tempdir/dir/file.txt"); + create_directory_or_exit(temp_dir + "/dir/subdir"); + create_posix_file_symbolic_link_or_exit( + (temp_dir + "/dir/subdir/filelink.txt").c_str(), "..\\file.txt"); + create_windows_junction_or_exit( + (temp_dir + "/linkdir").c_str(), + ("\\??\\" + temp_dir + + QLJS_PREFERRED_PATH_DIRECTORY_SEPARATOR + "dir" QLJS_PREFERRED_PATH_DIRECTORY_SEPARATOR "subdir") + .c_str()); + write_file_or_exit(temp_dir + "/file.txt", u8"$tempdir/file.txt"); + + std::string input_path = temp_dir + "/linkdir/filelink.txt"; + Result canonical = + canonicalize_path(input_path); + ASSERT_TRUE(canonical.ok()) << canonical.error().to_string(); + + EXPECT_SAME_FILE(canonical->path(), temp_dir + "/linkdir/../file.txt"); + EXPECT_SAME_FILE(canonical->path(), temp_dir + "/file.txt"); +} +#endif TEST_F(Test_File_Canonical, canonical_path_resolves_directory_relative_symlinks) { std::string temp_dir = this->make_temporary_directory(); create_directory_or_exit(temp_dir + "/realdir"); - ASSERT_EQ(::symlink("realdir", (temp_dir + "/linkdir").c_str()), 0) - << std::strerror(errno); + create_posix_directory_symbolic_link_or_exit((temp_dir + "/linkdir").c_str(), + "realdir"); write_file_or_exit(temp_dir + "/realdir/temp.js", u8""_sv); std::string input_path = temp_dir + "/linkdir/temp.js"; @@ -698,15 +840,41 @@ TEST_F(Test_File_Canonical, EXPECT_SAME_FILE(canonical->path(), input_path); } +// Windows and POSIX behave differently. Windows resolves '..' in the path prior +// to following the symlink. POSIX follows '..' in the path after following the +// symlink. +#if defined(_WIN32) TEST_F(Test_File_Canonical, - canonical_path_resolves_dot_dot_with_directory_symlinks) { + canonical_path_resolves_dot_dot_before_following_directory_symlinks) { std::string temp_dir = this->make_temporary_directory(); create_directory_or_exit(temp_dir + "/dir"); create_directory_or_exit(temp_dir + "/dir/subdir"); - ASSERT_EQ(::symlink((temp_dir + "/dir/subdir").c_str(), - (temp_dir + "/linkdir").c_str()), - 0) - << std::strerror(errno); + // NOTE(strager): In this test, linkdir isn't used. (This behavior differs + // from POSIX.) + create_posix_directory_symbolic_link_or_exit( + (temp_dir + "/linkdir").c_str(), (temp_dir + "/dir/subdir").c_str()); + write_file_or_exit(temp_dir + "/temp.js", u8""_sv); + + std::string input_path = temp_dir + "/linkdir/../temp.js"; + Result canonical = + canonicalize_path(input_path); + ASSERT_TRUE(canonical.ok()) << canonical.error().to_string(); + + EXPECT_THAT(std::string(canonical->path()), Not(HasSubstr("/linkdir/"))); + EXPECT_THAT(std::string(canonical->path()), Not(HasSubstr("\\linkdir\\"))); + EXPECT_THAT(std::string(canonical->path()), Not(HasSubstr("/dir/"))); + EXPECT_THAT(std::string(canonical->path()), Not(HasSubstr("\\dir\\"))); + EXPECT_SAME_FILE(canonical->path(), temp_dir + "/temp.js"); + EXPECT_SAME_FILE(canonical->path(), input_path); +} +#else +TEST_F(Test_File_Canonical, + canonical_path_resolves_dot_dot_after_following_directory_symlinks) { + std::string temp_dir = this->make_temporary_directory(); + create_directory_or_exit(temp_dir + "/dir"); + create_directory_or_exit(temp_dir + "/dir/subdir"); + create_posix_directory_symbolic_link_or_exit( + (temp_dir + "/linkdir").c_str(), (temp_dir + "/dir/subdir").c_str()); write_file_or_exit(temp_dir + "/dir/temp.js", u8""_sv); std::string input_path = temp_dir + "/linkdir/../temp.js"; @@ -719,13 +887,15 @@ TEST_F(Test_File_Canonical, EXPECT_SAME_FILE(canonical->path(), temp_dir + "/dir/temp.js"); EXPECT_SAME_FILE(canonical->path(), input_path); } +#endif TEST_F(Test_File_Canonical, canonical_path_resolves_dot_dot_inside_symlinks) { std::string temp_dir = this->make_temporary_directory(); create_directory_or_exit(temp_dir + "/dir"); create_directory_or_exit(temp_dir + "/otherdir"); - ASSERT_EQ(::symlink("../otherdir", (temp_dir + "/dir/linkdir").c_str()), 0) - << std::strerror(errno); + create_posix_directory_symbolic_link_or_exit( + (temp_dir + "/dir/linkdir").c_str(), + ".." QLJS_PREFERRED_PATH_DIRECTORY_SEPARATOR "otherdir"); write_file_or_exit(temp_dir + "/otherdir/temp.js", u8""_sv); std::string input_path = temp_dir + "/dir/linkdir/temp.js"; @@ -742,10 +912,10 @@ TEST_F(Test_File_Canonical, canonical_path_resolves_dot_dot_inside_symlinks) { TEST_F(Test_File_Canonical, canonical_path_fails_with_symlink_loop_in_directory) { std::string temp_dir = this->make_temporary_directory(); - ASSERT_EQ(::symlink("link1", (temp_dir + "/link2").c_str()), 0) - << std::strerror(errno); - ASSERT_EQ(::symlink("link2", (temp_dir + "/link1").c_str()), 0) - << std::strerror(errno); + create_posix_directory_symbolic_link_or_exit((temp_dir + "/link2").c_str(), + "link1"); + create_posix_directory_symbolic_link_or_exit((temp_dir + "/link1").c_str(), + "link2"); std::string input_path = temp_dir + "/link1/file.js"; Result canonical = @@ -767,8 +937,8 @@ TEST_F(Test_File_Canonical, canonicalize_path(temp_dir); ASSERT_TRUE(temp_dir_canonical.ok()) << temp_dir_canonical.error().to_string(); - ASSERT_EQ(::symlink("does-not-exist", (temp_dir + "/testlink").c_str()), 0) - << std::strerror(errno); + create_posix_directory_symbolic_link_or_exit((temp_dir + "/testlink").c_str(), + "does-not-exist"); std::string input_path = temp_dir + "/testlink/file.js"; Result canonical = @@ -792,8 +962,8 @@ TEST_F(Test_File_Canonical, canonical_path_with_broken_symlink_file_fails) { canonicalize_path(temp_dir); ASSERT_TRUE(temp_dir_canonical.ok()) << temp_dir_canonical.error().to_string(); - ASSERT_EQ(::symlink("does-not-exist", (temp_dir + "/testlink").c_str()), 0) - << std::strerror(errno); + create_posix_file_symbolic_link_or_exit((temp_dir + "/testlink").c_str(), + "does-not-exist"); std::string input_path = temp_dir + "/testlink"; Result canonical = @@ -813,8 +983,8 @@ TEST_F(Test_File_Canonical, canonical_path_with_broken_symlink_file_fails) { TEST_F(Test_File_Canonical, canonical_path_resolves_symlinks_in_cwd) { std::string temp_dir = this->make_temporary_directory(); create_directory_or_exit(temp_dir + "/realdir"); - ASSERT_EQ(::symlink("realdir", (temp_dir + "/linkdir").c_str()), 0) - << std::strerror(errno); + create_posix_directory_symbolic_link_or_exit((temp_dir + "/linkdir").c_str(), + "realdir"); write_file_or_exit(temp_dir + "/realdir/temp.js", u8""_sv); this->set_current_working_directory(temp_dir + "/linkdir"); @@ -832,11 +1002,11 @@ TEST_F(Test_File_Canonical, canonical_path_resolves_symlinks_in_cwd) { TEST_F(Test_File_Canonical, canonical_path_resolves_symlink_pointing_to_symlink) { std::string temp_dir = this->make_temporary_directory(); - ASSERT_EQ(::symlink("otherlinkdir/subdir", (temp_dir + "/linkdir").c_str()), - 0) - << std::strerror(errno); - ASSERT_EQ(::symlink("realdir", (temp_dir + "/otherlinkdir").c_str()), 0) - << std::strerror(errno); + create_posix_directory_symbolic_link_or_exit( + (temp_dir + "/linkdir").c_str(), + "otherlinkdir" QLJS_PREFERRED_PATH_DIRECTORY_SEPARATOR "subdir"); + create_posix_directory_symbolic_link_or_exit( + (temp_dir + "/otherlinkdir").c_str(), "realdir"); create_directory_or_exit(temp_dir + "/realdir"); create_directory_or_exit(temp_dir + "/realdir/subdir"); write_file_or_exit(temp_dir + "/realdir/subdir/hello.js", u8""_sv); @@ -854,7 +1024,8 @@ TEST_F(Test_File_Canonical, EXPECT_SAME_FILE(canonical->path(), temp_dir + "/realdir/subdir/hello.js"); EXPECT_SAME_FILE(canonical->path(), input_path); } -#endif + +// TODO(strager): Test Windows junctions. #if QLJS_HAVE_UNISTD_H TEST_F(Test_File_Canonical, unsearchable_parent_directory) { diff --git a/test/test-file-path.cpp b/test/test-file-path.cpp index dbe03558d3..903636153c 100644 --- a/test/test-file-path.cpp +++ b/test/test-file-path.cpp @@ -3,8 +3,12 @@ #include #include +#include +#include #include +#include #include +#include #if QLJS_HAVE_UNISTD_H #include @@ -14,9 +18,14 @@ using ::testing::AnyOf; namespace quick_lint_js { namespace { +class Test_File_Path : public ::testing::Test, public Filesystem_Test { + public: + Monotonic_Allocator allocator_{"test"}; +}; + #if defined(QLJS_HAVE_UNISTD_H) && defined(_POSIX_VERSION) && \ _POSIX_VERSION >= 200112L -TEST(Test_File_Path, parent_path_posix) { +TEST_F(Test_File_Path, parent_path_posix) { EXPECT_EQ(parent_path("x/y"), "x"); EXPECT_EQ(parent_path("x/y/z"), "x/y"); @@ -47,7 +56,7 @@ TEST(Test_File_Path, parent_path_posix) { << "// is implementation-defined"; } -TEST(Test_File_Path, path_file_name_posix) { +TEST_F(Test_File_Path, path_file_name_posix) { EXPECT_EQ(path_file_name(""), ""); EXPECT_EQ(path_file_name("x"), "x"); @@ -79,7 +88,7 @@ TEST(Test_File_Path, path_file_name_posix) { #endif #if defined(_WIN32) -TEST(Test_File_Path, parent_path_windows) { +TEST_F(Test_File_Path, parent_path_windows) { EXPECT_EQ(parent_path(R"(x/y)"), R"(x)"); EXPECT_EQ(parent_path(R"(x/y/z)"), R"(x/y)"); EXPECT_EQ(parent_path(R"(x\y)"), R"(x)"); @@ -160,7 +169,7 @@ TEST(Test_File_Path, parent_path_windows) { // TODO(strager): Test \\?\UNC\host\share paths. } -TEST(Test_File_Path, path_file_name_windows) { +TEST_F(Test_File_Path, path_file_name_windows) { EXPECT_EQ(path_file_name(""), ""); EXPECT_EQ(path_file_name(R"(x)"), "x"); @@ -239,6 +248,279 @@ TEST(Test_File_Path, path_file_name_windows) { // TODO(strager): Test \\?\UNC\host\share paths. } #endif + +#if defined(_WIN32) +struct Simplified_Path_Assertion { + const wchar_t* full_path; + const wchar_t* root; + const wchar_t* relative; + + friend bool operator==(Simplified_Path lhs, Simplified_Path_Assertion rhs) { + if (rhs.full_path != nullptr && + std::wstring_view(lhs.full_path) != std::wstring_view(rhs.full_path)) { + return false; + } + if (rhs.root != nullptr && lhs.root != rhs.root) { + return false; + } + if (rhs.relative != nullptr && lhs.relative != rhs.relative) { + return false; + } + return true; + } + + friend std::ostream& operator<<(std::ostream& out, + Simplified_Path_Assertion assertion) { + auto maybe_write_field = [&](const char* name, const wchar_t* s) -> void { + if (s != nullptr) { + out << " ." << name << " = \"" << wstring_to_mbstring(s).value() + << "\",\n"; + } + }; + out << "Simplified_Path{\n"; + maybe_write_field("full_path", assertion.full_path); + maybe_write_field("root", assertion.root); + maybe_write_field("relative", assertion.relative); + out << "}"; + return out; + } +}; +#endif + +#if defined(_WIN32) +#define CHECK_SIMPLIFY(input_path, expected_full_path, expected_root, \ + expected_relative) \ + EXPECT_EQ(simplify_path_and_make_absolute(&this->allocator_, input_path), \ + (Simplified_Path_Assertion{ \ + .full_path = expected_full_path, \ + .root = expected_root, \ + .relative = expected_relative, \ + })) + +TEST_F(Test_File_Path, simplify_path_and_make_absolute_empty_path) { + CHECK_SIMPLIFY(L"", L"", L"", L""); +} + +TEST_F(Test_File_Path, simplify_path_and_make_absolute_already_absolute) { + // clang-format off + // Path splitting with no modifications: + CHECK_SIMPLIFY(LR"(C:\)", LR"(C:\)", L"C:", L""); + CHECK_SIMPLIFY(LR"(C:\a\b)", LR"(C:\a\b)", L"C:", LR"(a\b)"); + CHECK_SIMPLIFY(LR"(C:\a\b\)", LR"(C:\a\b\)", L"C:", LR"(a\b\)"); + + CHECK_SIMPLIFY(LR"(\\?\C:\)", LR"(\\?\C:\)", LR"(\\?\C:)", L""); + CHECK_SIMPLIFY(LR"(\\?\C:\a\b)", LR"(\\?\C:\a\b)", LR"(\\?\C:)", LR"(a\b)"); + CHECK_SIMPLIFY(LR"(\\?\C:\a\b\)", LR"(\\?\C:\a\b\)", LR"(\\?\C:)", LR"(a\b\)"); + + CHECK_SIMPLIFY(LR"(\\server\share)", LR"(\\server\share)", LR"(\\server\share)", L""); + CHECK_SIMPLIFY(LR"(\\server\share\)", LR"(\\server\share\)", LR"(\\server\share)", L""); + CHECK_SIMPLIFY(LR"(\\server\share\file.txt)", LR"(\\server\share\file.txt)", LR"(\\server\share)", L"file.txt"); + + // TODO(strager): Handle \\?\UNC\ paths correctly. + // CHECK_SIMPLIFY(LR"(\\?\UNC\server\share)", LR"(\\?\UNC\server\share)", LR"(\\?\UNC\server\share)", L""); + // CHECK_SIMPLIFY(LR"(\\?\UNC\server\share\)", LR"(\\?\UNC\server\share\)", LR"(\\?\UNC\server\share)", L""); + // CHECK_SIMPLIFY(LR"(\\?\UNC\server\share\file.txt)", LR"(\\?\UNC\server\share\file.txt)", LR"(\\?\UNC\server\share)", L"file.txt"); + + CHECK_SIMPLIFY(LR"(\\.\device)", LR"(\\.\device)", LR"(\\.\device)", L""); + CHECK_SIMPLIFY(LR"(\\.\device\)", LR"(\\.\device\)", LR"(\\.\device)", L""); + + // '.' components are dropped: + CHECK_SIMPLIFY(LR"(C:\a\.\b)", LR"(C:\a\b)", L"C:", LR"(a\b)"); + CHECK_SIMPLIFY(LR"(C:\a\b\.)", LR"(C:\a\b)", L"C:", LR"(a\b)"); + CHECK_SIMPLIFY(LR"(C:\a\b\.\)", LR"(C:\a\b\)", L"C:", LR"(a\b\)"); + + CHECK_SIMPLIFY(LR"(\\?/C:\a\.\b)", LR"(\\?\C:\a\b)", LR"(\\?\C:)", LR"(a\b)"); + + // '..' components are resolved: + CHECK_SIMPLIFY(LR"(C:\a\..\b)", LR"(C:\b)", L"C:", L"b"); + CHECK_SIMPLIFY(LR"(C:\a\b\..)", LR"(C:\a)", L"C:", L"a"); + CHECK_SIMPLIFY(LR"(C:\a\b\..\)", LR"(C:\a\)", L"C:", LR"(a\)"); + CHECK_SIMPLIFY(LR"(C:\a\b\..\..)", LR"(C:\)", L"C:", L""); + + CHECK_SIMPLIFY(LR"(\\?/C:\a\..\b)", LR"(\\?\C:\b)", LR"(\\?\C:)", L"b"); + + // Redundant '\'s are merged: + CHECK_SIMPLIFY(LR"(C:\\a\\b\\)", LR"(C:\a\b\)", L"C:", LR"(a\b\)"); + CHECK_SIMPLIFY(LR"(C:\a\\\\\\b)", LR"(C:\a\b)", L"C:", LR"(a\b)"); + + CHECK_SIMPLIFY(LR"(\\server\\share)", LR"(\\server\share)", LR"(\\server\share)", L""); + + CHECK_SIMPLIFY(LR"(\\?/C:\\a\\b)", LR"(\\?\C:\a\b)", LR"(\\?\C:)", LR"(a\b)"); + + // '/' is converted to '\': + CHECK_SIMPLIFY(LR"(C:/)", LR"(C:\)", L"C:", L""); + CHECK_SIMPLIFY(LR"(C:/a/b)", LR"(C:\a\b)", L"C:", LR"(a\b)"); + CHECK_SIMPLIFY(LR"(C:/a/b/)", LR"(C:\a\b\)", L"C:", LR"(a\b\)"); + + CHECK_SIMPLIFY(LR"(\\server\share)", LR"(\\server\share)", LR"(\\server\share)", L""); + CHECK_SIMPLIFY(LR"(\\server\share/)", LR"(\\server\share\)", LR"(\\server\share)", L""); + CHECK_SIMPLIFY(LR"(\\server\share/a/b)", LR"(\\server\share\a\b)", LR"(\\server\share)", LR"(a\b)"); + + CHECK_SIMPLIFY(LR"(\\?/C:/a/b)", LR"(\\?\C:\a\b)", LR"(\\?\C:)", LR"(a\b)"); + + // '.' is not resolved for \\?\ paths: + CHECK_SIMPLIFY(LR"(\\?\C:\a\.\b)", LR"(\\?\C:\a\.\b)", LR"(\\?\C:)", LR"(a\.\b)"); + + // '..' is not resolved for \\?\ paths: + CHECK_SIMPLIFY(LR"(\\?\C:\a\b\..)", LR"(\\?\C:\a\b\..)", LR"(\\?\C:)", LR"(a\b\..)"); + + // '/' is not converted to '\' for \\?\ paths: + CHECK_SIMPLIFY(LR"(\\?\C:/a/b\c/d/)", LR"(\\?\C:/a/b\c/d/)", LR"(\\?\C:/a/b)", LR"(c/d/)"); + + // clang-format on +} + +struct CWD_Parts { + // Example: + // cwd: L"C:\\projects\\dir" + // root: L"C:" + // relative: L"projects\\dir" + std::wstring cwd; + std::wstring root; + std::wstring relative; +}; + +CWD_Parts get_cwd_parts() { + std::wstring cwd; + if (!get_current_working_directory(cwd).ok()) { + QLJS_UNIMPLEMENTED(); + } + SCOPED_TRACE(cwd); + + // Assumption: cwd has the form 'C:\' or 'C:\dir\subdir'. + EXPECT_GE(cwd.size(), 3); + EXPECT_EQ(cwd[1], L':'); + EXPECT_EQ(cwd[2], L'\\'); + if (cwd.size() > 3) { + EXPECT_NE(cwd.back(), L'\\'); + } + + return CWD_Parts{ + .cwd = cwd, + .root = cwd.substr(0, 2), // "C:" + .relative = cwd.substr(3), // Skip "C:\". + }; +} + +TEST_F(Test_File_Path, simplify_path_and_make_absolute_cwd_relative) { + CWD_Parts cwd = get_cwd_parts(); + + // clang-format off + // Basic path is appended to cwd: + CHECK_SIMPLIFY(L"hello.txt", (cwd.cwd + LR"(\hello.txt)").c_str(), cwd.root.c_str(), (cwd.relative + LR"(\hello.txt)").c_str()); + CHECK_SIMPLIFY(LR"(dir\file)", (cwd.cwd + LR"(\dir\file)").c_str(), cwd.root.c_str(), (cwd.relative + LR"(\dir\file)").c_str()); + CHECK_SIMPLIFY(LR"(dir\file\)", (cwd.cwd + LR"(\dir\file\)").c_str(), cwd.root.c_str(), (cwd.relative + LR"(\dir\file\)").c_str()); + + // '.' components are dropped: + CHECK_SIMPLIFY(L".", cwd.cwd.c_str(), cwd.root.c_str(), cwd.relative.c_str()); + CHECK_SIMPLIFY(LR"(.\)", (cwd.cwd + LR"(\)").c_str(), cwd.root.c_str(), (cwd.relative + LR"(\)").c_str()); + CHECK_SIMPLIFY(LR"(a\.\b)", (cwd.cwd + LR"(\a\b)").c_str(), cwd.root.c_str(), (cwd.relative + LR"(\a\b)").c_str()); + + // '..' components are resolved: + CHECK_SIMPLIFY(LR"(a\..)", cwd.cwd.c_str(), cwd.root.c_str(), cwd.relative.c_str()); + CHECK_SIMPLIFY(LR"(a\..\)", (cwd.cwd + LR"(\)").c_str(), cwd.root.c_str(), (cwd.relative + LR"(\)").c_str()); + // clang-format on +} + +TEST_F(Test_File_Path, + simplify_path_and_make_absolute_path_starting_with_dot_dot) { + { + this->set_current_working_directory(this->make_temporary_directory()); + CWD_Parts cwd = get_cwd_parts(); + ASSERT_NE(cwd.relative, L"") + << "temporary directory shouldn't be a root path"; + std::size_t relative_last_slash_index = cwd.relative.rfind(L'\\'); + std::wstring parent_relative = + relative_last_slash_index == cwd.relative.npos + ? L"" + : cwd.relative.substr(0, relative_last_slash_index); + std::wstring expected_full_path = cwd.root + L'\\' + parent_relative; + CHECK_SIMPLIFY(L"..", + /*full_path=*/expected_full_path.c_str(), + /*root=*/cwd.root.c_str(), + /*relative=*/parent_relative.c_str()); + CHECK_SIMPLIFY(LR"(..\.)", + /*full_path=*/expected_full_path.c_str(), + /*root=*/cwd.root.c_str(), + /*relative=*/parent_relative.c_str()); + CHECK_SIMPLIFY(LR"(.\..)", + /*full_path=*/expected_full_path.c_str(), + /*root=*/cwd.root.c_str(), + /*relative=*/parent_relative.c_str()); + } + + { + // Change the current directory to e.g. "C:\". + this->set_current_working_directory( + wstring_to_mbstring(get_cwd_parts().root + L"\\").value()); + CWD_Parts cwd = get_cwd_parts(); + ASSERT_EQ(cwd.relative, L"") << "current directory should be root path"; + std::wstring expected_full_path = cwd.root + L'\\'; + CHECK_SIMPLIFY(L"..", + /*full_path=*/expected_full_path.c_str(), + /*root=*/cwd.root.c_str(), + /*relative=*/L""); + CHECK_SIMPLIFY(LR"(..\..)", + /*full_path=*/expected_full_path.c_str(), + /*root=*/cwd.root.c_str(), + /*relative=*/L""); + } +} + +TEST_F(Test_File_Path, + simplify_path_and_make_absolute_drive_cwd_relative_path) { + { + CWD_Parts cwd = get_cwd_parts(); + // L"C:dir" for example. + CHECK_SIMPLIFY((cwd.root + L"dir").c_str(), + /*full_path=*/(cwd.cwd + LR"(\dir)").c_str(), + /*root=*/cwd.root.c_str(), + /*relative=*/(cwd.relative + LR"(\dir)").c_str()); + // L"C:dir\\subdir" for example. + CHECK_SIMPLIFY((cwd.root + LR"(dir\subdir)").c_str(), + /*full_path=*/(cwd.cwd + LR"(\dir\subdir)").c_str(), + /*root=*/cwd.root.c_str(), + /*relative=*/(cwd.relative + LR"(\dir\subdir)").c_str()); + } +} + +// TODO(strager): Test mixing drives (e.g. cwd is on C: but path is "D:foo"). + +TEST_F( + Test_File_Path, + simplify_path_and_make_absolute_drive_cwd_relative_path_starting_with_dot_dot) { + { + this->set_current_working_directory(this->make_temporary_directory()); + CWD_Parts cwd = get_cwd_parts(); + ASSERT_NE(cwd.relative, L"") + << "temporary directory shouldn't be a root path"; + std::size_t relative_last_slash_index = cwd.relative.rfind(L'\\'); + std::wstring parent_relative = + relative_last_slash_index == cwd.relative.npos + ? L"" + : cwd.relative.substr(0, relative_last_slash_index); + std::wstring expected_full_path = cwd.root + L'\\' + parent_relative; + // L"C:.." for example. + CHECK_SIMPLIFY((cwd.root + L"..").c_str(), + /*full_path=*/expected_full_path.c_str(), + /*root=*/cwd.root.c_str(), + /*relative=*/parent_relative.c_str()); + } +} + +TEST_F(Test_File_Path, + simplify_path_and_make_absolute_current_drive_relative_path) { + CWD_Parts cwd = get_cwd_parts(); + CHECK_SIMPLIFY(LR"(\somedir)", + /*full_path=*/(cwd.root + LR"(\somedir)").c_str(), + /*root=*/cwd.root.c_str(), + /*relative=*/L"somedir"); + CHECK_SIMPLIFY(L"/somedir", + /*full_path=*/(cwd.root + LR"(\somedir)").c_str(), + /*root=*/cwd.root.c_str(), + /*relative=*/L"somedir"); +} +#endif } } diff --git a/test/test-temporary-directory.cpp b/test/test-temporary-directory.cpp index 3eb50036c9..9893b05986 100644 --- a/test/test-temporary-directory.cpp +++ b/test/test-temporary-directory.cpp @@ -15,12 +15,15 @@ #include #include #include +#include #include #if QLJS_HAVE_UNISTD_H #include #endif +using namespace std::literals::string_view_literals; + namespace quick_lint_js { namespace { #if QLJS_HAVE_UNISTD_H @@ -61,6 +64,34 @@ TEST(Test_Temporary_Directory, } #endif +TEST(Test_Temporary_Directory, + delete_directory_containing_symlink_to_missing_directory) { + std::string temp_dir = make_temporary_directory(); + std::string sub_dir = temp_dir + "/sub_dir"; + create_directory_or_exit(sub_dir); + create_posix_directory_symbolic_link_or_exit((sub_dir + "/linkdir").c_str(), + "doesnotexist"); + + delete_directory_recursive(sub_dir); + + EXPECT_FILE_DOES_NOT_EXIST(sub_dir + "/linkdir"); + EXPECT_FILE_DOES_NOT_EXIST(sub_dir); +} + +TEST(Test_Temporary_Directory, + delete_file_containing_symlink_to_missing_directory) { + std::string temp_dir = make_temporary_directory(); + std::string sub_dir = temp_dir + "/sub_dir"; + create_directory_or_exit(sub_dir); + create_posix_file_symbolic_link_or_exit((sub_dir + "/linkfile").c_str(), + "doesnotexist"); + + delete_directory_recursive(sub_dir); + + EXPECT_FILE_DOES_NOT_EXIST(sub_dir + "/linkfile"); + EXPECT_FILE_DOES_NOT_EXIST(sub_dir); +} + TEST(Test_Temporary_Directory, creating_directory_over_existing_directory_fails) { std::string temp_dir = make_temporary_directory(); @@ -204,7 +235,7 @@ TEST_F(Test_Directory, list_directory_recursively) { create_directory_or_exit(temp_dir + "/dir-c"); struct Test_Visitor final : public List_Directory_Visitor { - void visit_file(const std::string& path) override { + void visit_file(const std::string& path, File_Type_Flags) override { this->visited_files.push_back(path); } @@ -247,12 +278,110 @@ TEST_F(Test_Directory, list_directory_recursively) { #undef SEP } +TEST_F(Test_Directory, list_directory_with_symlinks) { + std::string temp_dir = this->make_temporary_directory(); + create_directory_or_exit(temp_dir + "/dir"); + write_file_or_exit(temp_dir + "/file", u8""_sv); + // clang-format off + create_posix_directory_symbolic_link_or_exit((temp_dir + "/dir-symlink").c_str(), "dir"); + create_posix_directory_symbolic_link_or_exit((temp_dir + "/missing-dir-symlink").c_str(), "missing-dir"); + create_posix_file_symbolic_link_or_exit((temp_dir + "/file-symlink").c_str(), "file"); + create_posix_file_symbolic_link_or_exit((temp_dir + "/missing-file-symlink").c_str(), "missing-file"); + // clang-format on + + std::vector visited_files; + list_directory( + temp_dir.c_str(), [&](const char* name, File_Type_Flags flags) -> void { + if (name == "dir-symlink"sv || name == "missing-dir-symlink"sv) { + EXPECT_TRUE(enum_has_flags( + flags, File_Type_Flags::is_symbolic_link_or_reparse_point)); +#if defined(_WIN32) + EXPECT_TRUE(enum_has_flags(flags, File_Type_Flags::is_directory)); +#else + EXPECT_FALSE(enum_has_flags(flags, File_Type_Flags::is_directory)); +#endif + } else if (name == "file-symlink"sv || + name == "missing-file-symlink"sv) { + EXPECT_TRUE(enum_has_flags( + flags, File_Type_Flags::is_symbolic_link_or_reparse_point)); + EXPECT_FALSE(enum_has_flags(flags, File_Type_Flags::is_directory)); + } + visited_files.emplace_back(name); + }); + + EXPECT_THAT(visited_files, ::testing::UnorderedElementsAreArray({ + "dir", + "file", + "dir-symlink", + "missing-dir-symlink", + "file-symlink", + "missing-file-symlink", + })); +} + +TEST_F(Test_Directory, list_directory_recursively_with_symlinks) { + std::string temp_dir = this->make_temporary_directory(); + create_directory_or_exit(temp_dir + "/dir"); + write_file_or_exit(temp_dir + "/file", u8""_sv); + // clang-format off + create_posix_directory_symbolic_link_or_exit((temp_dir + "/dir-symlink").c_str(), "dir"); + create_posix_directory_symbolic_link_or_exit((temp_dir + "/missing-dir-symlink").c_str(), "missing-dir"); + create_posix_file_symbolic_link_or_exit((temp_dir + "/file-symlink").c_str(), "file"); + create_posix_file_symbolic_link_or_exit((temp_dir + "/missing-file-symlink").c_str(), "missing-file"); + // clang-format on + + struct Test_Visitor final : public List_Directory_Visitor { + void visit_file(const std::string& path, File_Type_Flags flags) override { + SCOPED_TRACE(path); + std::string_view name = path_file_name(path); + this->visited_files.emplace_back(name); + + if (name == "dir-symlink" || name == "missing-dir-symlink") { + EXPECT_TRUE(enum_has_flags( + flags, File_Type_Flags::is_symbolic_link_or_reparse_point)); +#if defined(_WIN32) + EXPECT_TRUE(enum_has_flags(flags, File_Type_Flags::is_directory)); +#else + EXPECT_FALSE(enum_has_flags(flags, File_Type_Flags::is_directory)); +#endif + } else if (name == "file-symlink" || name == "missing-file-symlink") { + EXPECT_TRUE(enum_has_flags( + flags, File_Type_Flags::is_symbolic_link_or_reparse_point)); + EXPECT_FALSE(enum_has_flags(flags, File_Type_Flags::is_directory)); + } + } + + void visit_directory_pre(const std::string&) override {} + + void visit_directory_post(const std::string&) override {} + + void on_error(const Platform_File_IO_Error& error, + [[maybe_unused]] int depth) override { + ADD_FAILURE() << error.to_string(); + } + + std::vector visited_files; + }; + Test_Visitor visitor; + list_directory_recursively(temp_dir.c_str(), visitor); + + EXPECT_THAT(visitor.visited_files, ::testing::UnorderedElementsAreArray({ + "file", + "dir-symlink", + "missing-dir-symlink", + "file-symlink", + "missing-file-symlink", + })); +} + TEST_F(Test_Directory, list_directory_recursively_on_regular_file_fails) { std::string temp_dir = this->make_temporary_directory(); write_file_or_exit(temp_dir + "/testfile", u8""_sv); struct Test_Visitor final : public List_Directory_Visitor { - void visit_file(const std::string& path) override { ADD_FAILURE() << path; } + void visit_file(const std::string& path, File_Type_Flags) override { + ADD_FAILURE() << path; + } void on_error(const Platform_File_IO_Error& error, int depth) override { SCOPED_TRACE(error.to_string()); diff --git a/tools/test-typescript/main.cpp b/tools/test-typescript/main.cpp index ae12b83937..1e03164525 100644 --- a/tools/test-typescript/main.cpp +++ b/tools/test-typescript/main.cpp @@ -304,7 +304,7 @@ void process_test_case_directory_or_file( const char* root_path) : expected_results(expected_results), root_path(root_path) {} - void visit_file(const std::string& file_path) override { + void visit_file(const std::string& file_path, File_Type_Flags) override { if (ends_with(file_path, ".ts"sv) || ends_with(file_path, ".tsx"sv)) { process_test_case_file(this->expected_results, file_path.c_str()); }