From 69bc29256b1d2a11969ceba37370302d2aed9f57 Mon Sep 17 00:00:00 2001 From: Eelco Dolstra Date: Fri, 24 Jul 2026 16:44:42 +0200 Subject: [PATCH 1/6] libfetchers: Add an fh-resolve input scheme This fetcher resolves a flakeref like { type = "fh-resolve"; org = "DeterminateSystems"; project = "nix-wasm-rust"; version = "^0"; output = "packages.x86_64-linux.default"; } to a prebuilt store path by calling 'fh resolve', and substitutes that path. This provides a convenient way for flakes to depend on prebuilt binary artifacts (such as WASM plugins). The lock file records the resolved store path and its NAR hash. Since the store path is generally input-addressed, it cannot be recomputed from the NAR hash, so Input::computeStorePath() now returns a recorded 'storePath' attribute directly. This makes the generic locked fast-path (store reuse and substitution) operate on the resolved path, so locked fetches never invoke 'fh'. Because a valid input-addressed path does not imply the expected contents, the store accessor now verifies the locked NAR hash against the path's actual NAR hash. The resolved store paths are not allowed to have references, since libfetchers trees have no concept of references. Assisted-by: Claude Fable 5 --- src/libfetchers/fetchers.cc | 23 +++- src/libfetchers/fh-resolve.cc | 205 ++++++++++++++++++++++++++++++++++ src/libfetchers/meson.build | 1 + 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/libfetchers/fh-resolve.cc diff --git a/src/libfetchers/fetchers.cc b/src/libfetchers/fetchers.cc index 595f518c7cac..2344c871e8ef 100644 --- a/src/libfetchers/fetchers.cc +++ b/src/libfetchers/fetchers.cc @@ -310,6 +310,21 @@ std::pair, Input> Input::getAccessorUnchecked(const Settings storePath = computeStorePath(store); auto makeStoreAccessor = [&]() -> std::pair, Input> { + auto narHash = store.queryPathInfo(*storePath)->narHash; + + /* If the store path is input-addressed (i.e. it comes from a + `storePath` attribute rather than being computed from the + NAR hash), its validity does not imply that it has the + expected contents, so verify the NAR hash. */ + if (narHash != *getNarHash()) + throw Error( + (unsigned int) 102, + "NAR hash mismatch in input '%s' at '%s', expected '%s' but got '%s'", + to_string(), + store.printStorePath(*storePath), + getNarHash()->to_string(HashFormat::SRI, true), + narHash.to_string(HashFormat::SRI, true)); + auto accessor = store.requireStoreObjectAccessor(*storePath); // FIXME: use the NAR hash for fingerprinting Git trees since it may have a .gitattributes file and we don't @@ -324,7 +339,7 @@ std::pair, Input> Input::getAccessorUnchecked(const Settings settings.getCache()->upsert( makeSourcePathToHashCacheKey( *accessor->fingerprint, ContentAddressMethod::Raw::NixArchive, CanonPath::root), - {{"hash", store.queryPathInfo(*storePath)->narHash.to_string(HashFormat::SRI, true)}}); + {{"hash", narHash.to_string(HashFormat::SRI, true)}}); } accessor->provenance = std::make_shared(*this); @@ -427,6 +442,12 @@ std::string Input::getName() const StorePath Input::computeStorePath(Store & store) const { + /* If the input records the resolved store path (which may be + input-addressed and thus not computable from the NAR hash), use + it directly. */ + if (auto storePath = maybeGetStrAttr(attrs, "storePath")) + return store.parseStorePath(*storePath); + auto narHash = getNarHash(); if (!narHash) throw Error("cannot compute store path for unlocked input '%s'", to_string()); diff --git a/src/libfetchers/fh-resolve.cc b/src/libfetchers/fh-resolve.cc new file mode 100644 index 000000000000..657414956af7 --- /dev/null +++ b/src/libfetchers/fh-resolve.cc @@ -0,0 +1,205 @@ +#include "nix/fetchers/fetchers.hh" +#include "nix/fetchers/fetch-settings.hh" +#include "nix/store/store-api.hh" +#include "nix/util/logging.hh" +#include "nix/util/processes.hh" +#include "nix/util/strings.hh" + +#include + +namespace nix::fetchers { + +/* A fetcher that resolves a FlakeHub output reference like + `DeterminateSystems/nix-wasm-rust/^0#packages.x86_64-linux.default` + to a prebuilt store path, using the `fh` CLI. The store path is + substituted rather than fetched as a source tree, so this provides + a convenient way for flakes to depend on prebuilt binary artifacts + (such as WASM plugins). */ +struct FhResolveInputScheme : InputScheme +{ + std::string_view schemeName() const override + { + return "fh-resolve"; + } + + std::string schemeDescription() const override + { + return "Resolves a FlakeHub output reference (`«org»/«project»/«version»#«output»`) to a prebuilt store path using `fh resolve`."; + } + + const std::map & allowedAttrs() const override + { + static const std::map attrs = { + { + "org", + {.doc = "The FlakeHub organization (e.g. `DeterminateSystems`)."}, + }, + { + "project", + {.doc = "The name of the flake on FlakeHub (e.g. `nix-wasm-rust`)."}, + }, + { + "version", + {.doc = "The semantic version requirement of the flake (e.g. `^0` or `=0.1.88`)."}, + }, + { + "output", + {.doc = "The flake output attribute path to resolve (e.g. `packages.x86_64-linux.default`)."}, + }, + { + "storePath", + {.required = false, .doc = "The store path that the reference resolved to."}, + }, + { + "narHash", + {.required = false, .doc = "The NAR hash of the resolved store path."}, + }, + }; + return attrs; + } + + std::optional inputFromAttrs(const Settings & settings, const Attrs & attrs) const override + { + getStrAttr(attrs, "org"); + getStrAttr(attrs, "project"); + getStrAttr(attrs, "version"); + getStrAttr(attrs, "output"); + + Input input{}; + input.attrs = attrs; + return input; + } + + std::optional inputFromURL(const Settings & settings, const ParsedURL & url, bool requireTree) const override + { + if (url.scheme != schemeName()) + return {}; + + auto path = url.pathSegments(/*skipEmpty=*/true) | std::ranges::to>(); + if (path.size() != 3) + throw BadURL("URL '%s' should have the form 'fh-resolve:«org»/«project»/«version»#«output»'", url); + + if (url.fragment.empty()) + throw BadURL("URL '%s' lacks an output attribute path (e.g. '#packages.x86_64-linux.default')", url); + + Attrs attrs; + attrs.insert_or_assign("type", std::string{schemeName()}); + attrs.insert_or_assign("org", path[0]); + attrs.insert_or_assign("project", path[1]); + attrs.insert_or_assign("version", path[2]); + attrs.insert_or_assign("output", url.fragment); + + for (auto & [name, value] : url.query) + if (name == "narHash" || name == "storePath") + attrs.insert_or_assign(name, value); + else + throw BadURL("URL '%s' has unsupported parameter '%s'", url, name); + + return inputFromAttrs(settings, attrs); + } + + ParsedURL toURL(const Input & input, bool abbreviate) const override + { + auto url = ParsedURL{ + .scheme = std::string{schemeName()}, + .path = + {getStrAttr(input.attrs, "org"), + getStrAttr(input.attrs, "project"), + getStrAttr(input.attrs, "version")}, + .fragment = getStrAttr(input.attrs, "output"), + }; + if (!abbreviate) { + if (auto storePath = maybeGetStrAttr(input.attrs, "storePath")) + url.query.insert_or_assign("storePath", *storePath); + if (auto narHash = input.getNarHash()) + url.query.insert_or_assign("narHash", narHash->to_string(HashFormat::SRI, true)); + } + return url; + } + + bool isLocked(const Settings & settings, const Input & input) const override + { + return maybeGetStrAttr(input.attrs, "storePath").has_value() && input.getNarHash().has_value(); + } + + std::optional getFingerprint(Store & store, const Input & input) const override + { + if (auto narHash = input.getNarHash()) + return "fh-resolve:" + narHash->to_string(HashFormat::SRI, true); + return std::nullopt; + } + + std::optional, Input>> + getAccessor(const Settings & settings, Store & store, const Input & _input, bool fastOnly) const override + { + Input input(_input); + + std::optional storePath; + + if (auto storePathS = maybeGetStrAttr(input.attrs, "storePath")) { + /* Use the previously resolved store path, substituting it + if it's not already valid. Note: for final locked inputs, + `Input::getAccessorUnchecked()` will usually have done + this already via `computeStorePath()`. */ + storePath = store.parseStorePath(*storePathS); + store.addTempRoot(*storePath); + if (!store.isValidPath(*storePath)) { + if (fastOnly) + return std::nullopt; + store.ensurePath(*storePath); + } + } else { + if (fastOnly) + return std::nullopt; + + auto fhRef = + fmt("%s/%s/%s#%s", + getStrAttr(input.attrs, "org"), + getStrAttr(input.attrs, "project"), + getStrAttr(input.attrs, "version"), + getStrAttr(input.attrs, "output")); + + Activity act(*logger, lvlTalkative, actUnknown, fmt("resolving FlakeHub reference '%s'", fhRef)); + + auto json = nlohmann::json::parse(runProgram("fh", true, {"resolve", "--json", fhRef})); + + storePath = store.parseStorePath(json.at("store_path").get()); + store.addTempRoot(*storePath); + if (!store.isValidPath(*storePath)) + store.ensurePath(*storePath); + + input.attrs.insert_or_assign("storePath", store.printStorePath(*storePath)); + } + + auto info = store.queryPathInfo(*storePath); + + if (auto expected = input.getNarHash()) + if (info->narHash != *expected) + throw Error( + (unsigned int) 102, + "NAR hash mismatch in input '%s' at '%s', expected '%s' but got '%s'", + input.to_string(), + store.printStorePath(*storePath), + expected->to_string(HashFormat::SRI, true), + info->narHash.to_string(HashFormat::SRI, true)); + + if (!info->references.empty()) + throw Error( + "store path '%s' of input '%s' has references (%s), which is not supported by 'fh-resolve' inputs", + store.printStorePath(*storePath), + input.to_string(), + concatStringsSep( + ", ", + info->references | std::views::transform([&](const StorePath & p) { + return store.printStorePath(p); + }) | std::ranges::to>())); + + input.attrs.insert_or_assign("narHash", info->narHash.to_string(HashFormat::SRI, true)); + + return {{store.requireStoreObjectAccessor(*storePath), std::move(input)}}; + } +}; + +static auto rFhResolveInputScheme = OnStartup([] { registerInputScheme(std::make_unique()); }); + +} // namespace nix::fetchers diff --git a/src/libfetchers/meson.build b/src/libfetchers/meson.build index 84f807db3b94..54fd66c6171d 100644 --- a/src/libfetchers/meson.build +++ b/src/libfetchers/meson.build @@ -42,6 +42,7 @@ sources = files( 'fetch-settings.cc', 'fetch-to-store.cc', 'fetchers.cc', + 'fh-resolve.cc', 'filtering-source-accessor.cc', 'git-lfs-fetch.cc', 'git-utils.cc', From 629b5d29e11a1c9c09ad1df00e6132123db54e18 Mon Sep 17 00:00:00 2001 From: Eelco Dolstra Date: Fri, 24 Jul 2026 17:05:09 +0200 Subject: [PATCH 2/6] libfetchers: Factor out NAR hash mismatch checks into Input::checkNarHash() Assisted-by: Claude Fable 5 --- src/libfetchers/fetchers.cc | 52 ++++++++++--------- src/libfetchers/fh-resolve.cc | 18 ++----- .../include/nix/fetchers/fetchers.hh | 11 ++++ 3 files changed, 42 insertions(+), 39 deletions(-) diff --git a/src/libfetchers/fetchers.cc b/src/libfetchers/fetchers.cc index 2344c871e8ef..7a9514584413 100644 --- a/src/libfetchers/fetchers.cc +++ b/src/libfetchers/fetchers.cc @@ -258,23 +258,7 @@ void Input::checkLocks(Input specified, Input & result) return; } - if (auto prevNarHash = specified.getNarHash()) { - if (result.getNarHash() != prevNarHash) { - if (result.getNarHash()) - throw Error( - (unsigned int) 102, - "NAR hash mismatch in input '%s', expected '%s' but got '%s'", - specified.to_string(), - prevNarHash->to_string(HashFormat::SRI, true), - result.getNarHash()->to_string(HashFormat::SRI, true)); - else - throw Error( - (unsigned int) 102, - "NAR hash mismatch in input '%s', expected '%s' but got none", - specified.to_string(), - prevNarHash->to_string(HashFormat::SRI, true)); - } - } + specified.checkNarHash(result.getNarHash()); if (auto prevRev = specified.getRev()) { if (result.getRev() != prevRev) @@ -282,6 +266,31 @@ void Input::checkLocks(Input specified, Input & result) } } +void Input::checkNarHash(const std::optional & narHash, const std::optional & storePath) const +{ + auto expected = getNarHash(); + if (!expected || (narHash && *narHash == *expected)) + return; + + auto location = storePath ? fmt(" at '%s'", *storePath) : ""; + + if (narHash) + throw Error( + (unsigned int) 102, + "NAR hash mismatch in input '%s'%s, expected '%s' but got '%s'", + to_string(), + location, + expected->to_string(HashFormat::SRI, true), + narHash->to_string(HashFormat::SRI, true)); + else + throw Error( + (unsigned int) 102, + "NAR hash mismatch in input '%s'%s, expected '%s' but got none", + to_string(), + location, + expected->to_string(HashFormat::SRI, true)); +} + std::pair, Input> Input::getAccessor(const Settings & settings, Store & store) const { try { @@ -316,14 +325,7 @@ std::pair, Input> Input::getAccessorUnchecked(const Settings `storePath` attribute rather than being computed from the NAR hash), its validity does not imply that it has the expected contents, so verify the NAR hash. */ - if (narHash != *getNarHash()) - throw Error( - (unsigned int) 102, - "NAR hash mismatch in input '%s' at '%s', expected '%s' but got '%s'", - to_string(), - store.printStorePath(*storePath), - getNarHash()->to_string(HashFormat::SRI, true), - narHash.to_string(HashFormat::SRI, true)); + checkNarHash(narHash, store.printStorePath(*storePath)); auto accessor = store.requireStoreObjectAccessor(*storePath); diff --git a/src/libfetchers/fh-resolve.cc b/src/libfetchers/fh-resolve.cc index 657414956af7..09264a1acbc0 100644 --- a/src/libfetchers/fh-resolve.cc +++ b/src/libfetchers/fh-resolve.cc @@ -173,26 +173,16 @@ struct FhResolveInputScheme : InputScheme auto info = store.queryPathInfo(*storePath); - if (auto expected = input.getNarHash()) - if (info->narHash != *expected) - throw Error( - (unsigned int) 102, - "NAR hash mismatch in input '%s' at '%s', expected '%s' but got '%s'", - input.to_string(), - store.printStorePath(*storePath), - expected->to_string(HashFormat::SRI, true), - info->narHash.to_string(HashFormat::SRI, true)); + input.checkNarHash(info->narHash, store.printStorePath(*storePath)); if (!info->references.empty()) throw Error( "store path '%s' of input '%s' has references (%s), which is not supported by 'fh-resolve' inputs", store.printStorePath(*storePath), input.to_string(), - concatStringsSep( - ", ", - info->references | std::views::transform([&](const StorePath & p) { - return store.printStorePath(p); - }) | std::ranges::to>())); + concatStringsSep(", ", info->references | std::views::transform([&](const StorePath & p) { + return store.printStorePath(p); + }) | std::ranges::to>())); input.attrs.insert_or_assign("narHash", info->narHash.to_string(HashFormat::SRI, true)); diff --git a/src/libfetchers/include/nix/fetchers/fetchers.hh b/src/libfetchers/include/nix/fetchers/fetchers.hh index d830d83c840a..cf1e9ac0e80b 100644 --- a/src/libfetchers/include/nix/fetchers/fetchers.hh +++ b/src/libfetchers/include/nix/fetchers/fetchers.hh @@ -128,6 +128,17 @@ public: */ static void checkLocks(Input specified, Input & result); + /** + * If this input has a `narHash` attribute, check that `narHash` + * matches it, and throw a NAR hash mismatch error (exit code 102) + * otherwise. + * + * @param storePath Optional printed store path of the object that + * was hashed, for the error message. + */ + void checkNarHash( + const std::optional & narHash, const std::optional & storePath = std::nullopt) const; + /** * Return a `SourceAccessor` that allows access to files in the * input without copying it to the store. Also return a possibly From f3db9e0ccf21a602e256f9ea4bfd20d6c28a7c0c Mon Sep 17 00:00:00 2001 From: Eelco Dolstra Date: Fri, 24 Jul 2026 17:37:28 +0200 Subject: [PATCH 3/6] libfetchers: Resolve fh-resolve inputs by querying api.flakehub.com directly Instead of shelling out to 'fh resolve', do the FlakeHub JSON query ourselves. Credentials for api.flakehub.com are picked up from the user's netrc file, which the curl wrapper applies automatically. Assisted-by: Claude Fable 5 --- src/libfetchers/fh-resolve.cc | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/libfetchers/fh-resolve.cc b/src/libfetchers/fh-resolve.cc index 09264a1acbc0..7f984d2ef5c7 100644 --- a/src/libfetchers/fh-resolve.cc +++ b/src/libfetchers/fh-resolve.cc @@ -1,8 +1,8 @@ #include "nix/fetchers/fetchers.hh" #include "nix/fetchers/fetch-settings.hh" +#include "nix/store/filetransfer.hh" #include "nix/store/store-api.hh" #include "nix/util/logging.hh" -#include "nix/util/processes.hh" #include "nix/util/strings.hh" #include @@ -11,10 +11,13 @@ namespace nix::fetchers { /* A fetcher that resolves a FlakeHub output reference like `DeterminateSystems/nix-wasm-rust/^0#packages.x86_64-linux.default` - to a prebuilt store path, using the `fh` CLI. The store path is - substituted rather than fetched as a source tree, so this provides - a convenient way for flakes to depend on prebuilt binary artifacts - (such as WASM plugins). */ + to a prebuilt store path by querying api.flakehub.com. The store + path is substituted rather than fetched as a source tree, so this + provides a convenient way for flakes to depend on prebuilt binary + artifacts (such as WASM plugins). + + Access to private flakes uses the credentials for api.flakehub.com + in the user's netrc file. */ struct FhResolveInputScheme : InputScheme { std::string_view schemeName() const override @@ -152,16 +155,18 @@ struct FhResolveInputScheme : InputScheme if (fastOnly) return std::nullopt; - auto fhRef = - fmt("%s/%s/%s#%s", - getStrAttr(input.attrs, "org"), - getStrAttr(input.attrs, "project"), - getStrAttr(input.attrs, "version"), - getStrAttr(input.attrs, "output")); + Activity act( + *logger, lvlTalkative, actUnknown, fmt("resolving FlakeHub reference '%s'", input.to_string())); - Activity act(*logger, lvlTalkative, actUnknown, fmt("resolving FlakeHub reference '%s'", fhRef)); + FileTransferRequest request( + fmt("https://api.flakehub.com/f/%s/%s/%s/output/%s", + percentEncode(getStrAttr(input.attrs, "org")), + percentEncode(getStrAttr(input.attrs, "project")), + percentEncode(getStrAttr(input.attrs, "version")), + percentEncode(getStrAttr(input.attrs, "output")))); + request.headers = {{"Accept", "application/json"}}; - auto json = nlohmann::json::parse(runProgram("fh", true, {"resolve", "--json", fhRef})); + auto json = nlohmann::json::parse(getFileTransfer()->download(request).data); storePath = store.parseStorePath(json.at("store_path").get()); store.addTempRoot(*storePath); From 585c666b84786cec3eaeb6add7108821fd815337 Mon Sep 17 00:00:00 2001 From: Eelco Dolstra Date: Fri, 24 Jul 2026 17:45:37 +0200 Subject: [PATCH 4/6] libfetchers: Deduplicate store path substitution in fh-resolve Assisted-by: Claude Fable 5 --- src/libfetchers/fh-resolve.cc | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/libfetchers/fh-resolve.cc b/src/libfetchers/fh-resolve.cc index 7f984d2ef5c7..9bcf727fbd2d 100644 --- a/src/libfetchers/fh-resolve.cc +++ b/src/libfetchers/fh-resolve.cc @@ -140,17 +140,11 @@ struct FhResolveInputScheme : InputScheme std::optional storePath; if (auto storePathS = maybeGetStrAttr(input.attrs, "storePath")) { - /* Use the previously resolved store path, substituting it - if it's not already valid. Note: for final locked inputs, - `Input::getAccessorUnchecked()` will usually have done - this already via `computeStorePath()`. */ + /* Use the previously resolved store path. Note: for final + locked inputs, `Input::getAccessorUnchecked()` will + usually have done this already via + `computeStorePath()`. */ storePath = store.parseStorePath(*storePathS); - store.addTempRoot(*storePath); - if (!store.isValidPath(*storePath)) { - if (fastOnly) - return std::nullopt; - store.ensurePath(*storePath); - } } else { if (fastOnly) return std::nullopt; @@ -169,13 +163,18 @@ struct FhResolveInputScheme : InputScheme auto json = nlohmann::json::parse(getFileTransfer()->download(request).data); storePath = store.parseStorePath(json.at("store_path").get()); - store.addTempRoot(*storePath); - if (!store.isValidPath(*storePath)) - store.ensurePath(*storePath); input.attrs.insert_or_assign("storePath", store.printStorePath(*storePath)); } + store.addTempRoot(*storePath); + if (!store.isValidPath(*storePath)) { + if (fastOnly) + return std::nullopt; + /* Substitute the store path. */ + store.ensurePath(*storePath); + } + auto info = store.queryPathInfo(*storePath); input.checkNarHash(info->narHash, store.printStorePath(*storePath)); From 2c5e3d4bd7c9b91a6a99364413db060fe16fdc69 Mon Sep 17 00:00:00 2001 From: Eelco Dolstra Date: Mon, 27 Jul 2026 13:47:46 +0200 Subject: [PATCH 5/6] tests/nixos: Add a VM test for the fh-resolve fetcher This runs a fake api.flakehub.com serving static resolve responses, with the resolved artifact baked into the VM's store. Assisted-by: Claude Fable 5 --- tests/nixos/default.nix | 2 + tests/nixos/fh-resolve.nix | 171 +++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 tests/nixos/fh-resolve.nix diff --git a/tests/nixos/default.nix b/tests/nixos/default.nix index c30e872bc1d0..892e23e336d7 100644 --- a/tests/nixos/default.nix +++ b/tests/nixos/default.nix @@ -166,6 +166,8 @@ in githubFlakes = runNixOSTest ./github-flakes.nix; + fhResolve = runNixOSTest ./fh-resolve.nix; + gitSubmodules = runNixOSTest ./git-submodules.nix; sourcehutFlakes = runNixOSTest ./sourcehut-flakes.nix; diff --git a/tests/nixos/fh-resolve.nix b/tests/nixos/fh-resolve.nix new file mode 100644 index 000000000000..3d31f77c92f4 --- /dev/null +++ b/tests/nixos/fh-resolve.nix @@ -0,0 +1,171 @@ +{ + lib, + config, + ... +}: +let + pkgs = config.nodes.machine.nixpkgs.pkgs; + + # Generate a fake root CA and a fake api.flakehub.com certificate. + cert = pkgs.runCommand "cert" { nativeBuildInputs = [ pkgs.openssl ]; } '' + mkdir -p $out + + openssl genrsa -out ca.key 2048 + openssl req -new -x509 -days 36500 -key ca.key \ + -subj "/C=NL/ST=Denial/L=Springfield/O=Dis/CN=Root CA" -out $out/ca.crt + + openssl req -newkey rsa:2048 -nodes -keyout $out/server.key \ + -subj "/C=CN/ST=Denial/L=Springfield/O=Dis/CN=api.flakehub.com" -out server.csr + openssl x509 -req -extfile <(printf "subjectAltName=DNS:api.flakehub.com") \ + -days 36500 -in server.csr -CA $out/ca.crt -CAkey ca.key -CAcreateserial -out $out/server.crt + ''; + + # The prebuilt artifact that the fh-resolve input resolves to. It + # must not have references. + artifact = pkgs.runCommand "nix-wasm-rust-0.1.0" { } '' + mkdir -p $out + printf 'hello world' > $out/plugin.wasm + ''; + + # An artifact with references, which fh-resolve inputs must reject. + artifactWithRefs = pkgs.runCommand "has-refs-0.1.0" { } '' + mkdir -p $out + echo ${artifact} > $out/ref + ''; + + # Static responses for the FlakeHub resolve endpoint + # (/f/{org}/{project}/{version_req}/output/{attr_path}). + api = pkgs.runCommand "flakehub-api" { } '' + dir="$out/DeterminateSystems/nix-wasm-rust/^0/output" + mkdir -p "$dir" + cat > "$dir/packages.x86_64-linux.default" << EOF + ${builtins.toJSON { + attribute_path = "packages.x86_64-linux.default"; + store_path = "${artifact}"; + token = null; + }} + EOF + + dir="$out/DeterminateSystems/has-refs/^0/output" + mkdir -p "$dir" + cat > "$dir/packages.x86_64-linux.default" << EOF + ${builtins.toJSON { + attribute_path = "packages.x86_64-linux.default"; + store_path = "${artifactWithRefs}"; + token = null; + }} + EOF + ''; + + flake = pkgs.writeTextFile { + name = "flake"; + destination = "/flake.nix"; + text = '' + { + inputs.artifact = { + type = "fh-resolve"; + org = "DeterminateSystems"; + project = "nix-wasm-rust"; + version = "^0"; + output = "packages.x86_64-linux.default"; + flake = false; + }; + + outputs = { self, artifact }: { + content = builtins.readFile (artifact + "/plugin.wasm"); + }; + } + ''; + }; +in + +{ + name = "fh-resolve"; + + nodes = { + machine = + { config, pkgs, ... }: + { + virtualisation.writableStore = true; + virtualisation.additionalPaths = [ + artifact + artifactWithRefs + ]; + nix.settings.substituters = lib.mkForce [ ]; + networking.hosts."127.0.0.1" = [ "api.flakehub.com" ]; + security.pki.certificateFiles = [ "${cert}/ca.crt" ]; + + services.httpd.enable = true; + services.httpd.adminAddr = "foo@example.org"; + services.httpd.extraConfig = '' + ErrorLog syslog:local6 + ''; + services.httpd.virtualHosts."api.flakehub.com" = { + forceSSL = true; + sslServerKey = "${cert}/server.key"; + sslServerCert = "${cert}/server.crt"; + servedDirs = [ + { + urlPath = "/f"; + dir = api; + } + ]; + }; + }; + }; + + testScript = + { nodes }: + '' + # fmt: off + import json + + start_all() + + machine.wait_for_unit("httpd.service") + machine.wait_for_unit("multi-user.target") + + # Check that the fake resolve endpoint works. + out = machine.succeed("curl --fail https://api.flakehub.com/f/DeterminateSystems/nix-wasm-rust/%5E0/output/packages.x86_64-linux.default") + print(out) + assert json.loads(out)["store_path"] == "${artifact}" + + # Lock a flake with an fh-resolve input. + machine.succeed("cp -r ${flake} /tmp/flake && chmod -R u+w /tmp/flake") + machine.succeed("nix flake lock /tmp/flake") + lock = json.loads(machine.succeed("cat /tmp/flake/flake.lock")) + locked = lock["nodes"]["artifact"]["locked"] + print(locked) + assert locked["type"] == "fh-resolve" + assert locked["storePath"] == "${artifact}", "lock file does not record the resolved store path" + nar_hash = locked["narHash"] + + # Evaluating the flake should yield the contents of the artifact. + out = machine.succeed("nix eval --raw /tmp/flake#content") + assert out == "hello world", f"unexpected artifact content: {out}" + + # Test the URL syntax. + out = machine.succeed(""" + nix eval --impure --raw --expr '(builtins.fetchTree "fh-resolve:DeterminateSystems/nix-wasm-rust/%5E0#packages.x86_64-linux.default").narHash' + """) + assert out == nar_hash, f"unexpected NAR hash: {out}" + + # Fetching with an incorrect NAR hash should fail. + out = machine.fail(""" + nix eval --impure --raw --expr '(builtins.fetchTree { type = "fh-resolve"; org = "DeterminateSystems"; project = "nix-wasm-rust"; version = "^0"; output = "packages.x86_64-linux.default"; narHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; }).narHash' 2>&1 + """) + assert "NAR hash mismatch" in out, "NAR hash check did not fail with the expected error" + + # Store paths with references should be rejected. + out = machine.fail(""" + nix eval --impure --raw --expr '(builtins.fetchTree { type = "fh-resolve"; org = "DeterminateSystems"; project = "has-refs"; version = "^0"; output = "packages.x86_64-linux.default"; }).narHash' 2>&1 + """) + assert "has references" in out, "fetching a store path with references did not fail with the expected error" + + # Locked inputs should not require the API server. + machine.succeed("systemctl stop httpd.service") + machine.succeed("rm -rf /root/.cache/nix") + out = machine.succeed("nix eval --raw /tmp/flake#content") + assert out == "hello world", f"unexpected artifact content: {out}" + ''; +} From 95b930ebd18a7763749051af54d0ce855b47f089 Mon Sep 17 00:00:00 2001 From: Eelco Dolstra Date: Mon, 27 Jul 2026 15:10:30 +0200 Subject: [PATCH 6/6] libfetchers: Check that substituted store paths have no references Factor the NAR hash and no-references checks into Input::checkStorePath(), shared between the generic store reuse/substitution path and the fh-resolve fetcher. Previously the generic path did not reject store paths with references. Assisted-by: Claude Fable 5 --- src/libfetchers/fetchers.cc | 19 ++++++++++++++++--- src/libfetchers/fh-resolve.cc | 12 +----------- .../include/nix/fetchers/fetchers.hh | 8 ++++++++ 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/libfetchers/fetchers.cc b/src/libfetchers/fetchers.cc index 7a9514584413..b6c5ea9f97a1 100644 --- a/src/libfetchers/fetchers.cc +++ b/src/libfetchers/fetchers.cc @@ -11,6 +11,7 @@ #include "nix/util/users.hh" #include "nix/store/pathlocks.hh" #include "nix/util/environment-variables.hh" +#include "nix/util/strings.hh" #include #include @@ -291,6 +292,18 @@ void Input::checkNarHash(const std::optional & narHash, const std::optiona expected->to_string(HashFormat::SRI, true)); } +void Input::checkStorePath(Store & store, const ValidPathInfo & info) const +{ + checkNarHash(info.narHash, store.printStorePath(info.path)); + + if (!info.references.empty()) + throw Error( + "store path '%s' of input '%s' has references (%s), which is not supported", + store.printStorePath(info.path), + to_string(), + concatStringsSep(", ", store.printStorePathSet(info.references))); +} + std::pair, Input> Input::getAccessor(const Settings & settings, Store & store) const { try { @@ -319,13 +332,13 @@ std::pair, Input> Input::getAccessorUnchecked(const Settings storePath = computeStorePath(store); auto makeStoreAccessor = [&]() -> std::pair, Input> { - auto narHash = store.queryPathInfo(*storePath)->narHash; + auto info = store.queryPathInfo(*storePath); /* If the store path is input-addressed (i.e. it comes from a `storePath` attribute rather than being computed from the NAR hash), its validity does not imply that it has the expected contents, so verify the NAR hash. */ - checkNarHash(narHash, store.printStorePath(*storePath)); + checkStorePath(store, *info); auto accessor = store.requireStoreObjectAccessor(*storePath); @@ -341,7 +354,7 @@ std::pair, Input> Input::getAccessorUnchecked(const Settings settings.getCache()->upsert( makeSourcePathToHashCacheKey( *accessor->fingerprint, ContentAddressMethod::Raw::NixArchive, CanonPath::root), - {{"hash", narHash.to_string(HashFormat::SRI, true)}}); + {{"hash", info->narHash.to_string(HashFormat::SRI, true)}}); } accessor->provenance = std::make_shared(*this); diff --git a/src/libfetchers/fh-resolve.cc b/src/libfetchers/fh-resolve.cc index 9bcf727fbd2d..93e2ca5c89ac 100644 --- a/src/libfetchers/fh-resolve.cc +++ b/src/libfetchers/fh-resolve.cc @@ -3,7 +3,6 @@ #include "nix/store/filetransfer.hh" #include "nix/store/store-api.hh" #include "nix/util/logging.hh" -#include "nix/util/strings.hh" #include @@ -177,16 +176,7 @@ struct FhResolveInputScheme : InputScheme auto info = store.queryPathInfo(*storePath); - input.checkNarHash(info->narHash, store.printStorePath(*storePath)); - - if (!info->references.empty()) - throw Error( - "store path '%s' of input '%s' has references (%s), which is not supported by 'fh-resolve' inputs", - store.printStorePath(*storePath), - input.to_string(), - concatStringsSep(", ", info->references | std::views::transform([&](const StorePath & p) { - return store.printStorePath(p); - }) | std::ranges::to>())); + input.checkStorePath(store, *info); input.attrs.insert_or_assign("narHash", info->narHash.to_string(HashFormat::SRI, true)); diff --git a/src/libfetchers/include/nix/fetchers/fetchers.hh b/src/libfetchers/include/nix/fetchers/fetchers.hh index cf1e9ac0e80b..c9e8ab9e38ab 100644 --- a/src/libfetchers/include/nix/fetchers/fetchers.hh +++ b/src/libfetchers/include/nix/fetchers/fetchers.hh @@ -17,6 +17,7 @@ namespace nix { class Store; class StorePath; struct SourceAccessor; +struct ValidPathInfo; } // namespace nix namespace nix::fetchers { @@ -139,6 +140,13 @@ public: void checkNarHash( const std::optional & narHash, const std::optional & storePath = std::nullopt) const; + /** + * Check that a store path has the NAR hash expected by this input + * (see `checkNarHash()`) and that it has no references (since + * references are not supported by fetcher trees). + */ + void checkStorePath(Store & store, const ValidPathInfo & info) const; + /** * Return a `SourceAccessor` that allows access to files in the * input without copying it to the store. Also return a possibly