不靠猜测,手动构建 Nix Derivations
手动构建 Nix derivations,无需猜测
2025年4月5日
昨天和今天早上,我进行了一次关于底层 Nix 的小冒险,起因是 这篇优秀的博客文章。在这篇文章中,Farid 构建了最简单的 Nix derivation——创建一个包含 "hello world" 内容的文件。我喜欢这篇文章的原因是:
- 没有 Nix 语言;只有手动构建 derivation 的真正棘手的底层部分。
- 它是增量的,从一个友好的 JSON blob 开始,逐步添加更复杂的部分。
- 它只使用了两个底层 Nix 命令。
然而,阅读后我仍然不明白的是哈希值从何而来。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
(在底部)和环境变量。不要过多考虑 inputSrcs
和 inputDrvs
,因为它们在此文章中不起作用。
最后,我们有 outputs
,它像 env
一样,也有这个神奇的路径名。它是你的 Nix derivation 需要创建的输出文件(或目录,我想)。outputs
和 env
中的名称 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,因为它变得有点文本操作繁重:
- 将没有输出路径的 ATerm derivation 称为
inner-fingerprint
,因为我们没有处理text
或source
类型或 NAR 等等。 - 对
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()
)
然后,一旦我们有了它,我们就可以对它进行更多操作:
- 将
inner-digest
与 derivation 的name
等其他字段组合起来,并将其称为fingerprint
。 - 对其进行哈希处理,取前 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 代码。