From 329c89b73e0924e8946941c16030435a94bbbf98 Mon Sep 17 00:00:00 2001 From: Eelco Dolstra Date: Wed, 18 Feb 2026 16:23:56 +0100 Subject: [PATCH 1/7] Add `nix provenance verify` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Example output: $ nix provenance verify /run/current-system /nix/store/bwmc8dmbf01b07sgzfa48f5y5dgj48am-nixos-system-machine-25.11.20260214.3aadb7c ✅ fetched tree 'git+file:///my-repo?ref=refs/heads/master&rev=f6ba198ac4c8010cb0879978541189f201b734f1' ✅ evaluated 'git+file:///my-repo?ref=refs/heads/master&rev=f6ba198ac4c8010cb0879978541189f201b734f1#nixosConfigurations.machine.config.system.build.toplevel' ✅ re-instantiated path '/nix/store/qi4z9bg89sxjrs5ar11627hgwjiv42m3-nixos-system-machine-25.11.20260214.3aadb7c.drv' ✅ rebuilt derivation '/nix/store/qi4z9bg89sxjrs5ar11627hgwjiv42m3-nixos-system-machine-25.11.20260214.3aadb7c.drv^out' --- .../include/nix/cmd/installable-flake.hh | 1 + src/libcmd/installable-flake.cc | 2 +- src/libflake/flake.cc | 4 +- src/libflake/include/nix/flake/flake.hh | 3 +- src/nix/provenance-verify.md | 21 ++ src/nix/provenance.cc | 271 ++++++++++++++++++ tests/functional/flakes/provenance.sh | 37 +++ 7 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 src/nix/provenance-verify.md diff --git a/src/libcmd/include/nix/cmd/installable-flake.hh b/src/libcmd/include/nix/cmd/installable-flake.hh index 99417dc90ec8..4685b87cb2b4 100644 --- a/src/libcmd/include/nix/cmd/installable-flake.hh +++ b/src/libcmd/include/nix/cmd/installable-flake.hh @@ -41,6 +41,7 @@ struct InstallableFlake : InstallableValue ExtendedOutputsSpec extendedOutputsSpec; const flake::LockFlags & lockFlags; mutable std::shared_ptr _lockedFlake; + bool useEvalCache = true; InstallableFlake( SourceExprCommand * cmd, diff --git a/src/libcmd/installable-flake.cc b/src/libcmd/installable-flake.cc index f132536356ea..73f669d62e46 100644 --- a/src/libcmd/installable-flake.cc +++ b/src/libcmd/installable-flake.cc @@ -163,7 +163,7 @@ std::pair InstallableFlake::toValue(EvalState & state) std::vector> InstallableFlake::getCursors(EvalState & state) { - auto evalCache = openEvalCache(state, getLockedFlake()); + auto evalCache = openEvalCache(state, getLockedFlake(), useEvalCache); auto root = evalCache->getRoot(); diff --git a/src/libflake/flake.cc b/src/libflake/flake.cc index 93ebef7c2db6..a6679c79566d 100644 --- a/src/libflake/flake.cc +++ b/src/libflake/flake.cc @@ -1054,9 +1054,9 @@ std::optional LockedFlake::getFingerprint(Store & store, const fetc Flake::~Flake() {} -ref openEvalCache(EvalState & state, ref lockedFlake) +ref openEvalCache(EvalState & state, ref lockedFlake, bool allowEvalCache) { - auto fingerprint = state.settings.useEvalCache && state.settings.pureEval + auto fingerprint = allowEvalCache && state.settings.useEvalCache && state.settings.pureEval ? lockedFlake->getFingerprint(*state.store, state.fetchSettings) : std::nullopt; auto rootLoader = [&state, lockedFlake]() { diff --git a/src/libflake/include/nix/flake/flake.hh b/src/libflake/include/nix/flake/flake.hh index c62bc1531234..84da5fc41e8f 100644 --- a/src/libflake/include/nix/flake/flake.hh +++ b/src/libflake/include/nix/flake/flake.hh @@ -247,7 +247,8 @@ void callFlake(EvalState & state, const LockedFlake & lockedFlake, Value & v); /** * Open an evaluation cache for a flake. */ -ref openEvalCache(EvalState & state, ref lockedFlake); +ref +openEvalCache(EvalState & state, ref lockedFlake, bool allowEvalCache = true); } // namespace flake diff --git a/src/nix/provenance-verify.md b/src/nix/provenance-verify.md new file mode 100644 index 000000000000..e05cb95bf249 --- /dev/null +++ b/src/nix/provenance-verify.md @@ -0,0 +1,21 @@ +R""( + +# Examples + +* Verify the provenance of a store path: + + ```console + # nix provenance verify /run/current-system + ``` + +# Description + +Verify the provenance of one or more store paths. This checks whether the store paths can be rebuilt from source. Specifically, it verifies the following: + +* That source trees can be fetched. +* That flake evaluations result in the instantiation of the desired store paths (most commonly, store derivations). +* That derivations can be successfully rebuilt, producing identical outputs. + +A non-zero exit code is returned if any of the verifications fail. + +)"" diff --git a/src/nix/provenance.cc b/src/nix/provenance.cc index 65e7436ff292..1d74d49fc4f7 100644 --- a/src/nix/provenance.cc +++ b/src/nix/provenance.cc @@ -1,14 +1,21 @@ #include "nix/cmd/command.hh" #include "nix/store/store-api.hh" +#include "nix/store/store-open.hh" #include "nix/expr/provenance.hh" #include "nix/store/provenance.hh" #include "nix/flake/provenance.hh" #include "nix/fetchers/provenance.hh" #include "nix/util/provenance.hh" #include "nix/util/json-utils.hh" +#include "nix/fetchers/fetch-to-store.hh" +#include "nix/util/exit.hh" +#include "nix/cmd/installable-flake.hh" +#include "nix/store/derivations.hh" #include #include +#include +#include using namespace nix; @@ -186,3 +193,267 @@ struct CmdProvenanceShow : StorePathsCommand }; static auto rCmdProvenanceShow = registerCommand2({"provenance", "show"}); + +struct CmdProvenanceVerify : StorePathsCommand +{ + bool noRebuild = false; + + CmdProvenanceVerify() + { + addFlag({ + .longName = "no-rebuild", + .description = "Skip rebuilding derivations to verify reproducibility.", + .handler = {&noRebuild, true}, + }); + } + + std::string description() override + { + return "verify the provenance of store paths"; + } + + std::string doc() override + { + return +#include "provenance-verify.md" + ; + } + + bool verifySourcePath(Store & store, const StorePath & expectedPath, const SourcePath & sourcePath) + { + auto computedPath = fetchToStore2(fetchSettings, store, sourcePath, FetchMode::Copy, expectedPath.name()).first; + if (computedPath != expectedPath) { + logger->cout( + "❌ " ANSI_RED "store path mismatch for source '%s': expected '%s' but got '%s'" ANSI_NORMAL, + sourcePath.to_string(), + store.printStorePath(expectedPath), + store.printStorePath(computedPath)); + return false; + } else { + logger->cout("✅ verified store path for source '%s'", sourcePath.to_string()); + return true; + } + } + + using CheckResult = std::variant< + std::pair>, + std::pair, + std::monostate>; + + std::pair + verify(Store & store, std::optional path, std::shared_ptr provenance) + { + if (auto copied = std::dynamic_pointer_cast(provenance)) { + if (!path) { + logger->cout("❌ " ANSI_RED "cannot verify copied provenance without a store path" ANSI_NORMAL); + return {false, std::monostate{}}; + } + bool success = true; + auto fromStore = openStore(copied->from); + auto localInfo = store.queryPathInfo(*path); + auto fromInfo = fromStore->queryPathInfo(*path); + if (localInfo->narHash != fromInfo->narHash) { + logger->cout( + "❌ " ANSI_RED "NAR hash mismatch in origin store '%s': should be '%s' but is '%s'" ANSI_NORMAL, + copied->from, + localInfo->narHash.to_string(HashFormat::SRI, true), + fromInfo->narHash.to_string(HashFormat::SRI, true)); + success = false; + } else + logger->cout("✅ verified NAR hash in origin store '%s'", copied->from); + auto [nextSuccess, result] = verify(store, path, copied->next); + return {success && nextSuccess, std::move(result)}; + } + + else if (auto build = std::dynamic_pointer_cast(provenance)) { + auto success = verify(store, build->drvPath, build->next).first; + + // Verify that `path` is the expected output of the derivation. + auto outputMap = store.queryPartialDerivationOutputMap(build->drvPath); + auto it = outputMap.find(build->output); + if (it == outputMap.end()) { + logger->cout( + "❌ " ANSI_RED "derivation '%s' does not have expected output '%s'" ANSI_NORMAL, + store.printStorePath(build->drvPath), + build->output); + return {false, std::monostate{}}; + } else if (!it->second) { + // Note: this is not an error, should we even print a message? + logger->cout( + "❓ output '%s' of derivation '%s' is not statically known", + build->output, + store.printStorePath(build->drvPath)); + } else if (*it->second != path) { + logger->cout( + "❌ " ANSI_RED "output '%s' of derivation '%s' is '%s', expected '%s'" ANSI_NORMAL, + build->output, + store.printStorePath(build->drvPath), + store.printStorePath(*it->second), + store.printStorePath(*path)); + return {false, std::monostate{}}; + } + + // Do a check rebuild to verify that the derivation + // produces the same output. + if (noRebuild) { + logger->cout( + "⏭️ skipped rebuild of derivation '%s^%s'", store.printStorePath(build->drvPath), build->output); + } else { + try { + store.buildPaths( + {DerivedPath::Built{ + .drvPath = make_ref(SingleDerivedPath::Opaque{build->drvPath}), + .outputs = OutputsSpec::Names{build->output}, + }}, + bmCheck); + logger->cout("✅ rebuilt derivation '%s^%s'", store.printStorePath(build->drvPath), build->output); + } catch (Error & e) { + logger->cout( + "❌ " ANSI_RED "rebuild of derivation '%s^%s' failed: %s" ANSI_NORMAL, + store.printStorePath(build->drvPath), + build->output, + e.what()); + success = false; + } + } + + return {success, std::monostate{}}; + } + + else if (auto flake = std::dynamic_pointer_cast(provenance)) { + // Fetch the flake source. + auto [success, _res] = verify(store, {}, flake->next); + + auto res = std::get_if>(&_res); + if (!res) + return {false, std::monostate{}}; + + // Evaluate the flake output. + flake::LockFlags lockFlags{ + .updateLockFile = false, + .failOnUnlocked = true, + .useRegistries = false, + .allowUnlocked = false, + }; + + if (res->second.path.baseName() != "flake.nix") { + logger->cout( + "❌ " ANSI_RED "expected flake source to be a 'flake.nix' file, but got '%s'" ANSI_NORMAL, + res->second.path.abs()); + return {false, std::monostate{}}; + } + + InstallableFlake installable{ + nullptr, + getEvalState(), + FlakeRef{std::move(res->first), std::string(res->second.path.parent().value().rel())}, + "." + flake->flakeOutput, + ExtendedOutputsSpec::Default{}, // FIXME: record this in the provenance? + {}, + {}, + lockFlags}; + + // We have to disable the eval cache to ensure that we see which store paths get instantiated. + installable.useEvalCache = false; + + // Hacky: we're abusing drvHashes to see what derivations got instantiated, so we have to clear it before + // evaluation. + drvHashes.clear(); + + // Similarly, ensure that fileEvalCache is nuked. + getEvalState()->resetFileCache(); + + installable.toDerivedPaths(); + + logger->cout("✅ evaluated '%s#%s'", installable.flakeRef.to_string(true), flake->flakeOutput); + + if (path) { + boost::unordered_flat_set instantiatedPaths; + drvHashes.cvisit_all([&](const auto & kv) { instantiatedPaths.insert(kv.first); }); + // FIXME: add non-derivation paths + + if (!instantiatedPaths.contains(*path)) { + logger->cout( + "❌ " ANSI_RED "evaluation did not re-instantiate path '%s'" ANSI_NORMAL, + store.printStorePath(*path)); + return {false, std::monostate{}}; + } + + logger->cout("✅ re-instantiated path '%s'", store.printStorePath(*path)); + } + + return {success, std::monostate{}}; + } + + else if (auto tree = std::dynamic_pointer_cast(provenance)) { + auto input = fetchers::Input::fromAttrs(fetchSettings, fetchers::jsonToAttrs(*tree->attrs)); + try { + auto [accessor, final] = input.getAccessor(fetchSettings, store); + if (!input.isLocked(fetchSettings)) + logger->cout("❓ fetched tree '%s', but it's unlocked", input.to_string()); + else + // FIXME: check NAR hash? + logger->cout("✅ fetched tree '%s'", input.to_string()); + + bool success = !path || verifySourcePath(store, *path, SourcePath(accessor, CanonPath::root)); + + return {success, std::make_pair(std::move(final), accessor)}; + } catch (Error & e) { + logger->cout("❌ " ANSI_RED "failed to fetch tree '%s': %s" ANSI_NORMAL, input.to_string(), e.what()); + return {false, std::monostate{}}; + } + } + + else if (auto subpath = std::dynamic_pointer_cast(provenance)) { + auto [success, result] = verify(store, {}, subpath->next); + if (auto p = std::get_if>>(&result)) { + + auto sourcePath = SourcePath(p->second, subpath->subpath); + + bool success = !path || verifySourcePath(store, *path, sourcePath); + + return {success, std::make_pair(std::move(p->first), std::move(sourcePath))}; + } else + return {false, std::monostate{}}; + } + + if (auto drv = std::dynamic_pointer_cast(provenance)) + return verify(store, path, drv->next); + + else if (!provenance) { + logger->cout("❓ " ANSI_RED "missing further provenance" ANSI_NORMAL); + return {false, std::monostate{}}; + } + + else { + logger->cout("❓ " ANSI_RED "unknown provenance type" ANSI_NORMAL); + return {false, std::monostate{}}; + } + } + + void run(ref store, StorePaths && storePaths) override + { + bool first = true; + bool success = true; + + for (auto & storePath : storePaths) { + auto info = store->queryPathInfo(storePath); + if (!first) + logger->cout(""); + first = false; + logger->cout(ANSI_BOLD "%s" ANSI_NORMAL, store->printStorePath(info->path)); + + if (info->provenance) + success &= verify(*store, storePath, info->provenance).first; + else { + logger->cout(ANSI_RED " (no provenance information available)" ANSI_NORMAL); + success = false; + } + } + + if (!success) + throw Exit(1); + } +}; + +static auto rCmdProvenanceVerify = registerCommand2({"provenance", "verify"}); diff --git a/tests/functional/flakes/provenance.sh b/tests/functional/flakes/provenance.sh index 7adefd96965a..eb933ee50731 100644 --- a/tests/functional/flakes/provenance.sh +++ b/tests/functional/flakes/provenance.sh @@ -106,6 +106,14 @@ EOF EOF ) ]] +# Verify the provenance of all store paths. +nix provenance verify --all + +# Verification should fail if the sources cannot be fetched +mv "$flake1Dir" "$flake1Dir-tmp" +expectStderr 1 nix provenance verify --all | grepQuiet "Git repository.*does not exist" +mv "$flake1Dir-tmp" "$flake1Dir" + # Check that substituting from a binary cache adds "copied" provenance. binaryCache="$TEST_ROOT/binary-cache" nix copy --to "file://$binaryCache" "$outPath" @@ -185,6 +193,8 @@ unset _NIX_FORCE_HTTP EOF ) ]] +nix provenance verify --all + # Check that --impure does not add additional provenance. clearStore nix build --impure --print-out-paths --no-link "$flake1Dir#packages.$system.default" @@ -250,3 +260,30 @@ EOF ← from unlocked tree git+file://$flake1Dir EOF ) ]] + +nix provenance verify --all + +# Test that impure builds fail verification. +clearStore +echo x > "$TEST_ROOT/counter" +cat > "$flake1Dir/flake.nix" < \$out + echo x >> "$TEST_ROOT/counter" + ''; + }; + }; + }; +} +EOF +outPath=$(nix build --print-out-paths --no-link "$flake1Dir") + +expectStderr 1 nix provenance verify --all | grepQuiet "derivation .* may not be deterministic: output .* differs" From 31e8299a34c5e250a2a001fef8aefb34c079288e Mon Sep 17 00:00:00 2001 From: Eelco Dolstra Date: Thu, 19 Feb 2026 12:49:33 +0100 Subject: [PATCH 2/7] nix provenance verify: Use a wrapper store to track instantiated files --- src/nix/provenance.cc | 128 +++++++++++++++++++++++--- tests/functional/flakes/provenance.sh | 24 +++++ 2 files changed, 139 insertions(+), 13 deletions(-) diff --git a/src/nix/provenance.cc b/src/nix/provenance.cc index 1d74d49fc4f7..16fd69255b38 100644 --- a/src/nix/provenance.cc +++ b/src/nix/provenance.cc @@ -13,6 +13,7 @@ #include "nix/store/derivations.hh" #include +#include "nix/util/callback.hh" #include #include #include @@ -194,6 +195,105 @@ struct CmdProvenanceShow : StorePathsCommand static auto rCmdProvenanceShow = registerCommand2({"provenance", "show"}); +/** + * A wrapper around an arbitrary store that intercepts `addToStore()` + * and `addToStoreFromDump()` calls to keep track of added paths. + */ +struct TrackingStore : public Store +{ + ref next; + boost::unordered_flat_set instantiatedPaths; + + TrackingStore(ref next) + : Store(next->config) + , next(next) + { + } + + void addToStore(const ValidPathInfo & info, Source & narSource, RepairFlag repair, CheckSigsFlag checkSigs) override + { + next->addToStore(info, narSource, repair, checkSigs); + instantiatedPaths.insert(info.path); + // FIXME: we should really just disable the path info cache, since the underlying store already does caching. + invalidatePathInfoCacheFor(info.path); + } + + StorePath addToStore( + std::string_view name, + const SourcePath & path, + ContentAddressMethod method, + HashAlgorithm hashAlgo, + const StorePathSet & references, + PathFilter & filter, + RepairFlag repair) override + { + auto storePath = next->addToStore(name, path, method, hashAlgo, references, filter, repair); + instantiatedPaths.insert(storePath); + invalidatePathInfoCacheFor(storePath); + return storePath; + } + + StorePath addToStoreFromDump( + Source & dump, + std::string_view name, + FileSerialisationMethod dumpMethod, + ContentAddressMethod hashMethod, + HashAlgorithm hashAlgo, + const StorePathSet & references, + RepairFlag repair, + std::shared_ptr provenance) override + { + auto storePath = + next->addToStoreFromDump(dump, name, dumpMethod, hashMethod, hashAlgo, references, repair, provenance); + instantiatedPaths.insert(storePath); + invalidatePathInfoCacheFor(storePath); + return storePath; + } + + void queryPathInfoUncached( + const StorePath & path, Callback> callback) noexcept override + { + try { + callback(std::make_shared(*next->queryPathInfo(path))); + } catch (InvalidPath &) { + callback(nullptr); + } catch (...) { + callback.rethrow(); + } + } + + void queryRealisationUncached( + const DrvOutput & output, Callback> callback) noexcept override + { + next->queryRealisation(output, std::move(callback)); + } + + std::optional queryPathFromHashPart(const std::string & hashPart) override + { + return next->queryPathFromHashPart(hashPart); + } + + void registerDrvOutput(const Realisation & output) override + { + next->registerDrvOutput(output); + } + + ref getFSAccessor(bool requireValidPath) override + { + return next->getFSAccessor(requireValidPath); + } + + std::shared_ptr getFSAccessor(const StorePath & path, bool requireValidPath) override + { + return next->getFSAccessor(path, requireValidPath); + } + + std::optional isTrustedClient() override + { + return next->isTrustedClient(); + } +}; + struct CmdProvenanceVerify : StorePathsCommand { bool noRebuild = false; @@ -343,9 +443,20 @@ struct CmdProvenanceVerify : StorePathsCommand return {false, std::monostate{}}; } + auto trackingStore = make_ref(getEvalStore()); + + auto evalState = + ref(std::allocate_shared( + traceable_allocator(), + LookupPath{}, + ref(trackingStore), + fetchSettings, + evalSettings, + getStore())); + InstallableFlake installable{ nullptr, - getEvalState(), + evalState, FlakeRef{std::move(res->first), std::string(res->second.path.parent().value().rel())}, "." + flake->flakeOutput, ExtendedOutputsSpec::Default{}, // FIXME: record this in the provenance? @@ -356,23 +467,14 @@ struct CmdProvenanceVerify : StorePathsCommand // We have to disable the eval cache to ensure that we see which store paths get instantiated. installable.useEvalCache = false; - // Hacky: we're abusing drvHashes to see what derivations got instantiated, so we have to clear it before - // evaluation. - drvHashes.clear(); - - // Similarly, ensure that fileEvalCache is nuked. - getEvalState()->resetFileCache(); - installable.toDerivedPaths(); + evalState->waitForAllPaths(); + logger->cout("✅ evaluated '%s#%s'", installable.flakeRef.to_string(true), flake->flakeOutput); if (path) { - boost::unordered_flat_set instantiatedPaths; - drvHashes.cvisit_all([&](const auto & kv) { instantiatedPaths.insert(kv.first); }); - // FIXME: add non-derivation paths - - if (!instantiatedPaths.contains(*path)) { + if (!trackingStore->instantiatedPaths.contains(*path)) { logger->cout( "❌ " ANSI_RED "evaluation did not re-instantiate path '%s'" ANSI_NORMAL, store.printStorePath(*path)); diff --git a/tests/functional/flakes/provenance.sh b/tests/functional/flakes/provenance.sh index eb933ee50731..bee72408500f 100644 --- a/tests/functional/flakes/provenance.sh +++ b/tests/functional/flakes/provenance.sh @@ -287,3 +287,27 @@ EOF outPath=$(nix build --print-out-paths --no-link "$flake1Dir") expectStderr 1 nix provenance verify --all | grepQuiet "derivation .* may not be deterministic: output .* differs" + +# Test various types of source files. +clearStore +echo x > "$TEST_ROOT/counter" +cat > "$flake1Dir/flake.nix" < Date: Thu, 19 Feb 2026 16:27:56 +0100 Subject: [PATCH 3/7] Expose FetchurlProvenance --- .../include/nix/fetchers/provenance.hh | 12 +++++++++++ src/libfetchers/provenance.cc | 13 ++++++++++++ src/libfetchers/tarball.cc | 20 +------------------ 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/libfetchers/include/nix/fetchers/provenance.hh b/src/libfetchers/include/nix/fetchers/provenance.hh index 3fed314cedcb..6a1aab073079 100644 --- a/src/libfetchers/include/nix/fetchers/provenance.hh +++ b/src/libfetchers/include/nix/fetchers/provenance.hh @@ -19,4 +19,16 @@ struct TreeProvenance : Provenance nlohmann::json to_json() const override; }; +struct FetchurlProvenance : Provenance +{ + std::string url; + + FetchurlProvenance(const std::string & url) + : url(url) + { + } + + nlohmann::json to_json() const override; +}; + } // namespace nix diff --git a/src/libfetchers/provenance.cc b/src/libfetchers/provenance.cc index 5ea6c6213bc3..304ff769f79c 100644 --- a/src/libfetchers/provenance.cc +++ b/src/libfetchers/provenance.cc @@ -31,4 +31,17 @@ Provenance::Register registerTreeProvenance("tree", [](nlohmann::json json) { return make_ref(make_ref(attrsJson)); }); +nlohmann::json FetchurlProvenance::to_json() const +{ + return nlohmann::json{ + {"type", "fetchurl"}, + {"url", url}, + }; +} + +Provenance::Register registerFetchurlProvenance("fetchurl", [](nlohmann::json json) { + auto & obj = getObject(json); + return make_ref(getString(valueAt(obj, "url"))); +}); + } // namespace nix diff --git a/src/libfetchers/tarball.cc b/src/libfetchers/tarball.cc index 9ad80bb06d09..8fa4bc181d33 100644 --- a/src/libfetchers/tarball.cc +++ b/src/libfetchers/tarball.cc @@ -9,30 +9,12 @@ #include "nix/store/store-api.hh" #include "nix/fetchers/git-utils.hh" #include "nix/fetchers/fetch-settings.hh" -#include "nix/util/provenance.hh" +#include "nix/fetchers/provenance.hh" #include namespace nix::fetchers { -struct FetchurlProvenance : Provenance -{ - std::string url; - - FetchurlProvenance(const std::string & url) - : url(url) - { - } - - nlohmann::json to_json() const override - { - return nlohmann::json{ - {"type", "fetchurl"}, - {"url", url}, - }; - } -}; - DownloadFileResult downloadFile( Store & store, const Settings & settings, From 3bb8793f51dbf925c089558275950bf7c5a58770 Mon Sep 17 00:00:00 2001 From: Eelco Dolstra Date: Thu, 19 Feb 2026 18:41:51 +0100 Subject: [PATCH 4/7] Verify fetchurl provenance --- src/libutil/include/nix/util/meson.build | 1 + .../override-provenance-source-accessor.hh | 21 ++++++ src/nix/prefetch.cc | 12 ++- src/nix/provenance.cc | 74 +++++++++++++++++-- tests/functional/flakes/provenance.sh | 19 +++++ 5 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 src/libutil/include/nix/util/override-provenance-source-accessor.hh diff --git a/src/libutil/include/nix/util/meson.build b/src/libutil/include/nix/util/meson.build index 5d26a25c0348..8e0336bbdaab 100644 --- a/src/libutil/include/nix/util/meson.build +++ b/src/libutil/include/nix/util/meson.build @@ -53,6 +53,7 @@ headers = files( 'muxable-pipe.hh', 'nar-accessor.hh', 'os-string.hh', + 'override-provenance-source-accessor.hh', 'pool.hh', 'pos-idx.hh', 'pos-table.hh', diff --git a/src/libutil/include/nix/util/override-provenance-source-accessor.hh b/src/libutil/include/nix/util/override-provenance-source-accessor.hh new file mode 100644 index 000000000000..5ed937db02ad --- /dev/null +++ b/src/libutil/include/nix/util/override-provenance-source-accessor.hh @@ -0,0 +1,21 @@ +#pragma once + +#include "nix/util/forwarding-source-accessor.hh" + +namespace nix { + +struct OverrideProvenanceSourceAccessor : ForwardingSourceAccessor +{ + OverrideProvenanceSourceAccessor(ref next, std::shared_ptr provenance) + : ForwardingSourceAccessor(std::move(next)) + { + this->provenance = std::move(provenance); + } + + std::shared_ptr getProvenance(const CanonPath & path) override + { + return provenance; + } +}; + +} // namespace nix diff --git a/src/nix/prefetch.cc b/src/nix/prefetch.cc index d494b0986864..781677cb4fe5 100644 --- a/src/nix/prefetch.cc +++ b/src/nix/prefetch.cc @@ -15,6 +15,8 @@ #include "nix/util/environment-variables.hh" #include "nix/util/url.hh" #include "nix/store/path.hh" +#include "nix/util/override-provenance-source-accessor.hh" +#include "nix/fetchers/provenance.hh" #include "man-pages.hh" @@ -143,7 +145,15 @@ std::tuple prefetchFile( Activity act(*logger, lvlChatty, actUnknown, fmt("adding '%s' to the store", url.to_string())); - auto info = store->addToStoreSlow(name, makeFSSourceAccessor(tmpFile), method, hashAlgo, {}, expectedHash); + auto info = store->addToStoreSlow( + name, + {make_ref( + makeFSSourceAccessor(tmpFile), + unpack ? nullptr : std::make_shared(url.to_string()))}, + method, + hashAlgo, + {}, + expectedHash); storePath = info.path; assert(info.ca); hash = info.ca->hash; diff --git a/src/nix/provenance.cc b/src/nix/provenance.cc index 16fd69255b38..6424467f5382 100644 --- a/src/nix/provenance.cc +++ b/src/nix/provenance.cc @@ -11,6 +11,7 @@ #include "nix/util/exit.hh" #include "nix/cmd/installable-flake.hh" #include "nix/store/derivations.hh" +#include "nix/store/filetransfer.hh" #include #include "nix/util/callback.hh" @@ -65,7 +66,9 @@ struct CmdProvenanceShow : StorePathsCommand if (auto copied = std::dynamic_pointer_cast(provenance)) { logger->cout("← copied from " ANSI_BOLD "%s" ANSI_NORMAL, copied->from); provenance = copied->next; - } else if (auto build = std::dynamic_pointer_cast(provenance)) { + } + + else if (auto build = std::dynamic_pointer_cast(provenance)) { logger->cout( "← built from derivation " ANSI_BOLD "%s" ANSI_NORMAL " (output " ANSI_BOLD "%s" ANSI_NORMAL ") on " ANSI_BOLD "%s" ANSI_NORMAL " for " ANSI_BOLD "%s" ANSI_NORMAL, @@ -74,7 +77,9 @@ struct CmdProvenanceShow : StorePathsCommand build->buildHost.value_or("unknown host").c_str(), build->system); provenance = build->next; - } else if (auto flake = std::dynamic_pointer_cast(provenance)) { + } + + else if (auto flake = std::dynamic_pointer_cast(provenance)) { // Collapse subpath/tree provenance into the flake provenance for legibility. auto next = flake->next; CanonPath flakePath("/flake.nix"); @@ -97,17 +102,23 @@ struct CmdProvenanceShow : StorePathsCommand logger->cout("← instantiated from flake output " ANSI_BOLD "%s" ANSI_NORMAL, flake->flakeOutput); provenance = flake->next; } - } else if (auto tree = std::dynamic_pointer_cast(provenance)) { + } + + else if (auto tree = std::dynamic_pointer_cast(provenance)) { auto input = fetchers::Input::fromAttrs(fetchSettings, fetchers::jsonToAttrs(*tree->attrs)); logger->cout( "← from %stree " ANSI_BOLD "%s" ANSI_NORMAL, input.isLocked(fetchSettings) ? "" : ANSI_RED "unlocked" ANSI_NORMAL " ", input.to_string()); break; - } else if (auto subpath = std::dynamic_pointer_cast(provenance)) { + } + + else if (auto subpath = std::dynamic_pointer_cast(provenance)) { logger->cout("← from file " ANSI_BOLD "%s" ANSI_NORMAL, subpath->subpath.abs()); provenance = subpath->next; - } else if (auto drv = std::dynamic_pointer_cast(provenance)) { + } + + else if (auto drv = std::dynamic_pointer_cast(provenance)) { logger->cout("← with derivation metadata"); #define TAB " " auto json = getObject(*(drv->meta)); @@ -161,7 +172,14 @@ struct CmdProvenanceShow : StorePathsCommand } #undef TAB provenance = drv->next; - } else { + } + + else if (auto fetchurl = std::dynamic_pointer_cast(provenance)) { + logger->cout("← fetched from URL " ANSI_BOLD "%s" ANSI_NORMAL, fetchurl->url); + break; + } + + else { // Unknown or unhandled provenance type auto json = provenance->to_json(); auto typeIt = json.find("type"); @@ -519,9 +537,51 @@ struct CmdProvenanceVerify : StorePathsCommand return {false, std::monostate{}}; } - if (auto drv = std::dynamic_pointer_cast(provenance)) + else if (auto drv = std::dynamic_pointer_cast(provenance)) return verify(store, path, drv->next); + else if (auto fetchurl = std::dynamic_pointer_cast(provenance)) { + if (!path) + return {false, std::monostate{}}; + + auto info = store.queryPathInfo(*path); + + if (!info->ca) { + logger->cout( + "❌ " ANSI_RED "cannot verify URL '%s' without a content address for path '%s'" ANSI_NORMAL, + fetchurl->url, + store.printStorePath(*path)); + return {false, std::monostate{}}; + } + + if (info->ca->method != ContentAddressMethod::Raw::Flat) { + logger->cout( + "❌ " ANSI_RED + "cannot verify URL '%s' with unsupported content address method for path '%s'" ANSI_NORMAL, + fetchurl->url, + store.printStorePath(*path)); + return {false, std::monostate{}}; + } + + HashSink hashSink{info->ca->hash.algo}; + FileTransferRequest req(fetchurl->url); + req.decompress = false; + getFileTransfer()->download(std::move(req), hashSink); + auto hash = hashSink.finish().hash; + + if (hash != info->ca->hash) { + logger->cout( + "❌ " ANSI_RED "hash mismatch for URL '%s': expected '%s' but got '%s'" ANSI_NORMAL, + fetchurl->url, + info->ca->hash.to_string(HashFormat::SRI, true), + hash.to_string(HashFormat::SRI, true)); + return {false, std::monostate{}}; + } + + logger->cout("✅ verified hash of URL '%s'", fetchurl->url); + return {true, std::monostate{}}; + } + else if (!provenance) { logger->cout("❓ " ANSI_RED "missing further provenance" ANSI_NORMAL); return {false, std::monostate{}}; diff --git a/tests/functional/flakes/provenance.sh b/tests/functional/flakes/provenance.sh index bee72408500f..31f29a47df38 100644 --- a/tests/functional/flakes/provenance.sh +++ b/tests/functional/flakes/provenance.sh @@ -311,3 +311,22 @@ EOF outPath=$(nix build --print-out-paths --no-link "$flake1Dir") nix provenance verify --all + +# Test fetchurl provenance. +clearStore + +echo hello > "$TEST_ROOT/hello.txt" + +path="$(nix store prefetch-file --json "file://$TEST_ROOT/hello.txt" | jq -r .storePath)" + +[[ "$(nix provenance show "$path")" = $(cat < "$TEST_ROOT/hello.txt" + +expectStderr 1 nix provenance verify "$path" | grepQuiet "hash mismatch for URL" From e1bf5de0cfecdb35ccc40f1dec58d01776324300 Mon Sep 17 00:00:00 2001 From: Eelco Dolstra Date: Thu, 19 Feb 2026 18:55:52 +0100 Subject: [PATCH 5/7] Don't set source path provenance for filtered paths --- src/libexpr/primops.cc | 16 ++++++++++++++-- tests/functional/flakes/provenance.sh | 1 + 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/libexpr/primops.cc b/src/libexpr/primops.cc index 2f99adefd882..9a6c09e32bef 100644 --- a/src/libexpr/primops.cc +++ b/src/libexpr/primops.cc @@ -19,6 +19,7 @@ #include "nix/util/sort.hh" #include "nix/util/mounted-source-accessor.hh" #include "nix/expr/provenance.hh" +#include "nix/util/override-provenance-source-accessor.hh" #include #include @@ -2923,10 +2924,21 @@ static void addPath( if (!expectedHash || !state.store->isValidPath(*expectedStorePath)) { // FIXME: make this lazy? // FIXME: support refs in fetchToStore()? + auto path2 = path.resolveSymlinks(); + // Don't use source path provenance if we have a filter applied, since we can't accurately + // record that. Instead, use the current global provenance, since it's better than nothing. + auto path3 = filter + ? SourcePath{ + make_ref( + path2.accessor, state.evalContext.provenance), + path2.path + } + : path2; + auto dstPath = refs.empty() ? fetchToStore( state.fetchSettings, *state.store, - path.resolveSymlinks(), + path3, settings.readOnlyMode ? FetchMode::DryRun : FetchMode::Copy, name, method, @@ -2934,7 +2946,7 @@ static void addPath( state.repair) : state.store->addToStore( name, - path.resolveSymlinks(), + path3, method, HashAlgorithm::SHA256, refs, diff --git a/tests/functional/flakes/provenance.sh b/tests/functional/flakes/provenance.sh index 31f29a47df38..bb8160e2b4dc 100644 --- a/tests/functional/flakes/provenance.sh +++ b/tests/functional/flakes/provenance.sh @@ -303,6 +303,7 @@ cat > "$flake1Dir/flake.nix" < Date: Thu, 19 Feb 2026 21:09:36 +0100 Subject: [PATCH 6/7] Fix shadowed variable --- src/nix/provenance.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/nix/provenance.cc b/src/nix/provenance.cc index 6424467f5382..11dcc9d232fa 100644 --- a/src/nix/provenance.cc +++ b/src/nix/provenance.cc @@ -530,7 +530,8 @@ struct CmdProvenanceVerify : StorePathsCommand auto sourcePath = SourcePath(p->second, subpath->subpath); - bool success = !path || verifySourcePath(store, *path, sourcePath); + if (path && !verifySourcePath(store, *path, sourcePath)) + success = false; return {success, std::make_pair(std::move(p->first), std::move(sourcePath))}; } else From b3aa6ea3f9ed033d7efe323b1bd4171ffe19b3a0 Mon Sep 17 00:00:00 2001 From: Eelco Dolstra Date: Thu, 19 Feb 2026 21:09:51 +0100 Subject: [PATCH 7/7] ParsedURL: Add renderSanitized() method --- src/libfetchers/tarball.cc | 9 ++------- src/libutil/include/nix/util/url.hh | 5 +++++ src/libutil/url.cc | 9 +++++++++ src/nix/prefetch.cc | 2 +- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/libfetchers/tarball.cc b/src/libfetchers/tarball.cc index 8fa4bc181d33..c72677e3cd8c 100644 --- a/src/libfetchers/tarball.cc +++ b/src/libfetchers/tarball.cc @@ -86,13 +86,8 @@ DownloadFileResult downloadFile( }, hashString(HashAlgorithm::SHA256, sink.s)); info.narSize = sink.s.size(); - if (experimentalFeatureSettings.isEnabled(Xp::Provenance)) { - auto sanitizedUrl = request.uri.parsed(); - if (sanitizedUrl.authority) - sanitizedUrl.authority->password.reset(); - sanitizedUrl.query.clear(); - info.provenance = std::make_shared(sanitizedUrl.to_string()); - } + if (experimentalFeatureSettings.isEnabled(Xp::Provenance)) + info.provenance = std::make_shared(request.uri.parsed().renderSanitized()); auto source = StringSource{sink.s}; store.addToStore(info, source, NoRepair, NoCheckSigs); storePath = std::move(info.path); diff --git a/src/libutil/include/nix/util/url.hh b/src/libutil/include/nix/util/url.hh index 55c475df651c..5f9eab4e9de9 100644 --- a/src/libutil/include/nix/util/url.hh +++ b/src/libutil/include/nix/util/url.hh @@ -228,6 +228,11 @@ struct ParsedURL */ std::string renderPath(bool encode = false) const; + /** + * Like to_string(), but removes query strings and passwords. + */ + std::string renderSanitized() const; + auto operator<=>(const ParsedURL & other) const noexcept = default; /** diff --git a/src/libutil/url.cc b/src/libutil/url.cc index 0a8b64528140..d9f61078cc48 100644 --- a/src/libutil/url.cc +++ b/src/libutil/url.cc @@ -344,6 +344,15 @@ std::string ParsedURL::renderPath(bool encode) const return concatStringsSep("/", path); } +std::string ParsedURL::renderSanitized() const +{ + auto url = *this; + if (url.authority) + url.authority->password.reset(); + url.query.clear(); + return url.to_string(); +} + std::string ParsedURL::renderAuthorityAndPath() const { std::string res; diff --git a/src/nix/prefetch.cc b/src/nix/prefetch.cc index 781677cb4fe5..de5f1bd5259b 100644 --- a/src/nix/prefetch.cc +++ b/src/nix/prefetch.cc @@ -149,7 +149,7 @@ std::tuple prefetchFile( name, {make_ref( makeFSSourceAccessor(tmpFile), - unpack ? nullptr : std::make_shared(url.to_string()))}, + unpack ? nullptr : std::make_shared(url.parsed().renderSanitized()))}, method, hashAlgo, {},