Fil-C on Nix

Filnix wiki

filroot.md

A relocatable Fil-C root

nix build .#filroot makes filroot-0.686-filnix-x86_64-linux.tar.xz, the toolchain and programs of optfil in a directory that works wherever it is unpacked: at any path, including one with spaces, and read-only. It needs nothing from Nix or from the host's libraries:

tar -xJf filroot-0.686-filnix-x86_64-linux.tar.xz
export PATH=$PWD/filroot-0.686-filnix-x86_64-linux/bin:$PATH
filcc -O2 -o hello hello.c && fil-run ./hello

packages/filroot/README.md is the user documentation shipped in the tarball.

Descriptor 987

A relocatable tree has to resolve its own paths without knowing where it is. ELF interpreters must be absolute paths, and so are most of the paths compiled into the programs and libraries: glibc's character set modules and locales, the loader's library directories, OpenSSL's directory, terminfo, Python's prefix, Perl's @INC, shebangs, pkg-config files.

The root writes all of them as /proc/self/fd/987/..., and arranges for descriptor 987 to be the root's directory in every process that needs it:

The kernel resolves an interpreter or a #! line in the process that calls execve, so a program started by a program of the root finds its loader through the inherited descriptor. Within the process, the loader, libc and the rest open their files through it. Host programs started from the root inherit the descriptor and nothing else; no environment variables are set, so unlike php-dist the root needs no renamed variables in glibc and does not leak settings into host programs.

The costs:

The compiler

bin/filcc, bin/fil++ and bin/filcpp are links to bin/clang-20, a launcher for libexec/filroot/bin/clang-20. Clang computes its directory from /proc/self/exe, so it reads clang.cfg (clang++.cfg, clang-cpp.cfg) from libexec/filroot/bin and finds its resource directory and libc++'s headers through the links libexec/filroot/lib and libexec/filroot/include. The configuration puts the driver into its pizfix mode with these options:

--filc-resource-dir=<CFGDIR>/..
--filc-stdfil-include=<CFGDIR>/../include
--filc-os-include=<CFGDIR>/../include
--filc-include=<CFGDIR>/../include
--filc-crt-path=/proc/self/fd/987/lib
--filc-dynamic-linker=/proc/self/fd/987/lib/ld-fil1-x86_64.so

Headers come from the root's real path, so diagnostics and dependency files name real files. Linking uses the crt files and libraries through the descriptor, which the compiler has because it was started by a launcher, and records /proc/self/fd/987/lib as the interpreter's directory and the RUNPATH. ld, as and sarcasm are found next to Clang.

What compiled programs need

A program built with the root's filcc has the interpreter /proc/self/fd/987/lib/ld-fil1-x86_64.so and the RUNPATH /proc/self/fd/987/lib, like the programs in the root. It runs in any process where descriptor 987 is a Fil-C root of the same version:

Started from a host shell, execve fails with ENOENT. Nothing ties a compiled program to the directory that built it, so it runs with the root moved, or with another copy of it. The alternative, recording the root's absolute path as the interpreter, would let programs start directly, but they would break when the root moves, and glibc's data paths in the root would still need the descriptor.

Testing

packages/filroot/test/run.sh unpacks the tarball and runs inside.sh in podman containers without Nix, as root and as an unprivileged user. The tree is mounted read-only at /srv/my fil root/filroot-... and again at /elsewhere:

nix build .#filroot
packages/filroot/test/run.sh result debian:12 debian:13 ubuntu:22.04 alpine:3

It runs optfil's checks through fil-run and also checks that compiled programs run from the root's bash and make and with the root at the other path, that they do not start without the descriptor, that programs work with an empty environment, and that Python, Perl, git and scripts in bin/ find their files. FILROOT_TEST_NET=1 adds HTTPS fetches with curl and git.