diff --git a/src/libutil-tests/file-descriptor.cc b/src/libutil-tests/file-descriptor.cc index df94788d223..d8f438af592 100644 --- a/src/libutil-tests/file-descriptor.cc +++ b/src/libutil-tests/file-descriptor.cc @@ -3,8 +3,6 @@ #include "nix/util/file-descriptor.hh" #include "nix/util/serialise.hh" -#include "nix/util/file-system.hh" -#include "nix/util/fs-sink.hh" #include @@ -245,166 +243,4 @@ TEST(BufferedSourceReadLine, BufferExhaustedThenEof) EXPECT_EQ(source.readLine(/*eofOk=*/true), ""); } -/* ---------------------------------------------------------------------------- - * readLinkAt - * --------------------------------------------------------------------------*/ - -TEST(readLinkAt, works) -{ - std::filesystem::path tmpDir = nix::createTempDir(); - nix::AutoDelete delTmpDir(tmpDir, /*recursive=*/true); - - constexpr size_t maxPathLength = -#ifdef _WIN32 - 260 -#else - PATH_MAX -#endif - ; - std::string mediumTarget(maxPathLength / 2, 'x'); - std::string longTarget(maxPathLength - 1, 'y'); - - { - RestoreSink sink(/*startFsync=*/false); - sink.dstPath = tmpDir; - sink.dirFd = openDirectory(tmpDir); - sink.createSymlink(CanonPath("link"), "target"); - sink.createSymlink(CanonPath("relative"), "../relative/path"); - sink.createSymlink(CanonPath("absolute"), "/absolute/path"); - sink.createSymlink(CanonPath("medium"), mediumTarget); - sink.createSymlink(CanonPath("long"), longTarget); - sink.createDirectory(CanonPath("a")); - sink.createDirectory(CanonPath("a/b")); - sink.createSymlink(CanonPath("a/b/link"), "nested_target"); - sink.createRegularFile(CanonPath("regular"), [](CreateRegularFileSink &) {}); - sink.createDirectory(CanonPath("dir")); - } - - AutoCloseFD dirFd = openDirectory(tmpDir); - - EXPECT_EQ(readLinkAt(dirFd.get(), CanonPath("link")), OS_STR("target")); - EXPECT_EQ(readLinkAt(dirFd.get(), CanonPath("relative")), OS_STR("../relative/path")); - EXPECT_EQ(readLinkAt(dirFd.get(), CanonPath("absolute")), OS_STR("/absolute/path")); - EXPECT_EQ(readLinkAt(dirFd.get(), CanonPath("medium")), string_to_os_string(mediumTarget)); - EXPECT_EQ(readLinkAt(dirFd.get(), CanonPath("long")), string_to_os_string(longTarget)); - EXPECT_EQ(readLinkAt(dirFd.get(), CanonPath("a/b/link")), OS_STR("nested_target")); - - AutoCloseFD subDirFd = openDirectory(tmpDir / "a"); - EXPECT_EQ(readLinkAt(subDirFd.get(), CanonPath("b/link")), OS_STR("nested_target")); - - // Test error cases - expect SystemError on both platforms - EXPECT_THROW(readLinkAt(dirFd.get(), CanonPath("regular")), SystemError); - EXPECT_THROW(readLinkAt(dirFd.get(), CanonPath("dir")), SystemError); - EXPECT_THROW(readLinkAt(dirFd.get(), CanonPath("nonexistent")), SystemError); -} - -/* ---------------------------------------------------------------------------- - * openFileEnsureBeneathNoSymlinks - * --------------------------------------------------------------------------*/ - -TEST(openFileEnsureBeneathNoSymlinks, works) -{ - std::filesystem::path tmpDir = nix::createTempDir(); - nix::AutoDelete delTmpDir(tmpDir, /*recursive=*/true); - - { - RestoreSink sink(/*startFsync=*/false); - sink.dstPath = tmpDir; - sink.dirFd = openDirectory(tmpDir); - sink.createDirectory(CanonPath("a")); - sink.createDirectory(CanonPath("c")); - sink.createDirectory(CanonPath("c/d")); - sink.createRegularFile(CanonPath("c/d/regular"), [](CreateRegularFileSink & crf) { crf("some contents"); }); - sink.createSymlink(CanonPath("a/absolute_symlink"), tmpDir.string()); - sink.createSymlink(CanonPath("a/relative_symlink"), "../."); - sink.createSymlink(CanonPath("a/broken_symlink"), "./nonexistent"); - sink.createDirectory(CanonPath("a/b"), [](FileSystemObjectSink & dirSink, const CanonPath & relPath) { - dirSink.createDirectory(CanonPath("d")); - dirSink.createSymlink(CanonPath("c"), "./d"); - }); - // FIXME: This still follows symlinks on Unix (incorrectly succeeds) - sink.createDirectory(CanonPath("a/b/c/e")); - // Test that symlinks in intermediate path are detected during nested operations - ASSERT_THROW( - sink.createDirectory( - CanonPath("a/b/c/f"), [](FileSystemObjectSink & dirSink, const CanonPath & relPath) {}), - SymlinkNotAllowed); - ASSERT_THROW( - sink.createRegularFile( - CanonPath("a/b/c/regular"), [](CreateRegularFileSink & crf) { crf("some contents"); }), - SymlinkNotAllowed); - } - - AutoCloseFD dirFd = openDirectory(tmpDir); - - // Helper to open files with platform-specific arguments - auto openRead = [&](std::string_view path) -> Descriptor { - return openFileEnsureBeneathNoSymlinks( - dirFd.get(), - CanonPath(path), -#ifdef _WIN32 - FILE_READ_DATA | FILE_READ_ATTRIBUTES | SYNCHRONIZE, - 0 -#else - O_RDONLY, - 0 -#endif - ); - }; - - auto openReadDir = [&](std::string_view path) -> Descriptor { - return openFileEnsureBeneathNoSymlinks( - dirFd.get(), - CanonPath(path), -#ifdef _WIN32 - FILE_READ_ATTRIBUTES | SYNCHRONIZE, - FILE_DIRECTORY_FILE -#else - O_RDONLY | O_DIRECTORY, - 0 -#endif - ); - }; - - auto openCreateExclusive = [&](std::string_view path) -> Descriptor { - return openFileEnsureBeneathNoSymlinks( - dirFd.get(), - CanonPath(path), -#ifdef _WIN32 - FILE_WRITE_DATA | SYNCHRONIZE, - 0, - FILE_CREATE // Create new file, fail if exists (equivalent to O_CREAT | O_EXCL) -#else - O_CREAT | O_WRONLY | O_EXCL, - 0666 -#endif - ); - }; - - // Test that symlinks are detected and rejected - EXPECT_THROW(openRead("a/absolute_symlink"), SymlinkNotAllowed); - EXPECT_THROW(openRead("a/relative_symlink"), SymlinkNotAllowed); - EXPECT_THROW(openRead("a/absolute_symlink/a"), SymlinkNotAllowed); - EXPECT_THROW(openRead("a/absolute_symlink/c/d"), SymlinkNotAllowed); - EXPECT_THROW(openRead("a/relative_symlink/c"), SymlinkNotAllowed); - EXPECT_THROW(openRead("a/b/c/d"), SymlinkNotAllowed); - EXPECT_THROW(openRead("a/broken_symlink"), SymlinkNotAllowed); - -#if !defined(_WIN32) && !defined(__CYGWIN__) - // This returns ELOOP on cygwin when O_NOFOLLOW is used - EXPECT_EQ(openCreateExclusive("a/broken_symlink"), INVALID_DESCRIPTOR); - /* Sanity check, no symlink shenanigans and behaves the same as regular openat with O_EXCL | O_CREAT. */ - EXPECT_EQ(errno, EEXIST); -#endif - EXPECT_THROW(openCreateExclusive("a/absolute_symlink/broken_symlink"), SymlinkNotAllowed); - - // Test invalid paths - EXPECT_EQ(openRead("c/d/regular/a"), INVALID_DESCRIPTOR); - EXPECT_EQ(openReadDir("c/d/regular"), INVALID_DESCRIPTOR); - - // Test valid paths work - EXPECT_TRUE(AutoCloseFD{openRead("c/d/regular")}); - EXPECT_TRUE(AutoCloseFD{openCreateExclusive("a/regular")}); -} - } // namespace nix diff --git a/src/libutil-tests/file-system-at.cc b/src/libutil-tests/file-system-at.cc new file mode 100644 index 00000000000..e6039269392 --- /dev/null +++ b/src/libutil-tests/file-system-at.cc @@ -0,0 +1,172 @@ +#include +#include + +#include "nix/util/file-system-at.hh" +#include "nix/util/file-system.hh" +#include "nix/util/fs-sink.hh" + +namespace nix { + +/* ---------------------------------------------------------------------------- + * readLinkAt + * --------------------------------------------------------------------------*/ + +TEST(readLinkAt, works) +{ + std::filesystem::path tmpDir = nix::createTempDir(); + nix::AutoDelete delTmpDir(tmpDir, /*recursive=*/true); + + constexpr size_t maxPathLength = +#ifdef _WIN32 + 260 +#else + PATH_MAX +#endif + ; + std::string mediumTarget(maxPathLength / 2, 'x'); + std::string longTarget(maxPathLength - 1, 'y'); + + { + RestoreSink sink(/*startFsync=*/false); + sink.dstPath = tmpDir; + sink.dirFd = openDirectory(tmpDir); + sink.createSymlink(CanonPath("link"), "target"); + sink.createSymlink(CanonPath("relative"), "../relative/path"); + sink.createSymlink(CanonPath("absolute"), "/absolute/path"); + sink.createSymlink(CanonPath("medium"), mediumTarget); + sink.createSymlink(CanonPath("long"), longTarget); + sink.createDirectory(CanonPath("a")); + sink.createDirectory(CanonPath("a/b")); + sink.createSymlink(CanonPath("a/b/link"), "nested_target"); + sink.createRegularFile(CanonPath("regular"), [](CreateRegularFileSink &) {}); + sink.createDirectory(CanonPath("dir")); + } + + AutoCloseFD dirFd = openDirectory(tmpDir); + + EXPECT_EQ(readLinkAt(dirFd.get(), CanonPath("link")), OS_STR("target")); + EXPECT_EQ(readLinkAt(dirFd.get(), CanonPath("relative")), OS_STR("../relative/path")); + EXPECT_EQ(readLinkAt(dirFd.get(), CanonPath("absolute")), OS_STR("/absolute/path")); + EXPECT_EQ(readLinkAt(dirFd.get(), CanonPath("medium")), string_to_os_string(mediumTarget)); + EXPECT_EQ(readLinkAt(dirFd.get(), CanonPath("long")), string_to_os_string(longTarget)); + EXPECT_EQ(readLinkAt(dirFd.get(), CanonPath("a/b/link")), OS_STR("nested_target")); + + AutoCloseFD subDirFd = openDirectory(tmpDir / "a"); + EXPECT_EQ(readLinkAt(subDirFd.get(), CanonPath("b/link")), OS_STR("nested_target")); + + // Test error cases - expect SystemError on both platforms + EXPECT_THROW(readLinkAt(dirFd.get(), CanonPath("regular")), SystemError); + EXPECT_THROW(readLinkAt(dirFd.get(), CanonPath("dir")), SystemError); + EXPECT_THROW(readLinkAt(dirFd.get(), CanonPath("nonexistent")), SystemError); +} + +/* ---------------------------------------------------------------------------- + * openFileEnsureBeneathNoSymlinks + * --------------------------------------------------------------------------*/ + +TEST(openFileEnsureBeneathNoSymlinks, works) +{ + std::filesystem::path tmpDir = nix::createTempDir(); + nix::AutoDelete delTmpDir(tmpDir, /*recursive=*/true); + + { + RestoreSink sink(/*startFsync=*/false); + sink.dstPath = tmpDir; + sink.dirFd = openDirectory(tmpDir); + sink.createDirectory(CanonPath("a")); + sink.createDirectory(CanonPath("c")); + sink.createDirectory(CanonPath("c/d")); + sink.createRegularFile(CanonPath("c/d/regular"), [](CreateRegularFileSink & crf) { crf("some contents"); }); + sink.createSymlink(CanonPath("a/absolute_symlink"), tmpDir.string()); + sink.createSymlink(CanonPath("a/relative_symlink"), "../."); + sink.createSymlink(CanonPath("a/broken_symlink"), "./nonexistent"); + sink.createDirectory(CanonPath("a/b"), [](FileSystemObjectSink & dirSink, const CanonPath & relPath) { + dirSink.createDirectory(CanonPath("d")); + dirSink.createSymlink(CanonPath("c"), "./d"); + }); + // FIXME: This still follows symlinks on Unix (incorrectly succeeds) + sink.createDirectory(CanonPath("a/b/c/e")); + // Test that symlinks in intermediate path are detected during nested operations + ASSERT_THROW( + sink.createDirectory( + CanonPath("a/b/c/f"), [](FileSystemObjectSink & dirSink, const CanonPath & relPath) {}), + SymlinkNotAllowed); + ASSERT_THROW( + sink.createRegularFile( + CanonPath("a/b/c/regular"), [](CreateRegularFileSink & crf) { crf("some contents"); }), + SymlinkNotAllowed); + } + + AutoCloseFD dirFd = openDirectory(tmpDir); + + // Helper to open files with platform-specific arguments + auto openRead = [&](std::string_view path) -> Descriptor { + return openFileEnsureBeneathNoSymlinks( + dirFd.get(), + CanonPath(path), +#ifdef _WIN32 + FILE_READ_DATA | FILE_READ_ATTRIBUTES | SYNCHRONIZE, + 0 +#else + O_RDONLY, + 0 +#endif + ); + }; + + auto openReadDir = [&](std::string_view path) -> Descriptor { + return openFileEnsureBeneathNoSymlinks( + dirFd.get(), + CanonPath(path), +#ifdef _WIN32 + FILE_READ_ATTRIBUTES | SYNCHRONIZE, + FILE_DIRECTORY_FILE +#else + O_RDONLY | O_DIRECTORY, + 0 +#endif + ); + }; + + auto openCreateExclusive = [&](std::string_view path) -> Descriptor { + return openFileEnsureBeneathNoSymlinks( + dirFd.get(), + CanonPath(path), +#ifdef _WIN32 + FILE_WRITE_DATA | SYNCHRONIZE, + 0, + FILE_CREATE // Create new file, fail if exists (equivalent to O_CREAT | O_EXCL) +#else + O_CREAT | O_WRONLY | O_EXCL, + 0666 +#endif + ); + }; + + // Test that symlinks are detected and rejected + EXPECT_THROW(openRead("a/absolute_symlink"), SymlinkNotAllowed); + EXPECT_THROW(openRead("a/relative_symlink"), SymlinkNotAllowed); + EXPECT_THROW(openRead("a/absolute_symlink/a"), SymlinkNotAllowed); + EXPECT_THROW(openRead("a/absolute_symlink/c/d"), SymlinkNotAllowed); + EXPECT_THROW(openRead("a/relative_symlink/c"), SymlinkNotAllowed); + EXPECT_THROW(openRead("a/b/c/d"), SymlinkNotAllowed); + EXPECT_THROW(openRead("a/broken_symlink"), SymlinkNotAllowed); + +#if !defined(_WIN32) && !defined(__CYGWIN__) + // This returns ELOOP on cygwin when O_NOFOLLOW is used + EXPECT_EQ(openCreateExclusive("a/broken_symlink"), INVALID_DESCRIPTOR); + /* Sanity check, no symlink shenanigans and behaves the same as regular openat with O_EXCL | O_CREAT. */ + EXPECT_EQ(errno, EEXIST); +#endif + EXPECT_THROW(openCreateExclusive("a/absolute_symlink/broken_symlink"), SymlinkNotAllowed); + + // Test invalid paths + EXPECT_EQ(openRead("c/d/regular/a"), INVALID_DESCRIPTOR); + EXPECT_EQ(openReadDir("c/d/regular"), INVALID_DESCRIPTOR); + + // Test valid paths work + EXPECT_TRUE(AutoCloseFD{openRead("c/d/regular")}); + EXPECT_TRUE(AutoCloseFD{openCreateExclusive("a/regular")}); +} + +} // namespace nix diff --git a/src/libutil-tests/meson.build b/src/libutil-tests/meson.build index 9e7318bee85..34dbe55ddf9 100644 --- a/src/libutil-tests/meson.build +++ b/src/libutil-tests/meson.build @@ -60,6 +60,7 @@ sources = files( 'executable-path.cc', 'file-content-address.cc', 'file-descriptor.cc', + 'file-system-at.cc', 'file-system.cc', 'git.cc', 'hash.cc', diff --git a/src/libutil-tests/unix/file-descriptor.cc b/src/libutil-tests/unix/file-system-at.cc similarity index 99% rename from src/libutil-tests/unix/file-descriptor.cc rename to src/libutil-tests/unix/file-system-at.cc index 4e4da1e1230..c91235ba6e8 100644 --- a/src/libutil-tests/unix/file-descriptor.cc +++ b/src/libutil-tests/unix/file-system-at.cc @@ -1,6 +1,6 @@ #include -#include "nix/util/file-descriptor.hh" +#include "nix/util/file-system-at.hh" #include "nix/util/file-system.hh" #include "nix/util/fs-sink.hh" #include "nix/util/processes.hh" diff --git a/src/libutil-tests/unix/meson.build b/src/libutil-tests/unix/meson.build index 2861148a255..1baeeb3410c 100644 --- a/src/libutil-tests/unix/meson.build +++ b/src/libutil-tests/unix/meson.build @@ -1,3 +1,3 @@ sources += files( - 'file-descriptor.cc', + 'file-system-at.cc', ) diff --git a/src/libutil/fs-sink.cc b/src/libutil/fs-sink.cc index 2e06f95db36..a7e55aea883 100644 --- a/src/libutil/fs-sink.cc +++ b/src/libutil/fs-sink.cc @@ -2,6 +2,7 @@ #include "nix/util/error.hh" #include "nix/util/config-global.hh" +#include "nix/util/file-system-at.hh" #include "nix/util/fs-sink.hh" #ifdef _WIN32 diff --git a/src/libutil/include/nix/util/file-descriptor.hh b/src/libutil/include/nix/util/file-descriptor.hh index 68be0b35d91..6283dbbe8fe 100644 --- a/src/libutil/include/nix/util/file-descriptor.hh +++ b/src/libutil/include/nix/util/file-descriptor.hh @@ -1,5 +1,13 @@ #pragma once -///@file +/** + * @file + * + * @brief File descriptor operations for almost arbitrary file + * descriptors. + * + * More specialized file-system-specific operations are in + * @ref file-system-at.hh. + */ #include "nix/util/canon-path.hh" #include "nix/util/error.hh" @@ -184,14 +192,6 @@ std::string drainFD(Descriptor fd, DrainFdOpts opts = {}); */ void drainFD(Descriptor fd, Sink & sink, DrainFdSinkOpts opts = {}); -/** - * Read a symlink relative to a directory file descriptor. - * - * @throws SystemError on any I/O errors. - * @throws Interrupted if interrupted. - */ -OsString readLinkAt(Descriptor dirFd, const CanonPath & path); - /** * Get [Standard Input](https://en.wikipedia.org/wiki/Standard_streams#Standard_input_(stdin)) */ @@ -288,77 +288,6 @@ void closeOnExec(Descriptor fd); } // namespace unix #endif -#ifdef __linux__ -namespace linux { - -/** - * Wrapper around Linux's openat2 syscall introduced in Linux 5.6. - * - * @see https://man7.org/linux/man-pages/man2/openat2.2.html - * @see https://man7.org/linux/man-pages/man2/open_how.2type.html -v* - * @param flags O_* flags - * @param mode Mode for O_{CREAT,TMPFILE} - * @param resolve RESOLVE_* flags - * - * @return nullopt if openat2 is not supported by the kernel. - */ -std::optional openat2(Descriptor dirFd, const char * path, uint64_t flags, uint64_t mode, uint64_t resolve); - -} // namespace linux -#endif - -/** - * Safe(r) function to open a file relative to dirFd, while - * disallowing escaping from a directory and any symlinks in the process. - * - * @note On Windows, implemented via NtCreateFile single path component traversal - * with FILE_OPEN_REPARSE_POINT. On Unix, uses RESOLVE_BENEATH with openat2 when - * available, or falls back to openat single path component traversal. - * - * @param dirFd Directory handle to open relative to - * @param path Relative path (no .. or . components) - * @param desiredAccess (Windows) Windows ACCESS_MASK (e.g., GENERIC_READ, FILE_WRITE_DATA) - * @param createOptions (Windows) Windows create options (e.g., FILE_NON_DIRECTORY_FILE) - * @param createDisposition (Windows) FILE_OPEN, FILE_CREATE, etc. - * @param flags (Unix) O_* flags - * @param mode (Unix) Mode for O_{CREAT,TMPFILE} - * - * @pre path.isRoot() is false - * - * @throws SymlinkNotAllowed if any path components are symlinks - * @throws SystemError on other errors - */ -Descriptor openFileEnsureBeneathNoSymlinks( - Descriptor dirFd, - const CanonPath & path, -#ifdef _WIN32 - ACCESS_MASK desiredAccess, - ULONG createOptions, - ULONG createDisposition = FILE_OPEN -#else - int flags, - mode_t mode = 0 -#endif -); - -#ifndef _WIN32 -namespace unix { - -/** - * Try to change the mode of file named by \ref path relative to the parent directory denoted by \ref dirFd. - * - * @note When on linux without fchmodat2 support and without procfs mounted falls back to fchmodat without - * AT_SYMLINK_NOFOLLOW, since it's the best we can do without failing. - * - * @pre path.isRoot() is false - * @throws SysError if any operation fails - */ -void fchmodatTryNoFollow(Descriptor dirFd, const CanonPath & path, mode_t mode); - -} // namespace unix -#endif - MakeError(EndOfFile, Error); #ifdef _WIN32 diff --git a/src/libutil/include/nix/util/file-system-at.hh b/src/libutil/include/nix/util/file-system-at.hh new file mode 100644 index 00000000000..b1b636d83b8 --- /dev/null +++ b/src/libutil/include/nix/util/file-system-at.hh @@ -0,0 +1,107 @@ +#pragma once +/** + * @file + * + * @brief File system operations relative to directory file descriptors. + * + * This header provides cross-platform wrappers for POSIX `*at` functions + * (e.g., `symlinkat`, `mkdirat`, `readlinkat`) that operate relative to + * a directory file descriptor. + * + * Prefer this to @ref file-system.hh because file descriptor-based file + * system operations are necessary to avoid + * [TOCTOU](https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use) + * issues. + */ + +#include "nix/util/file-descriptor.hh" + +#include + +#ifdef _WIN32 +# define WIN32_LEAN_AND_MEAN +# include +#endif + +namespace nix { + +/** + * Read a symlink relative to a directory file descriptor. + * + * @throws SystemError on any I/O errors. + * @throws Interrupted if interrupted. + */ +OsString readLinkAt(Descriptor dirFd, const CanonPath & path); + +/** + * Safe(r) function to open a file relative to dirFd, while + * disallowing escaping from a directory and any symlinks in the process. + * + * @note On Windows, implemented via NtCreateFile single path component traversal + * with FILE_OPEN_REPARSE_POINT. On Unix, uses RESOLVE_BENEATH with openat2 when + * available, or falls back to openat single path component traversal. + * + * @param dirFd Directory handle to open relative to + * @param path Relative path (no .. or . components) + * @param desiredAccess (Windows) Windows ACCESS_MASK (e.g., GENERIC_READ, FILE_WRITE_DATA) + * @param createOptions (Windows) Windows create options (e.g., FILE_NON_DIRECTORY_FILE) + * @param createDisposition (Windows) FILE_OPEN, FILE_CREATE, etc. + * @param flags (Unix) O_* flags + * @param mode (Unix) Mode for O_{CREAT,TMPFILE} + * + * @pre path.isRoot() is false + * + * @throws SymlinkNotAllowed if any path components are symlinks + * @throws SystemError on other errors + */ +Descriptor openFileEnsureBeneathNoSymlinks( + Descriptor dirFd, + const CanonPath & path, +#ifdef _WIN32 + ACCESS_MASK desiredAccess, + ULONG createOptions, + ULONG createDisposition = FILE_OPEN +#else + int flags, + mode_t mode = 0 +#endif +); + +#ifdef __linux__ +namespace linux { + +/** + * Wrapper around Linux's openat2 syscall introduced in Linux 5.6. + * + * @see https://man7.org/linux/man-pages/man2/openat2.2.html + * @see https://man7.org/linux/man-pages/man2/open_how.2type.html +v* + * @param flags O_* flags + * @param mode Mode for O_{CREAT,TMPFILE} + * @param resolve RESOLVE_* flags + * + * @return nullopt if openat2 is not supported by the kernel. + */ +std::optional openat2(Descriptor dirFd, const char * path, uint64_t flags, uint64_t mode, uint64_t resolve); + +} // namespace linux +#endif + +#ifndef _WIN32 +namespace unix { + +/** + * Try to change the mode of file named by \ref path relative to the parent directory denoted by \ref dirFd. + * + * @note When on linux without fchmodat2 support and without procfs mounted falls back to fchmodat without + * AT_SYMLINK_NOFOLLOW, since it's the best we can do without failing. + * + * @pre path.isRoot() is false + * @throws SysError if any operation fails + */ +void fchmodatTryNoFollow(Descriptor dirFd, const CanonPath & path, mode_t mode); + +} // namespace unix +#endif + +} // namespace nix diff --git a/src/libutil/include/nix/util/file-system.hh b/src/libutil/include/nix/util/file-system.hh index 0ac61728d56..6fd30196213 100644 --- a/src/libutil/include/nix/util/file-system.hh +++ b/src/libutil/include/nix/util/file-system.hh @@ -2,7 +2,10 @@ /** * @file * - * Utilities for working with the file system and file paths. + * @brief Utilities for working with the file system and file paths. + * + * Please try to use @ref file-system-at.hh instead of this where + * possible, for the reasons given in the documentation of that header. */ #include "nix/util/types.hh" diff --git a/src/libutil/include/nix/util/meson.build b/src/libutil/include/nix/util/meson.build index be8e603ca57..a05af87b575 100644 --- a/src/libutil/include/nix/util/meson.build +++ b/src/libutil/include/nix/util/meson.build @@ -37,6 +37,7 @@ headers = files( 'file-descriptor.hh', 'file-path-impl.hh', 'file-path.hh', + 'file-system-at.hh', 'file-system.hh', 'finally.hh', 'fmt.hh', diff --git a/src/libutil/unix/file-descriptor.cc b/src/libutil/unix/file-descriptor.cc index ae3528eb503..ec01f337de9 100644 --- a/src/libutil/unix/file-descriptor.cc +++ b/src/libutil/unix/file-descriptor.cc @@ -1,32 +1,13 @@ -#include "nix/util/canon-path.hh" #include "nix/util/file-system.hh" #include "nix/util/signals.hh" #include "nix/util/finally.hh" #include "nix/util/serialise.hh" -#include "nix/util/source-accessor.hh" #include #include #include #include -#if defined(__linux__) -# include /* pull __NR_* definitions */ -#endif - -#if defined(__linux__) && defined(__NR_openat2) -# define HAVE_OPENAT2 1 -# include -#else -# define HAVE_OPENAT2 0 -#endif - -#if defined(__linux__) && defined(__NR_fchmodat2) -# define HAVE_FCHMODAT2 1 -#else -# define HAVE_FCHMODAT2 0 -#endif - #include "util-config-private.hh" #include "util-unix-config-private.hh" @@ -222,192 +203,4 @@ void unix::closeOnExec(int fd) throw SysError("setting close-on-exec flag"); } -#ifdef __linux__ - -namespace linux { - -std::optional openat2(Descriptor dirFd, const char * path, uint64_t flags, uint64_t mode, uint64_t resolve) -{ -# if HAVE_OPENAT2 - /* Cache the result of whether openat2 is not supported. */ - static std::atomic_flag unsupported{}; - - if (!unsupported.test()) { - /* No glibc wrapper yet, but there's a patch: - * https://patchwork.sourceware.org/project/glibc/patch/20251029200519.3203914-1-adhemerval.zanella@linaro.org/ - */ - auto how = ::open_how{.flags = flags, .mode = mode, .resolve = resolve}; - auto res = ::syscall(__NR_openat2, dirFd, path, &how, sizeof(how)); - /* Cache that the syscall is not supported. */ - if (res < 0 && errno == ENOSYS) { - unsupported.test_and_set(); - return std::nullopt; - } - - return res; - } -# endif - return std::nullopt; -} - -} // namespace linux - -#endif - -void unix::fchmodatTryNoFollow(Descriptor dirFd, const CanonPath & path, mode_t mode) -{ - assert(!path.isRoot()); - -#if HAVE_FCHMODAT2 - /* Cache whether fchmodat2 is not supported. */ - static std::atomic_flag fchmodat2Unsupported{}; - if (!fchmodat2Unsupported.test()) { - /* Try with fchmodat2 first. */ - auto res = ::syscall(__NR_fchmodat2, dirFd, path.rel_c_str(), mode, AT_SYMLINK_NOFOLLOW); - /* Cache that the syscall is not supported. */ - if (res < 0) { - if (errno == ENOSYS) - fchmodat2Unsupported.test_and_set(); - else - throw SysError("fchmodat2 '%s' relative to parent directory", path.rel()); - } else - return; - } -#endif - -#ifdef __linux__ - AutoCloseFD pathFd = ::openat(dirFd, path.rel_c_str(), O_PATH | O_NOFOLLOW | O_CLOEXEC); - if (!pathFd) - throw SysError( - "opening '%s' relative to parent directory to get an O_PATH file descriptor (fchmodat2 is unsupported)", - path.rel()); - - struct ::stat st; - /* Possible since https://github.com/torvalds/linux/commit/55815f70147dcfa3ead5738fd56d3574e2e3c1c2 (3.6) */ - if (::fstat(pathFd.get(), &st) == -1) - throw SysError("statting '%s' relative to parent directory via O_PATH file descriptor", path.rel()); - - if (S_ISLNK(st.st_mode)) - throw SysError(EOPNOTSUPP, "can't change mode of symlink '%s' relative to parent directory", path.rel()); - - static std::atomic_flag dontHaveProc{}; - if (!dontHaveProc.test()) { - static const CanonPath selfProcFd = CanonPath("/proc/self/fd"); - - auto selfProcFdPath = selfProcFd / std::to_string(pathFd.get()); - if (int res = ::chmod(selfProcFdPath.c_str(), mode); res == -1) { - if (errno == ENOENT) - dontHaveProc.test_and_set(); - else - throw SysError("chmod '%s' ('%s' relative to parent directory)", selfProcFdPath, path); - } else - return; - } - - static std::atomic warned = false; - warnOnce(warned, "kernel doesn't support fchmodat2 and procfs isn't mounted, falling back to fchmodat"); -#endif - - int res = ::fchmodat( - dirFd, - path.rel_c_str(), - mode, -#if defined(__APPLE__) || defined(__FreeBSD__) - AT_SYMLINK_NOFOLLOW -#else - 0 -#endif - ); - - if (res == -1) - throw SysError("fchmodat '%s' relative to parent directory", path.rel()); -} - -static Descriptor -openFileEnsureBeneathNoSymlinksIterative(Descriptor dirFd, const CanonPath & path, int flags, mode_t mode) -{ - AutoCloseFD parentFd; - auto nrComponents = std::ranges::distance(path); - assert(nrComponents >= 1); - auto components = std::views::take(path, nrComponents - 1); /* Everything but last component */ - auto getParentFd = [&]() { return parentFd ? parentFd.get() : dirFd; }; - - /* This rather convoluted loop is necessary to avoid TOCTOU when validating that - no inner path component is a symlink. */ - for (auto it = components.begin(); it != components.end(); ++it) { - auto component = std::string(*it); /* Copy into a string to make NUL terminated. */ - assert(component != ".." && !component.starts_with('/')); /* In case invariant is broken somehow.. */ - - AutoCloseFD parentFd2 = ::openat( - getParentFd(), /* First iteration uses dirFd. */ - component.c_str(), - O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC -#ifdef __linux__ - | O_PATH /* Linux-specific optimization. Files are open only for path resolution purposes. */ -#endif -#ifdef __FreeBSD__ - | O_RESOLVE_BENEATH /* Further guard against any possible SNAFUs. */ -#endif - ); - - if (!parentFd2) { - /* Construct the CanonPath for error message. */ - auto path2 = std::ranges::fold_left(components.begin(), ++it, CanonPath::root, [](auto lhs, auto rhs) { - lhs.push(rhs); - return lhs; - }); - - if (errno == ENOTDIR) /* Path component might be a symlink. */ { - struct ::stat st; - if (::fstatat(getParentFd(), component.c_str(), &st, AT_SYMLINK_NOFOLLOW) == 0 && S_ISLNK(st.st_mode)) - throw SymlinkNotAllowed(path2); - errno = ENOTDIR; /* Restore the errno. */ - } else if (errno == ELOOP) { - throw SymlinkNotAllowed(path2); - } - - return INVALID_DESCRIPTOR; - } - - parentFd = std::move(parentFd2); - } - - auto res = ::openat(getParentFd(), std::string(path.baseName().value()).c_str(), flags | O_NOFOLLOW, mode); - if (res < 0 && errno == ELOOP) - throw SymlinkNotAllowed(path); - return res; -} - -Descriptor openFileEnsureBeneathNoSymlinks(Descriptor dirFd, const CanonPath & path, int flags, mode_t mode) -{ - assert(!path.rel().starts_with('/')); /* Just in case the invariant is somehow broken. */ - assert(!path.isRoot()); -#if HAVE_OPENAT2 - auto maybeFd = linux::openat2( - dirFd, path.rel_c_str(), flags, static_cast(mode), RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS); - if (maybeFd) { - if (*maybeFd < 0 && errno == ELOOP) - throw SymlinkNotAllowed(path); - return *maybeFd; - } -#endif - return openFileEnsureBeneathNoSymlinksIterative(dirFd, path, flags, mode); -} - -OsString readLinkAt(Descriptor dirFd, const CanonPath & path) -{ - assert(!path.isRoot()); - assert(!path.rel().starts_with('/')); /* Just in case the invariant is somehow broken. */ - std::vector buf; - for (ssize_t bufSize = PATH_MAX / 4; true; bufSize += bufSize / 2) { - checkInterrupt(); - buf.resize(bufSize); - ssize_t rlSize = ::readlinkat(dirFd, path.rel_c_str(), buf.data(), bufSize); - if (rlSize == -1) - throw SysError("reading symbolic link '%1%' relative to parent directory", path.rel()); - else if (rlSize < bufSize) - return {buf.data(), static_cast(rlSize)}; - } -} - } // namespace nix diff --git a/src/libutil/unix/file-system-at.cc b/src/libutil/unix/file-system-at.cc new file mode 100644 index 00000000000..866cdd31ced --- /dev/null +++ b/src/libutil/unix/file-system-at.cc @@ -0,0 +1,216 @@ +#include "nix/util/file-system-at.hh" +#include "nix/util/file-system.hh" +#include "nix/util/signals.hh" +#include "nix/util/source-accessor.hh" + +#include +#include + +#if defined(__linux__) +# include /* pull __NR_* definitions */ +#endif + +#if defined(__linux__) && defined(__NR_openat2) +# define HAVE_OPENAT2 1 +# include +#else +# define HAVE_OPENAT2 0 +#endif + +#if defined(__linux__) && defined(__NR_fchmodat2) +# define HAVE_FCHMODAT2 1 +#else +# define HAVE_FCHMODAT2 0 +#endif + +namespace nix { + +#ifdef __linux__ + +namespace linux { + +std::optional openat2(Descriptor dirFd, const char * path, uint64_t flags, uint64_t mode, uint64_t resolve) +{ +# if HAVE_OPENAT2 + /* Cache the result of whether openat2 is not supported. */ + static std::atomic_flag unsupported{}; + + if (!unsupported.test()) { + /* No glibc wrapper yet, but there's a patch: + * https://patchwork.sourceware.org/project/glibc/patch/20251029200519.3203914-1-adhemerval.zanella@linaro.org/ + */ + auto how = ::open_how{.flags = flags, .mode = mode, .resolve = resolve}; + auto res = ::syscall(__NR_openat2, dirFd, path, &how, sizeof(how)); + /* Cache that the syscall is not supported. */ + if (res < 0 && errno == ENOSYS) { + unsupported.test_and_set(); + return std::nullopt; + } + + return res; + } +# endif + return std::nullopt; +} + +} // namespace linux + +#endif + +void unix::fchmodatTryNoFollow(Descriptor dirFd, const CanonPath & path, mode_t mode) +{ + assert(!path.isRoot()); + +#if HAVE_FCHMODAT2 + /* Cache whether fchmodat2 is not supported. */ + static std::atomic_flag fchmodat2Unsupported{}; + if (!fchmodat2Unsupported.test()) { + /* Try with fchmodat2 first. */ + auto res = ::syscall(__NR_fchmodat2, dirFd, path.rel_c_str(), mode, AT_SYMLINK_NOFOLLOW); + /* Cache that the syscall is not supported. */ + if (res < 0) { + if (errno == ENOSYS) + fchmodat2Unsupported.test_and_set(); + else + throw SysError("fchmodat2 '%s' relative to parent directory", path.rel()); + } else + return; + } +#endif + +#ifdef __linux__ + AutoCloseFD pathFd = ::openat(dirFd, path.rel_c_str(), O_PATH | O_NOFOLLOW | O_CLOEXEC); + if (!pathFd) + throw SysError( + "opening '%s' relative to parent directory to get an O_PATH file descriptor (fchmodat2 is unsupported)", + path.rel()); + + struct ::stat st; + /* Possible since https://github.com/torvalds/linux/commit/55815f70147dcfa3ead5738fd56d3574e2e3c1c2 (3.6) */ + if (::fstat(pathFd.get(), &st) == -1) + throw SysError("statting '%s' relative to parent directory via O_PATH file descriptor", path.rel()); + + if (S_ISLNK(st.st_mode)) + throw SysError(EOPNOTSUPP, "can't change mode of symlink '%s' relative to parent directory", path.rel()); + + static std::atomic_flag dontHaveProc{}; + if (!dontHaveProc.test()) { + static const CanonPath selfProcFd = CanonPath("/proc/self/fd"); + + auto selfProcFdPath = selfProcFd / std::to_string(pathFd.get()); + if (int res = ::chmod(selfProcFdPath.c_str(), mode); res == -1) { + if (errno == ENOENT) + dontHaveProc.test_and_set(); + else + throw SysError("chmod '%s' ('%s' relative to parent directory)", selfProcFdPath, path); + } else + return; + } + + static std::atomic warned = false; + warnOnce(warned, "kernel doesn't support fchmodat2 and procfs isn't mounted, falling back to fchmodat"); +#endif + + int res = ::fchmodat( + dirFd, + path.rel_c_str(), + mode, +#if defined(__APPLE__) || defined(__FreeBSD__) + AT_SYMLINK_NOFOLLOW +#else + 0 +#endif + ); + + if (res == -1) + throw SysError("fchmodat '%s' relative to parent directory", path.rel()); +} + +static Descriptor +openFileEnsureBeneathNoSymlinksIterative(Descriptor dirFd, const CanonPath & path, int flags, mode_t mode) +{ + AutoCloseFD parentFd; + auto nrComponents = std::ranges::distance(path); + assert(nrComponents >= 1); + auto components = std::views::take(path, nrComponents - 1); /* Everything but last component */ + auto getParentFd = [&]() { return parentFd ? parentFd.get() : dirFd; }; + + /* This rather convoluted loop is necessary to avoid TOCTOU when validating that + no inner path component is a symlink. */ + for (auto it = components.begin(); it != components.end(); ++it) { + auto component = std::string(*it); /* Copy into a string to make NUL terminated. */ + assert(component != ".." && !component.starts_with('/')); /* In case invariant is broken somehow.. */ + + AutoCloseFD parentFd2 = ::openat( + getParentFd(), /* First iteration uses dirFd. */ + component.c_str(), + O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC +#ifdef __linux__ + | O_PATH /* Linux-specific optimization. Files are open only for path resolution purposes. */ +#endif +#ifdef __FreeBSD__ + | O_RESOLVE_BENEATH /* Further guard against any possible SNAFUs. */ +#endif + ); + + if (!parentFd2) { + /* Construct the CanonPath for error message. */ + auto path2 = std::ranges::fold_left(components.begin(), ++it, CanonPath::root, [](auto lhs, auto rhs) { + lhs.push(rhs); + return lhs; + }); + + if (errno == ENOTDIR) /* Path component might be a symlink. */ { + struct ::stat st; + if (::fstatat(getParentFd(), component.c_str(), &st, AT_SYMLINK_NOFOLLOW) == 0 && S_ISLNK(st.st_mode)) + throw SymlinkNotAllowed(path2); + errno = ENOTDIR; /* Restore the errno. */ + } else if (errno == ELOOP) { + throw SymlinkNotAllowed(path2); + } + + return INVALID_DESCRIPTOR; + } + + parentFd = std::move(parentFd2); + } + + auto res = ::openat(getParentFd(), std::string(path.baseName().value()).c_str(), flags | O_NOFOLLOW, mode); + if (res < 0 && errno == ELOOP) + throw SymlinkNotAllowed(path); + return res; +} + +Descriptor openFileEnsureBeneathNoSymlinks(Descriptor dirFd, const CanonPath & path, int flags, mode_t mode) +{ + assert(!path.rel().starts_with('/')); /* Just in case the invariant is somehow broken. */ + assert(!path.isRoot()); +#if HAVE_OPENAT2 + auto maybeFd = linux::openat2( + dirFd, path.rel_c_str(), flags, static_cast(mode), RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS); + if (maybeFd) { + if (*maybeFd < 0 && errno == ELOOP) + throw SymlinkNotAllowed(path); + return *maybeFd; + } +#endif + return openFileEnsureBeneathNoSymlinksIterative(dirFd, path, flags, mode); +} + +OsString readLinkAt(Descriptor dirFd, const CanonPath & path) +{ + assert(!path.isRoot()); + assert(!path.rel().starts_with('/')); /* Just in case the invariant is somehow broken. */ + std::vector buf; + for (ssize_t bufSize = PATH_MAX / 4; true; bufSize += bufSize / 2) { + checkInterrupt(); + buf.resize(bufSize); + ssize_t rlSize = ::readlinkat(dirFd, path.rel_c_str(), buf.data(), bufSize); + if (rlSize == -1) + throw SysError("reading symbolic link '%1%' relative to parent directory", path.rel()); + else if (rlSize < bufSize) + return {buf.data(), static_cast(rlSize)}; + } +} + +} // namespace nix diff --git a/src/libutil/unix/file-system.cc b/src/libutil/unix/file-system.cc index ab9fed6ea41..d3b6cbe28a3 100644 --- a/src/libutil/unix/file-system.cc +++ b/src/libutil/unix/file-system.cc @@ -14,6 +14,7 @@ #endif #include "nix/util/file-system.hh" +#include "nix/util/file-system-at.hh" #include "nix/util/environment-variables.hh" #include "nix/util/signals.hh" #include "nix/util/util.hh" diff --git a/src/libutil/unix/meson.build b/src/libutil/unix/meson.build index 61390571fb8..e59b4b6386d 100644 --- a/src/libutil/unix/meson.build +++ b/src/libutil/unix/meson.build @@ -53,6 +53,7 @@ sources += files( 'environment-variables.cc', 'file-descriptor.cc', 'file-path.cc', + 'file-system-at.cc', 'file-system.cc', 'muxable-pipe.cc', 'os-string.cc', diff --git a/src/libutil/windows/file-descriptor.cc b/src/libutil/windows/file-descriptor.cc index adea665ba34..bd50e40be79 100644 --- a/src/libutil/windows/file-descriptor.cc +++ b/src/libutil/windows/file-descriptor.cc @@ -2,8 +2,6 @@ #include "nix/util/signals.hh" #include "nix/util/finally.hh" #include "nix/util/serialise.hh" -#include "nix/util/file-path.hh" -#include "nix/util/source-accessor.hh" #include @@ -13,8 +11,6 @@ #include #define WIN32_LEAN_AND_MEAN #include -#include -#include namespace nix { @@ -152,296 +148,4 @@ off_t lseek(HANDLE h, off_t offset, int whence) return newPos.QuadPart; } -namespace windows { - -namespace { - -/** - * Open a file/directory relative to a directory handle using NtCreateFile. - * - * @param dirFd Directory handle to open relative to - * @param pathComponent Single path component (not a full path) - * @param desiredAccess Access rights requested - * @param createOptions NT create options flags - * @param createDisposition FILE_OPEN, FILE_CREATE, etc. - * @return Handle to the opened file/directory (caller must close) - */ -HANDLE ntOpenAt( - Descriptor dirFd, - std::wstring_view pathComponent, - ACCESS_MASK desiredAccess, - ULONG createOptions, - ULONG createDisposition = FILE_OPEN) -{ - /* Set up UNICODE_STRING for the relative path */ - UNICODE_STRING pathStr; - pathStr.Buffer = const_cast(pathComponent.data()); - pathStr.Length = static_cast(pathComponent.size() * sizeof(wchar_t)); - pathStr.MaximumLength = pathStr.Length; - - /* Set up OBJECT_ATTRIBUTES to open relative to dirFd */ - OBJECT_ATTRIBUTES objAttrs; - InitializeObjectAttributes( - &objAttrs, - &pathStr, - 0, // No special flags - dirFd, // RootDirectory - nullptr // No security descriptor - ); - - /* Open using NT API */ - IO_STATUS_BLOCK ioStatus; - HANDLE h; - NTSTATUS status = NtCreateFile( - &h, - desiredAccess, - &objAttrs, - &ioStatus, - nullptr, // No allocation size - FILE_ATTRIBUTE_NORMAL, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - createDisposition, - createOptions | FILE_SYNCHRONOUS_IO_NONALERT, - nullptr, // No EA buffer - 0 // No EA length - ); - - if (status != 0) - throw WinError( - RtlNtStatusToDosError(status), "opening %s relative to directory handle", PathFmt(pathComponent)); - - return h; -} - -/** - * Open a symlink relative to a directory handle without following it. - * - * @param dirFd Directory handle to open relative to - * @param path Relative path to the symlink - * @return Handle to the symlink (caller must close) - */ -HANDLE openSymlinkAt(Descriptor dirFd, const CanonPath & path) -{ - assert(!path.isRoot()); - assert(!path.rel().starts_with('/')); /* Just in case the invariant is somehow broken. */ - - std::wstring wpath = string_to_os_string(path.rel()); - return ntOpenAt(dirFd, wpath, FILE_READ_ATTRIBUTES | SYNCHRONIZE, FILE_OPEN_REPARSE_POINT); -} - -/** - * This struct isn't defined in the normal Windows SDK, but only in the Windows Driver Kit. - * - * I (@Ericson2314) would not normally do something like this, but LLVM - * has decided that this is in fact stable, per - * https://github.com/llvm/llvm-project/blob/main/libcxx/src/filesystem/posix_compat.h, - * so I guess that is good enough for us. GCC doesn't support symlinks - * at all on windows so we have to put it here, not grab it from private - * c++ standard library headers anyways. - */ -struct ReparseDataBuffer -{ - unsigned long ReparseTag; - unsigned short ReparseDataLength; - unsigned short Reserved; - - union - { - struct - { - unsigned short SubstituteNameOffset; - unsigned short SubstituteNameLength; - unsigned short PrintNameOffset; - unsigned short PrintNameLength; - unsigned long Flags; - wchar_t PathBuffer[1]; - } SymbolicLinkReparseBuffer; - - struct - { - unsigned short SubstituteNameOffset; - unsigned short SubstituteNameLength; - unsigned short PrintNameOffset; - unsigned short PrintNameLength; - wchar_t PathBuffer[1]; - } MountPointReparseBuffer; - - struct - { - unsigned char DataBuffer[1]; - } GenericReparseBuffer; - }; -}; - -/** - * Read the target of a symlink from an open handle. - * - * @param linkHandle Handle to a symlink (must have been opened with FILE_OPEN_REPARSE_POINT) - * @return The symlink target as a wide string - */ -OsString readSymlinkTarget(HANDLE linkHandle) -{ - uint8_t buf[MAXIMUM_REPARSE_DATA_BUFFER_SIZE]; - DWORD out; - - checkInterrupt(); - - if (!DeviceIoControl(linkHandle, FSCTL_GET_REPARSE_POINT, nullptr, 0, buf, sizeof(buf), &out, nullptr)) - throw WinError("reading reparse point for handle %d", linkHandle); - - const auto * reparse = reinterpret_cast(buf); - size_t path_buf_offset = offsetof(ReparseDataBuffer, SymbolicLinkReparseBuffer.PathBuffer[0]); - - if (out < path_buf_offset) { - auto fullPath = handleToPath(linkHandle); - throw WinError( - DWORD{ERROR_REPARSE_TAG_INVALID}, "invalid reparse data for %d:%s", linkHandle, PathFmt(fullPath)); - } - - if (reparse->ReparseTag != IO_REPARSE_TAG_SYMLINK) { - auto fullPath = handleToPath(linkHandle); - throw WinError(DWORD{ERROR_REPARSE_TAG_INVALID}, "not a symlink: %d:%s", linkHandle, PathFmt(fullPath)); - } - - const auto & symlink = reparse->SymbolicLinkReparseBuffer; - unsigned short name_offset, name_length; - - /* Prefer PrintName over SubstituteName if available */ - if (symlink.PrintNameLength == 0) { - name_offset = symlink.SubstituteNameOffset; - name_length = symlink.SubstituteNameLength; - } else { - name_offset = symlink.PrintNameOffset; - name_length = symlink.PrintNameLength; - } - - if (path_buf_offset + name_offset + name_length > out) { - auto fullPath = handleToPath(linkHandle); - throw WinError( - DWORD{ERROR_REPARSE_TAG_INVALID}, "invalid symlink data for %d:%s", linkHandle, PathFmt(fullPath)); - } - - /* Extract the target path */ - const wchar_t * target_start = &symlink.PathBuffer[name_offset / sizeof(wchar_t)]; - size_t target_len = name_length / sizeof(wchar_t); - - return {target_start, target_len}; -} - -/** - * Check if a handle refers to a reparse point (e.g., symlink). - * - * @param handle Open file/directory handle - * @return true if the handle refers to a reparse point - */ -bool isReparsePoint(HANDLE handle) -{ - FILE_BASIC_INFO basicInfo; - if (!GetFileInformationByHandleEx(handle, FileBasicInfo, &basicInfo, sizeof(basicInfo))) - throw WinError("GetFileInformationByHandleEx"); - - return (basicInfo.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0; -} - -} // anonymous namespace - -} // namespace windows - -Descriptor openFileEnsureBeneathNoSymlinks( - Descriptor dirFd, const CanonPath & path, ACCESS_MASK desiredAccess, ULONG createOptions, ULONG createDisposition) -{ - assert(!path.isRoot()); - assert(!path.rel().starts_with('/')); /* Just in case the invariant is somehow broken. */ - - AutoCloseFD parentFd; - auto nrComponents = std::ranges::distance(path); - assert(nrComponents >= 1); - auto components = std::views::take(path, nrComponents - 1); /* Everything but last component */ - auto getParentFd = [&]() { return parentFd ? parentFd.get() : dirFd; }; - - /* Helper to construct CanonPath from components up to (and including) the given iterator */ - auto pathUpTo = [&](auto it) { - return std::ranges::fold_left(components.begin(), it, CanonPath::root, [](auto lhs, auto rhs) { - lhs.push(rhs); - return lhs; - }); - }; - - /* Helper to check if a component is a symlink and throw SymlinkNotAllowed if so */ - auto throwIfSymlink = [&](std::wstring_view component, const CanonPath & pathForError) { - try { - auto testFd = - ntOpenAt(getParentFd(), component, FILE_READ_ATTRIBUTES | SYNCHRONIZE, FILE_OPEN_REPARSE_POINT); - AutoCloseFD testHandle(testFd); - if (isReparsePoint(testHandle.get())) - throw SymlinkNotAllowed(pathForError); - } catch (SymlinkNotAllowed &) { - throw; - } catch (...) { - /* If we can't determine, ignore and let caller handle original error */ - } - }; - - /* Iterate through each path component to ensure no symlinks in intermediate directories. - * This prevents TOCTOU issues by opening each component relative to the parent. */ - for (auto it = components.begin(); it != components.end(); ++it) { - std::wstring wcomponent = string_to_os_string(std::string(*it)); - - /* Open directory without following symlinks */ - AutoCloseFD parentFd2; - try { - parentFd2 = ntOpenAt( - getParentFd(), - wcomponent, - FILE_TRAVERSE | SYNCHRONIZE, // Just need traversal rights - FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT // Open directory, don't follow symlinks - ); - } catch (WinError & e) { - /* Check if this is because it's a symlink */ - if (e.lastError == ERROR_CANT_ACCESS_FILE || e.lastError == ERROR_ACCESS_DENIED) { - throwIfSymlink(wcomponent, pathUpTo(std::next(it))); - } - throw; - } - - /* Check if what we opened is actually a symlink */ - if (isReparsePoint(parentFd2.get())) { - throw SymlinkNotAllowed(pathUpTo(std::next(it))); - } - - parentFd = std::move(parentFd2); - } - - /* Now open the final component with requested flags */ - std::wstring finalComponent = string_to_os_string(std::string(path.baseName().value())); - - HANDLE finalHandle; - try { - finalHandle = ntOpenAt( - getParentFd(), - finalComponent, - desiredAccess, - createOptions | FILE_OPEN_REPARSE_POINT, // Don't follow symlinks on final component either - createDisposition); - } catch (WinError & e) { - /* Check if final component is a symlink when we requested to not follow it */ - if (e.lastError == ERROR_CANT_ACCESS_FILE) { - throwIfSymlink(finalComponent, path); - } - throw; - } - - /* Final check: did we accidentally open a symlink? */ - if (isReparsePoint(finalHandle)) - throw SymlinkNotAllowed(path); - - return finalHandle; -} - -OsString readLinkAt(Descriptor dirFd, const CanonPath & path) -{ - AutoCloseFD linkHandle(windows::openSymlinkAt(dirFd, path)); - return windows::readSymlinkTarget(linkHandle.get()); -} - } // namespace nix diff --git a/src/libutil/windows/file-system-at.cc b/src/libutil/windows/file-system-at.cc new file mode 100644 index 00000000000..4e012bb0867 --- /dev/null +++ b/src/libutil/windows/file-system-at.cc @@ -0,0 +1,310 @@ +#include "nix/util/file-system-at.hh" +#include "nix/util/file-system.hh" +#include "nix/util/signals.hh" +#include "nix/util/file-path.hh" +#include "nix/util/source-accessor.hh" + +#include +#include +#define WIN32_LEAN_AND_MEAN +#include +#include +#include + +namespace nix { + +using namespace nix::windows; + +namespace windows { + +namespace { + +/** + * Open a file/directory relative to a directory handle using NtCreateFile. + * + * @param dirFd Directory handle to open relative to + * @param pathComponent Single path component (not a full path) + * @param desiredAccess Access rights requested + * @param createOptions NT create options flags + * @param createDisposition FILE_OPEN, FILE_CREATE, etc. + * @return Handle to the opened file/directory (caller must close) + */ +HANDLE ntOpenAt( + Descriptor dirFd, + std::wstring_view pathComponent, + ACCESS_MASK desiredAccess, + ULONG createOptions, + ULONG createDisposition = FILE_OPEN) +{ + /* Set up UNICODE_STRING for the relative path */ + UNICODE_STRING pathStr; + pathStr.Buffer = const_cast(pathComponent.data()); + pathStr.Length = static_cast(pathComponent.size() * sizeof(wchar_t)); + pathStr.MaximumLength = pathStr.Length; + + /* Set up OBJECT_ATTRIBUTES to open relative to dirFd */ + OBJECT_ATTRIBUTES objAttrs; + InitializeObjectAttributes( + &objAttrs, + &pathStr, + 0, // No special flags + dirFd, // RootDirectory + nullptr // No security descriptor + ); + + /* Open using NT API */ + IO_STATUS_BLOCK ioStatus; + HANDLE h; + NTSTATUS status = NtCreateFile( + &h, + desiredAccess, + &objAttrs, + &ioStatus, + nullptr, // No allocation size + FILE_ATTRIBUTE_NORMAL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + createDisposition, + createOptions | FILE_SYNCHRONOUS_IO_NONALERT, + nullptr, // No EA buffer + 0 // No EA length + ); + + if (status != 0) + throw WinError( + RtlNtStatusToDosError(status), "opening %s relative to directory handle", PathFmt(pathComponent)); + + return h; +} + +/** + * Open a symlink relative to a directory handle without following it. + * + * @param dirFd Directory handle to open relative to + * @param path Relative path to the symlink + * @return Handle to the symlink (caller must close) + */ +HANDLE openSymlinkAt(Descriptor dirFd, const CanonPath & path) +{ + assert(!path.isRoot()); + assert(!path.rel().starts_with('/')); /* Just in case the invariant is somehow broken. */ + + std::wstring wpath = string_to_os_string(path.rel()); + return ntOpenAt(dirFd, wpath, FILE_READ_ATTRIBUTES | SYNCHRONIZE, FILE_OPEN_REPARSE_POINT); +} + +/** + * This struct isn't defined in the normal Windows SDK, but only in the Windows Driver Kit. + * + * I (@Ericson2314) would not normally do something like this, but LLVM + * has decided that this is in fact stable, per + * https://github.com/llvm/llvm-project/blob/main/libcxx/src/filesystem/posix_compat.h, + * so I guess that is good enough for us. GCC doesn't support symlinks + * at all on windows so we have to put it here, not grab it from private + * c++ standard library headers anyways. + */ +struct ReparseDataBuffer +{ + unsigned long ReparseTag; + unsigned short ReparseDataLength; + unsigned short Reserved; + + union + { + struct + { + unsigned short SubstituteNameOffset; + unsigned short SubstituteNameLength; + unsigned short PrintNameOffset; + unsigned short PrintNameLength; + unsigned long Flags; + wchar_t PathBuffer[1]; + } SymbolicLinkReparseBuffer; + + struct + { + unsigned short SubstituteNameOffset; + unsigned short SubstituteNameLength; + unsigned short PrintNameOffset; + unsigned short PrintNameLength; + wchar_t PathBuffer[1]; + } MountPointReparseBuffer; + + struct + { + unsigned char DataBuffer[1]; + } GenericReparseBuffer; + }; +}; + +/** + * Read the target of a symlink from an open handle. + * + * @param linkHandle Handle to a symlink (must have been opened with FILE_OPEN_REPARSE_POINT) + * @return The symlink target as a wide string + */ +OsString readSymlinkTarget(HANDLE linkHandle) +{ + uint8_t buf[MAXIMUM_REPARSE_DATA_BUFFER_SIZE]; + DWORD out; + + checkInterrupt(); + + if (!DeviceIoControl(linkHandle, FSCTL_GET_REPARSE_POINT, nullptr, 0, buf, sizeof(buf), &out, nullptr)) + throw WinError("reading reparse point for handle %d", linkHandle); + + const auto * reparse = reinterpret_cast(buf); + size_t path_buf_offset = offsetof(ReparseDataBuffer, SymbolicLinkReparseBuffer.PathBuffer[0]); + + if (out < path_buf_offset) { + auto fullPath = handleToPath(linkHandle); + throw WinError( + DWORD{ERROR_REPARSE_TAG_INVALID}, "invalid reparse data for %d:%s", linkHandle, PathFmt(fullPath)); + } + + if (reparse->ReparseTag != IO_REPARSE_TAG_SYMLINK) { + auto fullPath = handleToPath(linkHandle); + throw WinError(DWORD{ERROR_REPARSE_TAG_INVALID}, "not a symlink: %d:%s", linkHandle, PathFmt(fullPath)); + } + + const auto & symlink = reparse->SymbolicLinkReparseBuffer; + unsigned short name_offset, name_length; + + /* Prefer PrintName over SubstituteName if available */ + if (symlink.PrintNameLength == 0) { + name_offset = symlink.SubstituteNameOffset; + name_length = symlink.SubstituteNameLength; + } else { + name_offset = symlink.PrintNameOffset; + name_length = symlink.PrintNameLength; + } + + if (path_buf_offset + name_offset + name_length > out) { + auto fullPath = handleToPath(linkHandle); + throw WinError( + DWORD{ERROR_REPARSE_TAG_INVALID}, "invalid symlink data for %d:%s", linkHandle, PathFmt(fullPath)); + } + + /* Extract the target path */ + const wchar_t * target_start = &symlink.PathBuffer[name_offset / sizeof(wchar_t)]; + size_t target_len = name_length / sizeof(wchar_t); + + return {target_start, target_len}; +} + +/** + * Check if a handle refers to a reparse point (e.g., symlink). + * + * @param handle Open file/directory handle + * @return true if the handle refers to a reparse point + */ +bool isReparsePoint(HANDLE handle) +{ + FILE_BASIC_INFO basicInfo; + if (!GetFileInformationByHandleEx(handle, FileBasicInfo, &basicInfo, sizeof(basicInfo))) + throw WinError("GetFileInformationByHandleEx"); + + return (basicInfo.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0; +} + +} // anonymous namespace + +} // namespace windows + +Descriptor openFileEnsureBeneathNoSymlinks( + Descriptor dirFd, const CanonPath & path, ACCESS_MASK desiredAccess, ULONG createOptions, ULONG createDisposition) +{ + assert(!path.isRoot()); + assert(!path.rel().starts_with('/')); /* Just in case the invariant is somehow broken. */ + + AutoCloseFD parentFd; + auto nrComponents = std::ranges::distance(path); + assert(nrComponents >= 1); + auto components = std::views::take(path, nrComponents - 1); /* Everything but last component */ + auto getParentFd = [&]() { return parentFd ? parentFd.get() : dirFd; }; + + /* Helper to construct CanonPath from components up to (and including) the given iterator */ + auto pathUpTo = [&](auto it) { + return std::ranges::fold_left(components.begin(), it, CanonPath::root, [](auto lhs, auto rhs) { + lhs.push(rhs); + return lhs; + }); + }; + + /* Helper to check if a component is a symlink and throw SymlinkNotAllowed if so */ + auto throwIfSymlink = [&](std::wstring_view component, const CanonPath & pathForError) { + try { + auto testFd = + ntOpenAt(getParentFd(), component, FILE_READ_ATTRIBUTES | SYNCHRONIZE, FILE_OPEN_REPARSE_POINT); + AutoCloseFD testHandle(testFd); + if (isReparsePoint(testHandle.get())) + throw SymlinkNotAllowed(pathForError); + } catch (SymlinkNotAllowed &) { + throw; + } catch (...) { + /* If we can't determine, ignore and let caller handle original error */ + } + }; + + /* Iterate through each path component to ensure no symlinks in intermediate directories. + * This prevents TOCTOU issues by opening each component relative to the parent. */ + for (auto it = components.begin(); it != components.end(); ++it) { + std::wstring wcomponent = string_to_os_string(std::string(*it)); + + /* Open directory without following symlinks */ + AutoCloseFD parentFd2; + try { + parentFd2 = ntOpenAt( + getParentFd(), + wcomponent, + FILE_TRAVERSE | SYNCHRONIZE, // Just need traversal rights + FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT // Open directory, don't follow symlinks + ); + } catch (WinError & e) { + /* Check if this is because it's a symlink */ + if (e.lastError == ERROR_CANT_ACCESS_FILE || e.lastError == ERROR_ACCESS_DENIED) { + throwIfSymlink(wcomponent, pathUpTo(std::next(it))); + } + throw; + } + + /* Check if what we opened is actually a symlink */ + if (isReparsePoint(parentFd2.get())) { + throw SymlinkNotAllowed(pathUpTo(std::next(it))); + } + + parentFd = std::move(parentFd2); + } + + /* Now open the final component with requested flags */ + std::wstring finalComponent = string_to_os_string(std::string(path.baseName().value())); + + HANDLE finalHandle; + try { + finalHandle = ntOpenAt( + getParentFd(), + finalComponent, + desiredAccess, + createOptions | FILE_OPEN_REPARSE_POINT, // Don't follow symlinks on final component either + createDisposition); + } catch (WinError & e) { + /* Check if final component is a symlink when we requested to not follow it */ + if (e.lastError == ERROR_CANT_ACCESS_FILE) { + throwIfSymlink(finalComponent, path); + } + throw; + } + + /* Final check: did we accidentally open a symlink? */ + if (isReparsePoint(finalHandle)) + throw SymlinkNotAllowed(path); + + return finalHandle; +} + +OsString readLinkAt(Descriptor dirFd, const CanonPath & path) +{ + AutoCloseFD linkHandle(windows::openSymlinkAt(dirFd, path)); + return windows::readSymlinkTarget(linkHandle.get()); +} + +} // namespace nix diff --git a/src/libutil/windows/meson.build b/src/libutil/windows/meson.build index b7e80e25651..c0283b7db14 100644 --- a/src/libutil/windows/meson.build +++ b/src/libutil/windows/meson.build @@ -3,6 +3,7 @@ sources += files( 'environment-variables.cc', 'file-descriptor.cc', 'file-path.cc', + 'file-system-at.cc', 'file-system.cc', 'known-folders.cc', 'muxable-pipe.cc',