rescriptum · an answer written for this machine
EN
On this page

Building

./build.sh                    # this machine, and print the size
./build.sh --all              # every target a release ships
./build.sh --no-sqlite        # the smallest binary
./build.sh armv7-unknown-linux-gnueabihf
./build.sh --help

build.sh adds a missing Rust target for you and warns if a musl build came out dynamically linked — which DSM would refuse to run, at exec time on the NAS rather than at build time on your laptop.

Plain cargo build works too; build.sh exists for the size report and that warning.

The release targets

TargetForCross
armv7-unknown-linux-gnueabihfthe DS416j, the reason this project exists — glibc, not musl, see belowzigbuild, floor 2.17
aarch64-unknown-linux-muslmodern ARM NAS, Raspberry Pizigbuild
x86_64-unknown-linux-muslmost other Linux hostszigbuild
aarch64-apple-darwinlocal developmentnative
x86_64-apple-darwinlocal developmentnative

Cross-compiling

cargo-zigbuild uses Zig as the linker, which avoids a full cross toolchain per target:

cargo install cargo-zigbuild
cargo zigbuild --release --target armv7-unknown-linux-gnueabihf.2.17

Why armv7 is the one target that is not musl

Every other target is static musl. ARMv7 is glibc, and it is not a preference — it is the only way the machine this project exists for runs the binary at all.

Synology’s ARMv7 kernels are 3.10, and they answer the time64 syscalls with EINVAL rather than ENOSYS. musl 1.2 made time_t 64-bit on 32-bit architectures and tries clock_gettime64 (and clock_nanosleep, and the timed futex) first, falling back to the 32-bit syscall only on ENOSYS. On a kernel that says EINVAL the fallback never happens, so every call for the time fails. Measured on a DS416j running DSM 7.1, kernel 3.10.108:

$ ./probe
libc clock_gettime(CLOCK_REALTIME)  -> -1  errno=22 (Invalid argument)
syscall 263 (time32)                -> 0   ok
syscall 403 (time64)                -> -1  errno=22 (Invalid argument)

The symptom is a binary that answers --version and then panics the moment it wants a timestamp — time.rs:131, Os { code: 22, kind: InvalidInput }. It is not an ABI problem and not a kernel-too-old-for-the-instructions problem, which is what it looks like.

glibc on 32-bit uses the time32 syscalls, and DSM ships its own (2.20 on armada38x). So the armv7 build targets a glibc floor of 2.17 — low enough for DSM, and since glibc is backward compatible, the same binary runs on newer ARMv7 Linux as well.

What to verify, then, is not that it is static — it is that it needs no glibc newer than the floor. Anything newer fails at exec time on the NAS, naming a symbol version and nothing else:

$ readelf --dyn-syms target/armv7-unknown-linux-gnueabihf/release/rescriptum \
    | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1
GLIBC_2.17

CI asserts exactly that on every push. The musl targets are still checked for being static, because for them that is the promise.

Installing Zig on the maintainer’s machine

Zig is not a Homebrew install here: brew install aborts on that machine over untrusted third-party taps unrelated to Zig. It lives in ~/.local/zig, symlinked at ~/.local/bin/zig. To upgrade, replace that directorybrew upgrade zig does nothing.

Verified toolchain: Rust 1.93, cargo-zigbuild 0.23.0, Zig 0.16.0, with targets aarch64-apple-darwin and armv7-unknown-linux-gnueabihf installed.

The release profile

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true

panic = "abort" is deliberately absent — see constraints. Measured cost of keeping unwinding on ARMv7: +2416 bytes, +0.8%.

Size

BuildARMv7
default2,103,456 bytes
--no-default-features (no SQLite, no admin API)944,928 bytes

Most of the difference is bundled SQLite, compiled from source. CI builds --release --no-default-features on every push so the small build cannot rot unnoticed.

Features

FeatureDefaultGives
sqliteonthe SQLite store and the admin API
cargo build --no-default-features          # smallest
cargo test --all-features                  # what CI runs

The Synology package

A .spk is a release format, not a build: the binary is finished before packaging begins, there is no DSM-specific build, and nothing in src/ knows Synology exists.

./build.sh --spk x86_64-unknown-linux-musl   # build, then wrap it
packaging/dsm/make-spk.sh armv7              # wrap a build that already exists
packaging/dsm/check-spk.sh                   # structural check over dist/*.spk

The package carries the loaders, so build them first or it will not pass its own check. make-spk.sh takes them from packaging/ipxe/out (override with RESCRIPTUM_LOADERS), and check-spk.sh fails a package that has none — a TFTP server with nothing to hand out boots nothing. Building iPXE needs a Linux C toolchain, which on a Mac means a container:

docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w debian:bookworm-slim sh -c '
  apt-get update -qq &&
  apt-get install -y --no-install-recommends build-essential liblzma-dev mtools \
    xorriso isolinux gcc-aarch64-linux-gnu git ca-certificates perl &&
  packaging/ipxe/build.sh --out /w/packaging/ipxe/out'

Once, not per package: the loaders are the same bytes in every ABI’s .spk, because they run on the machines being booted, not on the NAS. packaging/ipxe/out is gitignored — no binaries in git, ever.

ABIarch in INFOFrom
x86_64x86_64 — the family name, so it covers every Intel platformx86_64-unknown-linux-musl
armv7armada38x — the family shorthand does not reach the Marvell platformsarmv7-unknown-linux-gnueabihf
aarch64armv8aarch64-unknown-linux-musl, once the binary has been run on one

The rule for widening that: claim an ABI once the binary has run on the oldest-kernel member of it, never because a platform is plausible.

make-spk.sh is deterministic — fixed mtimes, ownership 0:0, ustar, gzip -n, a pre-sorted file list — so the same inputs give a byte-identical .spk, which is what makes the published checksum worth something.

check-spk.sh runs in CI on every push. It asserts the outer archive is an uncompressed tar, that INFO has its six required fields and an all-numeric version, that the icons are exactly 64×64 and 256×256, that the lifecycle scripts parse and are executable, and that the packaged binary’s own --version matches INFO — the x86_64 build runs on the runner, so that last one is a real assertion rather than a re-read of the same string.

lifecycle-test.sh then drives the package’s own scripts against a fake /var/packages tree — install, start, /health, the exit codes, an upgrade over a hand-edited configuration, an uninstall over a canary in the share — and also runs on every push.

packaging/dsm/lifecycle-test.sh

What none of that can prove is that DSM will accept the package; only installing it can. That is the rig in packaging/dsm/vm/: a QEMU launcher, and one script that runs the on-machine checks against the VM while you iterate and against the DS416j for the verdict. See testing.

Deploying a build

./deploy.sh admin@nas
./deploy.sh admin@nas /volume1/netboot

Builds, checks the answers and refuses to ship if they do not come back clean, copies under a temporary name, restarts, and confirms /health. See deployment.

EnvironmentDefault
TARGETarmv7-unknown-linux-gnueabihf
ANSWERS<remote-dir>/answers
PORT8000
Updated Edit this page