手动构建 Nix derivations,无需猜测

2025年4月5日

昨天和今天早上,我进行了一次关于底层 Nix 的小冒险,起因是 这篇优秀的博客文章。在这篇文章中,Farid 构建了最简单的 Nix derivation——创建一个包含 "hello world" 内容的文件。我喜欢这篇文章的原因是:

然而,阅读后我仍然不明白的是哈希值从何而来。Farid 的文章和我偶尔使用 C++ 或 Rust 编译器时做的事情一样,即故意引起一个错误来获取编译器已经知道的信息。对于像我这样从零开始的人来说,这不是很令人满意。所以我深入研究了一下,试图弄清楚如何手动生成这些哈希值。

但首先,让我们明确一些术语。请原谅(并帮助我纠正)沿途的错误,因为这是我第一次接触 Nix。

到底什么是 derivation?

据我所知,它是一个非常精确的构建文件的配方。它有点像 Make 配方或 shell 脚本,只不过它的所有输入和输出都指向 /nix/store 中众所周知的长路径。

以下是 Farid 博客中的一个示例,我们将在本文中尝试复制它,其中包含其中一个路径名:

{"name":"simple","system":"x86_64-linux","builder":"/bin/sh","outputs":{"out":{"path":"/nix/store/5bkcqwq3qb6dxshcj44hr1jrf8k7qhxb-simple"}},"inputSrcs":[],"inputDrvs":{},"env":{"out":"/nix/store/5bkcqwq3qb6dxshcj44hr1jrf8k7qhxb-simple"},"args":["-c","echo 'hello world' > $out"]}

其中,我们有一个 name 字段(你选择的名字)和一个 system 字段(我猜有一些众所周知的系统/平台名称。我也是碰巧运行 x86_64 Linux。如果你跟着做,只有当你使用相同的系统时,你的哈希值才会和我的一样,我想。)。

我们还有这个叫做 builder 的东西,它是要运行的单个命令。在这种情况下,我们还从 env 传递给它 args(在底部)和环境变量。不要过多考虑 inputSrcsinputDrvs,因为它们在此文章中不起作用。

最后,我们有 outputs,它像 env 一样,也有这个神奇的路径名。它是你的 Nix derivation 需要创建的输出文件(或目录,我想)。outputsenv 中的名称 out 是任意的,我认为它们不必具有相同的名称。只是约定而已。

这个 derivation 大致对应于以下 Make 配方(请记住,Make 变量也以 $ 开头,因此您必须转义您的 shell 变量,如 $${var}):

/nix/store/5bkcqwq3qb6dxshcj44hr1jrf8k7qhxb-simple:
	export out=/nix/store/5bkcqwq3qb6dxshcj44hr1jrf8k7qhxb-simple; \
  echo 'hello world' > $${out}

但是,这个巨大的路径从何而来?

准备工作

我不想安装 Nix,所以我所有的探索都在 Docker 内部进行。如果你不打算长期使用 Nix,我建议你这样做,因为它会对系统进行一些非常侵入性的更改。

以下是您入门所需的一切:

FROM nixos/nix AS builder

现在您可以构建一个 Docker 容器:

$ docker build . -t notnix
...
$

然后启动 /bin/sh,这是唯一一个位于易于记忆的路径中的 shell:

$ docker run -i -t notnix /bin/sh
sh-5.2#...

在本文的其余部分中,每当我使用 Nix 命令时,它要么在 Docker 的 RUN 命令中,要么在运行中的 Nix 容器内的 /bin/sh 中。

现在让我们找到一些哈希值。

寻找哈希值

John Ott 向我指出,Nix 关于存储路径的文档 包含部分答案。他说,该路径来自使用将 outPath 设置为空字符串的 ATerm 表示形式对 derivation 进行哈希处理。

……什么?

经过一番挖掘和重新阅读 Farid 的文章后,显然 ATerm 是一种较旧的配置语言,看起来有点像构建 OCaml 变体。我想我们不需要路径来计算路径是有道理的(否则我们就会陷入循环困境)。所以我让 Nix 创建我的 JSON derivation 的 ATerm 形式,没有任何路径:

{"name":"simple","system":"x86_64-linux","builder":"/bin/sh","outputs":{"out":{}},"inputSrcs":[],"inputDrvs":{},"env":{},"args":["-c","echo 'hello world' > $out"]}

通过运行:

$ nix --extra-experimental-features nix-command derivation add < simple.json
/nix/store/1p6dixyqvjddfq5fmys3i55nl90ckjam-simple.drv
$

运行该命令会输出 ATerm 形式文件的路径,我们可以查看它:

$ cat /nix/store/1p6dixyqvjddfq5fmys3i55nl90ckjam-simple.drv
Derive([("out","","","")],[],[],"x86_64-linux","/bin/sh",["-c","echo 'hello world' >$out"],[("out","")])$

你可以看到他们确保删除所有空格,甚至是尾随换行符(因此末尾的 $ 是我的 shell 提示符)。

