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 <raito@lix.systems>
This commit is contained in:
Raito Bezarius
2025-06-23 20:49:17 +00:00
committed by eldritch horrors
parent 4f0c59b307
commit 60d50ea31b
5 changed files with 99 additions and 9 deletions
@@ -0,0 +1,15 @@
---
synopsis: "Always clean up scratch paths after derivations failed to build"
issues: []
cls: [3444]
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.
+34 -7
View File
@@ -394,9 +394,13 @@ void LocalDerivationGoal::cleanupPostOutputsRegisteredModeCheck()
void LocalDerivationGoal::cleanupPostOutputsRegisteredModeNonCheck() void LocalDerivationGoal::cleanupPostOutputsRegisteredModeNonCheck()
{ {
/* Delete unused redirected outputs (when doing hash rewriting). */ /* In the past, redirected outputs were manually tracked for deletion.
for (auto & i : redirectedOutputs) * Now that we have the scratch outputs cleaner which are a superset of
deletePath(worker.store.Store::toRealPath(i.second)); * 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). */ /* Delete the chroot (if we were using one). */
autoDelChroot.reset(); /* this runs the destructor */ autoDelChroot.reset(); /* this runs the destructor */
@@ -531,6 +535,10 @@ try {
to use a temporary path */ to use a temporary path */
makeFallbackPath(status.known->path); makeFallbackPath(status.known->path);
scratchOutputs.insert_or_assign(outputName, scratchPath); 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.insert_or_assign(outputName, worker.store.printStorePath(scratchPath));
/* Substitute output placeholders with the scratch output paths. /* Substitute output placeholders with the scratch output paths.
We'll use during the build. */ We'll use during the build. */
@@ -553,8 +561,6 @@ try {
std::string h2 { scratchPath.hashPart() }; std::string h2 { scratchPath.hashPart() };
inputRewrites[h1] = h2; inputRewrites[h1] = h2;
} }
redirectedOutputs.insert_or_assign(std::move(fixedFinalPath), std::move(scratchPath));
} }
/* Construct the environment passed to the builder. */ /* Construct the environment passed to the builder. */
@@ -2029,7 +2035,9 @@ try {
} }
/* Don't register anything, since we already have the /* 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; continue;
} }
@@ -2053,8 +2061,13 @@ try {
/* If it's a CA path, register it right away. This is necessary if it /* 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 isn't statically known so that we can safely unlock the path before
the next iteration */ the next iteration */
if (newInfo.ca) if (newInfo.ca) {
TRY_AWAIT(localStore.registerValidPaths({{newInfo.path, newInfo}})); 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)); infos.emplace(outputName, std::move(newInfo));
} }
@@ -2094,6 +2107,13 @@ try {
infos2.insert_or_assign(newInfo.path, newInfo); infos2.insert_or_assign(newInfo.path, newInfo);
} }
TRY_AWAIT(localStore.registerValidPaths(infos2)); 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 /* In case of a fixed-output derivation hash mismatch, throw an
@@ -2127,6 +2147,13 @@ try {
builtOutputs.emplace(outputName, thisRealisation); 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; co_return builtOutputs;
} catch (...) { } catch (...) {
co_return result::current_exception(); co_return result::current_exception();
+13 -2
View File
@@ -100,8 +100,6 @@ struct LocalDerivationGoal : public DerivationGoal
* Hash rewriting. * Hash rewriting.
*/ */
StringMap inputRewrites, outputRewrites; StringMap inputRewrites, outputRewrites;
typedef map<StorePath, StorePath> RedirectedOutputs;
RedirectedOutputs redirectedOutputs;
/** /**
* The outputs paths used during the build. * The outputs paths used during the build.
@@ -118,6 +116,19 @@ struct LocalDerivationGoal : public DerivationGoal
* self-references. * self-references.
*/ */
OutputPathMap scratchOutputs; 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<std::string, AutoDelete> scratchOutputsCleaner;
/** /**
* Path registration info from the previous round, if we're * Path registration info from the previous round, if we're
+3
View File
@@ -169,6 +169,9 @@ in
symlinkResolvconf = runNixOSTestFor "x86_64-linux" ./symlink-resolvconf.nix; 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; noNewPrivilegesInSandbox = runNixOSTestFor "x86_64-linux" ./no-new-privileges/sandbox.nix;
noNewPrivilegesOutsideSandbox = runNixOSTestFor "x86_64-linux" ./no-new-privileges/no-sandbox.nix; noNewPrivilegesOutsideSandbox = runNixOSTestFor "x86_64-linux" ./no-new-privileges/no-sandbox.nix;
+34
View File
@@ -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}')
'';
}