The Nix sandbox is a hidden input
The Nix sandbox is a hidden input
Nix 沙盒是一个隐藏的输入
The whole beauty of Nix was that it was incredibly pragmatic to achieve “reproducibility”, which is quite an overloaded term. The default model for Nix is the intensional-model which is input-addressed: the hash of a store path is derived from the recipe (derivation) that produced it, and not the bytes that came out of it. In that framing Nix achieves repeatability. Nix, by default, was never bit-for-bit reproducible. Nix 的精髓在于它以极其务实的方式实现了“可复现性”(reproducibility),这是一个含义非常丰富的术语。Nix 的默认模型是基于输入的内涵模型(intensional-model):存储路径的哈希值源自生成它的配方(derivation),而非其输出的字节内容。在这种框架下,Nix 实现了可重复性。默认情况下,Nix 从未做到过位对位的完全可复现。
Nix has a ca-derivations feature that makes the output path a hash of the output bytes, which is a different model (extensional-model). For this to work, the derivation must be a complete description of the build. Anything missing from the derivation causes the reproducibility to break and Nix no longer to be “reproducible”. In a previous post I built a source-bootstrapped OpenJDK and we had to provide some additional flags:
Nix 具有一项 ca-derivations 功能,它使输出路径成为输出字节的哈希值,这是一种不同的模型(外延模型,extensional-model)。要使该功能生效,derivation 必须是构建过程的完整描述。任何在 derivation 中缺失的信息都会导致可复现性失效,从而使 Nix 不再“可复现”。在之前的一篇文章中,我构建了一个源码引导的 OpenJDK,当时我们必须提供一些额外的标志:
$ nix build .#openjdk \
--option filter-syscalls false \
--option sandbox-paths '' \
...
--option sandbox-paths mounts additional paths into the sandbox. You can see the default sandbox paths on your machine with:
--option sandbox-paths 会将额外的路径挂载到沙盒中。你可以通过以下命令查看你机器上的默认沙盒路径:
$ nix config show sandbox-paths | tr ' ' '\n'
/bin/sh=/nix/store/zrynrzpsy2993w555ns9a734lbzfff2b-busybox-1.37.0/bin/busybox
/nix/store/cdd109fhy1axl7xb5wisv3v5pd6fawdj-qemu-aarch64-binfmt-P
/run/binfmt
Okay so what’s the point? Turns out that these sandbox-paths are a hidden input to the derivation. The derivation does not mention it, but it can change the output meaningfully in its repeatability very subtley. 😬 Let’s explore this with a small example.
那么重点是什么呢?事实证明,这些 sandbox-paths 是 derivation 的一个隐藏输入。Derivation 本身并未提及它,但它却能以非常微妙的方式改变输出的可重复性。😬 让我们通过一个小例子来探讨一下。
Here is a derivation with no dependencies. It looks for a file /truth. If it finds one, it believes it. If not, it falls back to arithmetic.
这是一个没有任何依赖的 derivation。它会查找 /truth 文件。如果找到了,它就相信其中的内容;如果没有,它就退回到算术运算。
# answer.nix
derivation {
name = "answer";
system = "x86_64-linux";
builder = "/bin/sh";
args = [ "-c" ''
if [ -f /truth ]; then
read -r x < /truth;
else
x=4;
fi
echo "2 + 2 = $x" > $out
'' ];
}
/truth is not in the store, and the Nix sandbox does not mount it, so an honest build never sees it.
/truth 不在存储库(store)中,Nix 沙盒也没有挂载它,因此正常的构建过程永远看不到它。
$ nix-instantiate ./answer.nix
/nix/store/xbik44ifqm4jqjp4z7n1031smj08mil7-answer.drv
$ nix-store --realise /nix/store/xbik44…-answer.drv
/nix/store/qba6hdgvdrry4k1z83v2zm5xy714l83m-answer
$ cat /nix/store/qba6…-answer
2 + 2 = 4
Our output hash is qba6hdgvdrry4k1z83v2zm5xy714l83m. We now can add an additional sandbox-path which will cause the repeatibility of the builder to break. We will mount a file /truth into the sandbox that contains a lie.
我们的输出哈希是 qba6hdgvdrry4k1z83v2zm5xy714l83m。现在我们可以添加一个额外的 sandbox-path,这将导致构建器的可重复性失效。我们将把一个包含谎言的 /truth 文件挂载到沙盒中。
$ echo 5 > /tmp/truth
$ nix-store --delete /nix/store/qba6…-answer # throw away the honest one
$ nix-store --realise /nix/store/xbik44…-answer.drv \
--option extra-sandbox-paths "/truth=/tmp/truth"
/nix/store/qba6hdgvdrry4k1z83v2zm5xy714l83m-answer
$ cat /nix/store/qba6…-answer
2 + 2 = 5
Our output hash is still qba6hdgvdrry4k1z83v2zm5xy714l83m. 😭 The derivation (.drv) is meant to be the complete recipe. Since the sandbox is configured outside the recipe it now causes the same derivation to be leaky and no longer hermetic. Sandboxing is often a way to enforce hermticity and here it actively breaks it.
我们的输出哈希仍然是 qba6hdgvdrry4k1z83v2zm5xy714l83m。😭 Derivation (.drv) 本应是完整的配方。由于沙盒是在配方之外配置的,这导致同一个 derivation 出现了泄露,不再具有封闭性(hermetic)。沙盒通常是强制实现封闭性的一种手段,但在这里它反而主动破坏了封闭性。
The sandbox-paths are nowhere to found in the derivation. This is in contrast with __noChroot, which is a derivation attribute.
sandbox-paths 在 derivation 中无处可寻。这与作为 derivation 属性的 __noChroot 形成了鲜明对比。
$ nix derivation show /nix/store/xbik44…-answer.drv
{
"…-answer.drv": {
"builder": "/bin/sh",
"args": ["-c", "if [ -f /truth ]; …"],
"env": { "out": "…qba6…-answer", "name": "answer", … },
"inputs": { "drvs": {}, "srcs": [] },
…
}
}
Two people can evaluate a byte-identical .drv, run different actual build steps, and Nix create the same output hash. You may be thinking “Well, a derivation could have always relied on /dev/random or date, so what’s new?”. True, this is a form of non-determinism. The difference is those are more evident in the derivations to discover and audit for. The act of checking for the existence of a file is extremely subtle and not something that is easy to discover.
两个人可以评估字节完全相同的 .drv,运行不同的实际构建步骤,而 Nix 却生成了相同的输出哈希。你可能会想:“嗯,derivation 一直以来都可能依赖 /dev/random 或 date,这有什么新鲜的?”确实,这是一种非确定性。区别在于,那些依赖在 derivation 中更容易被发现和审计。而检查文件是否存在这一行为极其隐蔽,不容易被察觉。
In fact, your derivation may appear to even be byte-reproducible on your machine with the --check flag, but it may not be on someone else’s machine. How problematic is this?
事实上,你的 derivation 在你的机器上使用 --check 标志时可能看起来是字节可复现的,但在别人的机器上可能就不是了。这有多严重呢?
The default value of sandbox-paths is not a constant baked into the source. It is a compile-time property of your particular Nix binary. Two people running nix --version that prints the same number can have different sandboxes before either of them touches a flag.
sandbox-paths 的默认值并不是硬编码在源码中的常量。它是你所使用的特定 Nix 二进制文件的编译时属性。两个运行 nix --version 并显示相同版本号的人,在不触碰任何标志的情况下,可能拥有完全不同的沙盒环境。
The /bin/sh entry is not in my nix.conf. It is the default, and it comes from here in the Nix source src/libstore/globals.cc:
/bin/sh 条目并不在我的 nix.conf 中。它是默认值,源自 Nix 源码中的 src/libstore/globals.cc:
#if (defined(__linux__) || defined(__FreeBSD__)) && defined(SANDBOX_SHELL)
sandboxPaths = {{"/bin/sh", {.source = SANDBOX_SHELL}}};
#endif
There are at least two different baselines you can build Nix with, none of them visible to any derivation: 构建 Nix 时至少有两种不同的基准,且任何 derivation 都无法感知到它们:
- Built with
sandbox-shellset to a path and binary with hopefully the same semantics. 构建时设置了sandbox-shell指向某个路径和二进制文件,并期望其具有相同的语义。 - Built without
sandbox-shellsuch that the option is empty. No/bin/shat all. 构建时未设置sandbox-shell,导致该选项为空。根本没有/bin/sh。
Nix’s own documentation describes this possibility. Depending on how Nix was built, the default value for this option may be empty or provide /bin/sh as a bind-mount of bash. Although that itself is not true since Nixpkgs builds Nix with busybox not bash.
Nix 自身的文档描述了这种可能性。根据 Nix 的构建方式,该选项的默认值可能为空,或者提供一个作为 bash 绑定挂载的 /bin/sh。尽管这本身并不准确,因为 Nixpkgs 构建 Nix 时使用的是 busybox 而非 bash。
(lib.mesonOption "sandbox-shell" "${busybox-sandbox-shell}/bin/busybox")
Given that Flakes have made Nix become incredibly more decentralized, causing us to rely on multiple binary caches, there is no way now to guarantee that the builds are repeatable despite the fact that you can download the binary from the cache. Is this a bug? 🤔 This is tough to say. 鉴于 Flakes 使得 Nix 变得更加去中心化,导致我们依赖多个二进制缓存,现在已经无法保证构建是可重复的了,尽管你可以从缓存中下载二进制文件。这是一个 Bug 吗?🤔 这很难说。
Including the sandbox-paths in the derivation would make Nix completely unusable. Suppose you did fold sandbox-paths into the output hash by having it in the derivation. My busybox is busybox-1.37.0 at one store path; yours is a different version at a different path. The same derivation would then hash to different outputs on our two machines, and binary-cache sharing across would collapse. The property that lets us share builds is the same property that lets us poison them. None of this is hypothetical and this is how it comes back to the OpenJDK from the t…
将 sandbox-paths 包含在 derivation 中会使 Nix 完全无法使用。假设你确实通过将 sandbox-paths 放入 derivation 来将其折叠进输出哈希中。我的 busybox 是位于某个存储路径下的 busybox-1.37.0;而你的则是位于不同路径下的不同版本。那么同一个 derivation 在我们两台机器上就会哈希出不同的输出,跨机器的二进制缓存共享将彻底崩溃。使我们能够共享构建的属性,同时也正是允许我们污染它们的属性。这一切都不是假设,这就是它如何回到 OpenJDK 问题的原因……