fix!: file type flake inputs are always recursive hashed

Well that is a mess. I don't like breaking derivation compatibility,
but I like non-deterministic evaluation much less.

We can break compat if we own up to it, which this does.

Fixes: https://git.lix.systems/lix-project/lix/issues/750

Change-Id: Ic9e2407393f1d42c2be604f80b4aa11bc872bc23
This commit is contained in:
Jade Lovelace
2025-03-21 13:19:15 -07:00
parent ed7c89790e
commit b22bee91f5
4 changed files with 101 additions and 16 deletions
@@ -0,0 +1,17 @@
---
synopsis: "Flake inputs/`builtins.fetchTree` invocations with `type = \"file\"` now have consistent (but different from previous versions) resulting paths"
issues: [fj#750]
cls: [2864]
category: "Breaking Changes"
credits: [jade]
---
Previously `fetchTree { type = "file"; url = "...", narHash = "sha256-..."; }` could return a different result depending on whether someone has run `nix store add-path --name source ...` on a path with the same `narHash` as the flake input/`fetchTree` invocation (or if such a path exists in an accessible binary cache).
In the past `type = "file"` flake inputs were, in contrast to all other flake inputs, hashed in *flat* hash mode rather than *recursive* hash mode.
The difference between the two is that *flat* mode hashes are just what you get from `sha256sum` of a single file, whereas *recursive* hashes are the SHA256 sum of a NAR (Nix ARchive, a deterministic tarball-like format) of a file tree.
Much of flakes assumes that everything is recursive-hashed including `nix flake archive`, substitution of flake inputs from binary caches, and more, which led to the substitution path code being taken if such a path is present, yielding a different store path non-deterministically.
To fix this non-deterministic evaluation bug, we needed to break derivation hash stability, so some Nix evaluations now produce different results than previous versions of Lix.
Lix now has consistent behaviour with CppNix 2.24 with respect to `file` flake inputs: they are *always* recursively hashed.
+3 -1
View File
@@ -1,6 +1,7 @@
#pragma once
///@file
#include "lix/libstore/content-address.hh"
#include "lix/libutil/result.hh"
#include "lix/libutil/types.hh"
#include "lix/libutil/hash.hh"
@@ -214,7 +215,8 @@ kj::Promise<Result<DownloadFileResult>> downloadFile(
const std::string & url,
const std::string & name,
bool locked,
Headers headers = {});
Headers headers = {},
FileIngestionMethod ingestionMethod = FileIngestionMethod::Flat);
struct DownloadTarballResult
{
+47 -7
View File
@@ -1,5 +1,6 @@
#include "lix/libfetchers/fetchers.hh"
#include "lix/libfetchers/cache.hh"
#include "lix/libstore/content-address.hh"
#include "lix/libstore/filetransfer.hh"
#include "lix/libstore/globals.hh"
#include "lix/libfetchers/builtin-fetchers.hh"
@@ -14,12 +15,46 @@
namespace nix::fetchers {
/* Note [Recursive hashing of file inputs]:
* We recursively hash `file` inputs to be consistent with the way that we look
* up the paths in binary caches (which is assumed all over the place in
* flakes, like in `nix flake archive`).
*
* It would also be easier to always hash downloadFile outputs as recursive,
* but there are a *whole bunch* of random usages of downloadFile for stuff
* from channel tarballs to GitHub API that I haven't fully figured out what
* they are doing or what the implications are for changing them. It seems that
* for things that are not directly *themselves* flake inputs, the intent is
* that they are flat-hashed, but flake inputs are always assumed
* recursive-hashed.
*
* So we make the flake usage of it do the thing that's right for flakes, and
* everything else we leave as before.
*
* Quoth Dr. Eelco Dolstra: https://github.com/NixOS/nix/pull/6548#discussion_r877921756
* > A problem with the use of downloadFile() is that it uses
* > FileIngestionMethod::Flat instead of FileIngestionMethod::Recursive.
* > Currently it's assumed that all flake inputs use recursive+sha256 with a
* > name of "source". This allows inputs to be substituted using the narHash
* > attribute in the lock file (see Input::computeStorePath()). However, the
* > lazy trees branch will probably remove the ability to substitute inputs
* > anyway...
*
* In other words, the feature was supposed to have been implemented consistent
* with other flake input types as recursive-hashed, but it was forgotten
* before merging it into CppNix, and was later fixed by some version between
* 2.19 and 2.24, which we are now consistent with.
*
* See: https://github.com/edolstra/flake-compat/pull/44
*/
kj::Promise<Result<DownloadFileResult>> downloadFile(
ref<Store> store,
const std::string & url,
const std::string & name,
bool locked,
Headers headers)
Headers headers,
FileIngestionMethod ingestionMethod)
try {
// FIXME: check store
@@ -27,6 +62,7 @@ try {
{"type", "file"},
{"url", url},
{"name", name},
{"ingestionMethod", uint64_t(ingestionMethod)},
});
auto cached = TRY_AWAIT(getCache()->lookupExpired(store, inAttrs));
@@ -60,7 +96,8 @@ try {
throw;
}
// FIXME: write to temporary file.
// FIXME: write to temporary file. or stream it. or like. anything but load
// the entire thing in memory.
Attrs infoAttrs({
{"etag", res.etag},
{"url", res.effectiveUri},
@@ -77,16 +114,18 @@ try {
} else {
StringSink sink;
sink << dumpString(data);
auto hash = hashString(HashType::SHA256, data);
auto flatHash = [&]() { return hashString(HashType::SHA256, data); };
// See Note [Recursive hashing of file inputs].
auto narHash = hashString(HashType::SHA256, sink.s);
ValidPathInfo info {
*store,
name,
FixedOutputInfo {
.method = FileIngestionMethod::Flat,
.hash = hash,
.method = ingestionMethod,
.hash = ingestionMethod == FileIngestionMethod::Flat ? flatHash() : narHash,
.references = {},
},
hashString(HashType::SHA256, sink.s),
narHash,
};
info.narSize = sink.s.size();
auto source = AsyncStringInputStream { sink.s };
@@ -277,7 +316,8 @@ struct FileInputScheme : CurlInputScheme
fetch(ref<Store> store, const Input & input) override
try {
auto file =
TRY_AWAIT(downloadFile(store, getStrAttr(input.attrs, "url"), input.getName(), false));
// See Note [Recursive hashing of file inputs].
TRY_AWAIT(downloadFile(store, getStrAttr(input.attrs, "url"), input.getName(), false, {}, FileIngestionMethod::Recursive));
co_return {std::move(file.storePath), input};
} catch (...) {
co_return result::current_exception();
+34 -8
View File
@@ -4,17 +4,27 @@ clearStore
cd "$TEST_ROOT"
echo foo > test_input
test_input_hash="$(nix hash path test_input)"
test_fetch_file () {
echo foo > test_input
input_hash="$(nix hash path test_input)"
nix eval --impure --file - <<EOF
nix eval --raw --impure --file - <<EOF
let
tree = builtins.fetchTree { type = "file"; url = "file://$PWD/test_input"; };
in
assert (tree.narHash == "$input_hash");
tree
assert (tree.narHash == "$test_input_hash");
tree.outPath
EOF
}
test_substitution () {
# fetch a URL that will never work, make sure it hits the existing one in
# the store if it exists
nix eval --raw --impure --file - <<EOF
let
tree = builtins.fetchTree { type = "file"; url = "file:///dev/null"; narHash = "$test_input_hash"; };
in
tree.outPath
EOF
}
@@ -113,5 +123,21 @@ EOF
EOF
}
test_fetch_file
test_file_flake_input
fetch_file_path=$(test_fetch_file)
fetch_substitute_path=$(test_substitution)
# Substituting with a narHash should give you the same path as fetching it
# directly, which should give you the same path as throwing it in the store
# with recursive ingestion mode.
# Regression test for https://git.lix.systems/lix-project/lix/issues/750
[[ "$fetch_file_path" == "$fetch_substitute_path" ]]
# n.b. these are done after the first one which directly fetches the file in
# order to make sure that substitution is *not* at play in test_fetch_file
flat_hash_path=$(nix store add-file --name source ./test_input)
recursive_hash_path=$(nix store add-path --name source ./test_input)
# Fetching the file should give you a recursive-hashed path
[[ "$flat_hash_path" != "$recursive_hash_path" ]]
[[ "$fetch_file_path" == "$recursive_hash_path" ]]