Day 46 — Fetchers & FODs
Day 46 — Fetchers & FODs
Stage V · ~4h (lab-heavy)
Goal: Use fixed-output derivations (FODs) correctly—fetchurl, fetchFromGitHub, vendor hashes—and debug hash mismatches without turning off purity.
Why this day exists
Every package starts as bytes from somewhere. If those bytes are not fixed-output and hashed, your “reproducible” package is a network role-play. FODs are the contract: given this hash, the store path is determined by content, and the build may touch the network only to produce those exact bytes.
Theory 1 — Fixed-output vs normal derivations
| Kind | Network in sandbox? | Output path depends on | Failure mode |
|---|---|---|---|
| Normal build | No (default) | Inputs (input-addressed) | Compile errors, missing tools |
| Fixed-output (FOD) | Yes, limited | Declared output hash | Hash mismatch if bytes change |
FODs power almost every src = fetch… you see in nixpkgs.
src = fetchurl {
url = "https://example.com/foo-1.2.3.tar.gz";
hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};Modern nixpkgs prefers SRI hash = "sha256-…" over separate sha256 = "…" legacy attrs (both still appear in the wild).
Theory 2 — Common fetchers (2026 mental map)
| Fetcher | Use when | Notes |
|---|---|---|
fetchurl |
Direct tarball/file URL | Mirrors, curlOpts, name |
fetchTarball |
Eval-time tarball (flakes/inputs style) | Different API; not always for package src |
fetchFromGitHub |
GitHub repo + rev/tag | Sets owner, repo, rev, hash |
fetchFromGitLab |
GitLab | Similar shape |
fetchgit |
Arbitrary git | Needs hash / leaveDotGit carefully |
fetchzip |
Zip that should unpack | Unpacks in fetcher |
fetchpatch / fetchpatch2 |
Single patch files | Great for patches = [ … ] |
fetchPypi |
Python sdist/wheel helpers | Via python infra |
| Language-specific vendor hashes | Go modules, Cargo, npm | See Days 47–48 |
fetchFromGitHub pattern
{ stdenv, fetchFromGitHub, lib }:
stdenv.mkDerivation rec {
pname = "ripgrep-demo";
version = "14.1.1";
src = fetchFromGitHub {
owner = "BurntSushi";
repo = "ripgrep";
rev = version; # tag or full commit
hash = "sha256-…"; # FOD for the archive Nix downloads
};
# real packaging would use rustPlatform; this is fetcher-focused
}Prefer tags or full commit SHAs over branch names (main). Branches move; FODs must not.
Theory 3 — The hash mismatch workflow
You will see this forever:
error: hash mismatch in fixed-output derivation '…':
specified: sha256-AAAA…
got: sha256-BBBB…
Correct response
- Trust “got” only after you trust the URL/rev. If the project was compromised, blindly updating the hash pins malware.
- Confirm you intended that
rev/ version.
- Replace
hashwith thegotvalue (or uselib.fakeHash/ empty once to force a fail).
- Rebuild; lock the known-good hash in git.
- Never leave
hash = "";orlib.fakeSha256in committed production flakes.
Prefetch helpers
# URL
nix-prefetch-url https://example.com/foo-1.2.3.tar.gz
nix store prefetch-file https://example.com/foo-1.2.3.tar.gz
# GitHub-style (nix-prefetch-github if available in shell)
nix flake prefetch github:BurntSushi/ripgrep/14.1.1
# Generic: let the build fail once
# hash = lib.fakeHash;# Temporary during packaging
hash = lib.fakeHash; # forces mismatch with a clear "got"Theory 4 — Impure traps and purity
| Trap | Why it hurts | Fix |
|---|---|---|
builtins.fetchGit without flake lock discipline |
Eval-time drift | Prefer flake inputs or FOD fetchers with hash |
Branch rev = "main" |
Hash churn | Pin commit |
curl in a normal buildPhase |
Sandbox network blocked | Use FOD or pkgs.fetchurl as input |
| Post-download patching inside FOD | Changes bytes → hash mismatch | Patch in normal derivation patches |
Leaving .git without care |
Non-determinism | Default archives omit .git; leaveDotGit is advanced |
Rule: Download and fix bytes in the FOD or patch after unpack in the normal build—do not mix casually.
Theory 5 — Flake inputs vs package fetchers
| Mechanism | Pinned by | Good for |
|---|---|---|
inputs.foo.url = "github:…" |
flake.lock |
Nix code, whole repos as flakes |
fetchFromGitHub in a package |
hash on the fetcher |
Upstream source of a package |
pkgs.fetchgit of a monorepo path |
hash + sparse options | Subdir projects |
Do not confuse “my flake input updated” with “my package src hash updated.” Both can change store paths; only one is your app’s source tarball.
Worked examples
Example A — tarball package skeleton
# pkgs/cowsay-mini.nix (illustrative; prefer real meta from nixpkgs)
{ stdenv, fetchurl, perl, lib }:
stdenv.mkDerivation rec {
pname = "cowsay";
version = "3.8.4";
src = fetchurl {
url = "https://github.com/cowsay-org/cowsay/archive/refs/tags/v${version}.tar.gz";
hash = "sha256-…";
};
buildInputs = [ perl ];
meta = {
description = "Configurable talking cow";
license = lib.licenses.gpl3Only;
mainProgram = "cowsay";
};
}Example B — GitHub + patch FOD
{ stdenv, fetchFromGitHub, fetchpatch, lib }:
stdenv.mkDerivation {
pname = "example";
version = "1.0.0";
src = fetchFromGitHub {
owner = "example-org";
repo = "example";
rev = "v1.0.0";
hash = "sha256-SRCSOURCESOURCESOURCESOURCESOURCESOURCESO=";
};
patches = [
(fetchpatch {
url = "https://github.com/example-org/example/commit/abcdef.patch";
hash = "sha256-PATCHPATCHPATCHPATCHPATCHPATCHPATCHPATCHPA=";
})
];
}Example C — intentional mismatch lab snippet
src = fetchurl {
url = "https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.1.tar.xz";
hash = lib.fakeHash;
};First build prints the real hash; you paste it back.
Lab — multi-step
Suggested workspace: ~/lab/90daysofx/02-nixos/day46
Step 1 — Prefetch a known tarball
cd ~/lab/90daysofx/02-nixos/day46
mkdir -p pkgs
nix store prefetch-file \
https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gzRecord the SRI hash.
Step 2 — Package from that tarball
Write pkgs/hello-fod.nix with fetchurl + your hash. Export from a flake:
nix build .#hello-fod -L
./result/bin/helloStep 3 — Force a mismatch
Change one character of the hash; rebuild; capture the error. Restore the correct hash.
Step 4 — GitHub fetcher
Pick a tiny tagged GitHub project (or a single-file tool). Package with fetchFromGitHub:
src = fetchFromGitHub {
owner = "…";
repo = "…";
rev = "vX.Y.Z";
hash = lib.fakeHash;
};Iterate until the hash stabilizes. Prefer a project with a clear install (copy binary/script).
Step 5 — Patch as FOD
Add an empty or no-op local patch first:
# create patches/no-op or real fixpatches = [ ./patches/fix-typo.patch ];Local patches are not FODs (they’re path inputs). Then replace with fetchpatch for one upstream commit if available—note the second hash.
Step 6 — Document the trust boundary
In NOTES.md:
- Where did bytes come from?
- Who could change them?
- What does updating
hashmean operationally?
- Difference between updating
flake.lockand updatingsrc.hash.
Step 7 — Offline check (optional)
After populate:
nix build .#hello-fod --offlineIf it works, your FOD is already in the store; purity is about definition, not “never used network once.”
Common gotchas
| Symptom / mistake | What to do |
|---|---|
| Hash mismatch after “nothing changed” | Upstream re-released same tag; pin commit SHA |
fetchFromGitHub hash differs on Darwin vs Linux |
Unusual for pure archives; check name, filters, or LFS |
Using sha256 = "000…" empty |
Prefer lib.fakeHash for clarity; never commit fakes |
| Network error inside normal derivation | Move download to fetcher |
| Submodules missing | fetchFromGitHub fetchSubmodules = true (+ hash changes) |
Huge monorepo as src |
Sparse checkout / release tarball / sourceRoot |
| Private repo fetch | SSH/builtins.fetchGit + credentials patterns; prefer CI tokens carefully |
Checkpoint
- Explain FOD vs normal derivation in one paragraph
- Packaged something with
fetchurland correct SRI hash
- Packaged something with
fetchFromGitHub
- Demonstrated and fixed a hash mismatch deliberately
- Notes on trust when updating hashes
Commit
git add .
git commit -m "day46: fetchers and FOD hash workflow"Write three personal gotchas before continuing.
Tomorrow
Day 47 — Language ecosystem A: buildGoModule and friends for your primary language.