好的,所以我们有一个 ATerm 形式的 derivation,它没有输出路径。我想我们对它进行哈希处理?我在这一点上有点迷失了,直到 Jamey Sharp 加入了更详细的 存储路径规范

经过多次阅读,这澄清了我们需要执行以下步骤。在某些时候,我切换到使用 Python,因为它变得有点文本操作繁重:

  1. 将没有输出路径的 ATerm derivation 称为 inner-fingerprint,因为我们没有处理 textsource 类型或 NAR 等等。
  2. inner-fingerprint 进行 SHA256 哈希处理,然后对其进行 base16 编码。这称为 inner-digest

好的,还不错:

import hashlib
import base64

with open(inner_fingerprint, "rb") as f:
    inner_fingerprint_hash = hashlib.file_digest(f, "sha256").digest()
inner_digest = (
    base64.b16encode(inner_fingerprint_hash).decode("utf-8").lower()
)

然后,一旦我们有了它,我们就可以对它进行更多操作:

  1. inner-digest 与 derivation 的 name 等其他字段组合起来,并将其称为 fingerprint
  2. 对其进行哈希处理,取前 20 位,并取其 base32 表示形式。

好的,还不错,Python 可以在标准库中完成所有这些:

name = deriv["name"]  # "simple"
# "out" 是我们之前选择的名称,是任意的
fingerprint = f"output:out:sha256:{inner_digest}:/nix/store:{name}"
fingerprint_hash = hashlib.sha256(fingerprint.encode("utf-8")).digest()
fingerprint_digest = hashlib.b32encode(fingerprint_hash[:20])

然而。 该文档在两件事上具有误导性,这让我进行了一次愉快的追逐。

首先,Nix 使用普通的 base32。他们使用不同的字符集。此外,他们以相反的顺序进行 base32 编码。直到 tombl 在 Twitter 上发表评论后,我才弄清楚这两件事。

其次,存储路径文档在他们说“SHA-256 哈希的前 160 位 [20 字节]”时完全在撒谎。相反,他们应该说的是“对哈希进行这种奇怪的 XOR 操作,将其折叠回自身”。

我只是通过挖掘 Nix C++ 代码库 才获得第二个位的。所以相反,我们真正想要的是这个:

def to_nix_base32(bytes_data):
    b32_alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
    b32_nix = b"0123456789abcdfghijklmnpqrsvwxyz"
    trans = bytes.maketrans(b32_alphabet, b32_nix)
    return base64.b32encode(bytes_data[::-1]).translate(trans).decode("utf-8")


def compress_hash(h, newlen):
    result = bytearray(b"\0" * newlen)
    for i in range(len(h)):
        result[i % newlen] ^= h[i]
    return bytes(result)


fingerprint = (
    f"output:{output}:sha256:{inner_digest}:{STORE_DIR}:{name}"
)
fingerprint_hash = hashlib.sha256(fingerprint.encode("utf-8")).digest()
fingerprint_digest = to_nix_base32(compress_hash(fingerprint_hash, 20))

我使用 bytes.maketrans 是因为 Python 使得在 normal-base32 和 nix-base32 之间进行转换非常容易,但是您也应该查看 nix-base32 的独立实现。例如,这是 Tvix 的链接

从正确的指纹哈希方法中弹出的这个魔术数字与 Farid 的帖子中的数字相同! 5bkcqwq3qb6dxshcj44hr1jrf8k7qhxb

现在我们可以将其作为输出路径和环境变量添加回 JSON 中,以最终获得与之前相同的 JSON blob:

{"name":"simple","system":"x86_64-linux","builder":"/bin/sh","outputs":{"out":{"path":"/nix/store/5bkcqwq3qb6dxshcj44hr1jrf8k7qhxb-simple"}},"inputSrcs":[],"inputDrvs":{},"env":{"out":"/nix/store/5bkcqwq3qb6dxshcj44hr1jrf8k7qhxb-simple"},"args":["-c","echo 'hello world' > $out"]}

正如 Farid 所承诺的那样,Nix 接受我们的 derivation JSON 并为我们提供了一个新的 ATerm:

$ nix --extra-experimental-features nix-command derivation add < simple.json
/nix/store/vh5zww1mqbcshfcblrw3y92v7kkzamfx-simple.drv
$

它与 Farid 在他的帖子中使用的 derivation 路径相同。

但是,手头有一个 derivation 并不意味着我们——终于——编写了一个正确的配方来构建东西。让我们运行它并查看输出!

$ nix-store --realize /nix/store/vh5zww1mqbcshfcblrw3y92v7kkzamfx-simple.drv
...
/nix/store/5bkcqwq3qb6dxshcj44hr1jrf8k7qhxb-simple
$ cat /nix/store/5bkcqwq3qb6dxshcj44hr1jrf8k7qhxb-simple
hello world
$

我称之为成功。我们构建了一个 derivation,没有任何猜测和检查!

如果你喜欢,可以查看 我的 Python 代码

该博客是 开源的。看到错误了吗?继续 提出更改