nandi/nimstaticpublic Fork 0
799d4ce17b14426b13baf1aaeb778ed57f9ee392
Commits
Clone
git clone https://git.rickub.com/nandi/nimstatic.git
git clone ssh://git@rickub.com/nandi/nimstatic.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Rename to nimstatic, and detect dependencies from the source 53cd879 · on 799d4ce17b14426b13baf1aaeb778ed57f9ee392 · nandi · 16h ago
README.md · 173 lines · 6.3 KBmarkdown
Blame HistoryOpen raw

nimstatic

Fully static Nim binaries, dependencies and all.

nimstatic app.nim

That's the whole thing. nimstatic asks the Nim compiler what app.nim actually
needs, fetches musl-built static libraries for it from Alpine's mirrors, and
compiles against a sysroot it owns. No apk, no container, no root, no Nix.

$ nimstatic freeqsay.nim -- -d:ssl
probing freeqsay.nim …
  ssl           dlopen  openssl-libs-static
  crypto        dlopen  openssl-libs-static
downloading openssl-libs-static 3.3.7-r1 (12734 KiB)
building …
wrote freeqsay (20687 KiB, static)

The result runs anywhere with a Linux kernel — ldd says not a dynamic
executable
— and its HTTPS still works.

How it knows what you need

Guessing from import lines would be wrong in both directions: a transitive
import three modules deep still needs its library, and an import behind a
when that never fires does not. So nimstatic asks the compiler instead. A
--compileOnly --genScript probe produces the nimcache your real build would
have, and that cache answers twice over:

  • <project>.json carries the link command, so every -lfoo is explicit.
  • The generated C contains the dynlib candidate strings Nim will dlopen
    at runtime — "libssl.so(.3|.1.1|…)". These never appear on a link line, and
    they are exactly what breaks a static binary.

Libraries musl already provides (m, rt, dl, pthread, …) are skipped.
Anything else is looked up in a table of Alpine packages; whatever isn't mapped
is reported rather than silently dropped:

$ nimstatic detect app.nim
library         how      alpine package
sqlite3         link     sqlite-static
ssl             dlopen   openssl-libs-static
mystery         dlopen   (unmapped)

unmapped: mystery
search for one with: nimstatic search mystery

Then --map mystery=mystery-static teaches it, for that build.

The two things that silently break static Nim

Both are handled automatically; they're documented here because they cost
everyone an afternoon at least once.

  • -d:ssl makes Nim dlopen libssl at runtime. A static binary cannot,
    and dies at startup with could not load: libcrypto.so(...) — even on code
    paths that never touch the network. Fix: --dynlibOverride:ssl --dynlibOverride:crypto plus the archives on the link line. nimstatic emits
    an override only for libraries it actually has an archive for, since an
    override without one turns a runtime failure into a link failure.
  • OpenSSL 3 removed SSL_get_peer_certificate, which Nim's wrapper still
    names, so the link dies on one undefined symbol. Fix:
    -DSSL_get_peer_certificate=SSL_get1_peer_certificate.

A static binary also carries no CA trust store, so set SSL_CERT_FILE on the
host that runs it.

Usage

nimstatic <file.nim> [-- <nim args>]   Detect, fetch, build static
nimstatic detect <file.nim>            Show what it needs, change nothing
nimstatic add <pkg>...                 Put packages in the sysroot by hand
nimstatic list                         Show what the sysroot holds
nimstatic libs                         Show the sysroot's static libraries
nimstatic search <text>                Search Alpine's index
nimstatic show <pkg>                   Index record for one package
nimstatic nimflags [-l lib]            Print nim flags for the sysroot
nimstatic ccflags [-l lib]             Print cc/clang flags
nimstatic nimcfg [-o file]             Write a nim.cfg fragment
nimstatic zigcc [-o file]              Write a `zig cc -target …-musl` wrapper
nimstatic env                          Shell exports (PKG_CONFIG_*, NIMSTATIC_ROOT)
nimstatic path                         Print the sysroot path
nimstatic clean                        Remove the sysroot (cache is kept)

Everything after -- goes to the Nim compiler for both the probe and the
build, so conditional imports resolve the same way twice:

nimstatic app.nim -- -d:ssl -d:danger

Build options: -o/--output, -d/--debug (skip -d:release), -n/--dry-run
(print the command instead of running it), --map lib=pkg, --pkg name,
--cc path, --nim path.

Sysroot options: -r/--root, -b/--branch (default v3.21, edge for
rolling), -a/--arch, -m/--mirror, --repo main,community, -l/--lib,
--no-deps, --refresh, -q/--quiet.

The sysroot defaults to $XDG_DATA_HOME/nimstatic/sysroot and honors
NIMSTATIC_ROOT. Downloads cache under $XDG_CACHE_HOME/nimstatic, so the
second build is offline and the index is re-fetched once a day.

Cross-compiling

Same command, one flag — the sysroot, the packages and the zig target all
follow --arch:

nimstatic app.nim --arch aarch64 -o app-arm64

Requirements

  • zig on PATH — used as zig cc -target x86_64-linux-musl. Pass --cc to
    use a musl cross-compiler you already have instead.
  • tar — an .apk is concatenated gzip streams, which GNU tar reads.

Using it as a library

import nimstatic

let d = detect("app.nim", ["-d:ssl"])
echo packages(d)          # @["openssl-libs-static"]
echo dynlibOverrides(d)   # @["ssl", "crypto"]

let remote = initRemote(branch = "edge")
for pkg in remote.fetchIndex().resolve(packages(d)):
  echo pkg.name, " ", pkg.version, " ", remote.fetchPackage(pkg)

Modules: nimstatic/detect (probe, parse, map), nimstatic/build (the
one-command path), nimstatic/index (APKINDEX parsing, provides and dependency
resolution), nimstatic/repo (mirror, cache, download, unpack),
nimstatic/sysroot (manifest, static-lib discovery), nimstatic/flags (flag
emission).

Trust

Packages come over HTTPS from the mirror and are unpacked as-is. nimstatic
checks the size recorded in the index but does not verify Alpine's RSA
signatures — apk's checksum field covers a package's control segment rather
than the file, so a real check means implementing apk's signature format. Treat
a sysroot as build input, not as a trust root. If that matters, pin a mirror
you run.

Install

nimble install

or build in place with nim c -d:ssl -o:nimstatic src/nimstatic.nim.

Tests

nimble test

25 tests, all offline: soname parsing, link-command parsing, dynlib discovery
in generated C, package mapping, APKINDEX parsing, dependency resolution
through so:/pkgconfig provides, manifest round-trips and flag emission
(including link order and the dropped-override case).

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# nimstatic

Fully static Nim binaries, dependencies and all.

```bash
nimstatic app.nim
```

That's the whole thing. nimstatic asks the Nim compiler what `app.nim` actually
needs, fetches musl-built static libraries for it from Alpine's mirrors, and
compiles against a sysroot it owns. No apk, no container, no root, no Nix.

```
$ nimstatic freeqsay.nim -- -d:ssl
probing freeqsay.nim …
  ssl           dlopen  openssl-libs-static
  crypto        dlopen  openssl-libs-static
downloading openssl-libs-static 3.3.7-r1 (12734 KiB)
building …
wrote freeqsay (20687 KiB, static)
```

The result runs anywhere with a Linux kernel — `ldd` says *not a dynamic
executable* — and its HTTPS still works.

## How it knows what you need

Guessing from `import` lines would be wrong in both directions: a transitive
import three modules deep still needs its library, and an import behind a
`when` that never fires does not. So nimstatic asks the compiler instead. A
`--compileOnly --genScript` probe produces the nimcache your real build would
have, and that cache answers twice over:

- **`<project>.json`** carries the link command, so every `-lfoo` is explicit.
- **The generated C** contains the dynlib candidate strings Nim will `dlopen`
  at runtime — `"libssl.so(.3|.1.1|…)"`. These never appear on a link line, and
  they are exactly what breaks a static binary.

Libraries musl already provides (`m`, `rt`, `dl`, `pthread`, …) are skipped.
Anything else is looked up in a table of Alpine packages; whatever isn't mapped
is reported rather than silently dropped:

```
$ nimstatic detect app.nim
library         how      alpine package
sqlite3         link     sqlite-static
ssl             dlopen   openssl-libs-static
mystery         dlopen   (unmapped)

unmapped: mystery
search for one with: nimstatic search mystery
```

Then `--map mystery=mystery-static` teaches it, for that build.

## The two things that silently break static Nim

Both are handled automatically; they're documented here because they cost
everyone an afternoon at least once.

- **`-d:ssl` makes Nim `dlopen` libssl at runtime.** A static binary cannot,
  and dies at startup with `could not load: libcrypto.so(...)` — even on code
  paths that never touch the network. Fix: `--dynlibOverride:ssl
  --dynlibOverride:crypto` plus the archives on the link line. nimstatic emits
  an override only for libraries it actually has an archive for, since an
  override without one turns a runtime failure into a link failure.
- **OpenSSL 3 removed `SSL_get_peer_certificate`,** which Nim's wrapper still
  names, so the link dies on one undefined symbol. Fix:
  `-DSSL_get_peer_certificate=SSL_get1_peer_certificate`.

A static binary also carries no CA trust store, so set `SSL_CERT_FILE` on the
host that runs it.

## Usage

```
nimstatic <file.nim> [-- <nim args>]   Detect, fetch, build static
nimstatic detect <file.nim>            Show what it needs, change nothing
nimstatic add <pkg>...                 Put packages in the sysroot by hand
nimstatic list                         Show what the sysroot holds
nimstatic libs                         Show the sysroot's static libraries
nimstatic search <text>                Search Alpine's index
nimstatic show <pkg>                   Index record for one package
nimstatic nimflags [-l lib]            Print nim flags for the sysroot
nimstatic ccflags [-l lib]             Print cc/clang flags
nimstatic nimcfg [-o file]             Write a nim.cfg fragment
nimstatic zigcc [-o file]              Write a `zig cc -target …-musl` wrapper
nimstatic env                          Shell exports (PKG_CONFIG_*, NIMSTATIC_ROOT)
nimstatic path                         Print the sysroot path
nimstatic clean                        Remove the sysroot (cache is kept)
```

Everything after `--` goes to the Nim compiler for **both** the probe and the
build, so conditional imports resolve the same way twice:

```bash
nimstatic app.nim -- -d:ssl -d:danger
```

Build options: `-o/--output`, `-d/--debug` (skip `-d:release`), `-n/--dry-run`
(print the command instead of running it), `--map lib=pkg`, `--pkg name`,
`--cc path`, `--nim path`.

Sysroot options: `-r/--root`, `-b/--branch` (default `v3.21`, `edge` for
rolling), `-a/--arch`, `-m/--mirror`, `--repo main,community`, `-l/--lib`,
`--no-deps`, `--refresh`, `-q/--quiet`.

The sysroot defaults to `$XDG_DATA_HOME/nimstatic/sysroot` and honors
`NIMSTATIC_ROOT`. Downloads cache under `$XDG_CACHE_HOME/nimstatic`, so the
second build is offline and the index is re-fetched once a day.

## Cross-compiling

Same command, one flag — the sysroot, the packages and the zig target all
follow `--arch`:

```bash
nimstatic app.nim --arch aarch64 -o app-arm64
```

## Requirements

- **zig** on PATH — used as `zig cc -target x86_64-linux-musl`. Pass `--cc` to
  use a musl cross-compiler you already have instead.
- **tar** — an `.apk` is concatenated gzip streams, which GNU tar reads.

## Using it as a library

```nim
import nimstatic

let d = detect("app.nim", ["-d:ssl"])
echo packages(d)          # @["openssl-libs-static"]
echo dynlibOverrides(d)   # @["ssl", "crypto"]

let remote = initRemote(branch = "edge")
for pkg in remote.fetchIndex().resolve(packages(d)):
  echo pkg.name, " ", pkg.version, " ", remote.fetchPackage(pkg)
```

Modules: `nimstatic/detect` (probe, parse, map), `nimstatic/build` (the
one-command path), `nimstatic/index` (APKINDEX parsing, provides and dependency
resolution), `nimstatic/repo` (mirror, cache, download, unpack),
`nimstatic/sysroot` (manifest, static-lib discovery), `nimstatic/flags` (flag
emission).

## Trust

Packages come over HTTPS from the mirror and are unpacked as-is. nimstatic
checks the size recorded in the index but does **not** verify Alpine's RSA
signatures — apk's checksum field covers a package's control segment rather
than the file, so a real check means implementing apk's signature format. Treat
a sysroot as build input, not as a trust root. If that matters, pin a mirror
you run.

## Install

```bash
nimble install
```

or build in place with `nim c -d:ssl -o:nimstatic src/nimstatic.nim`.

## Tests

```bash
nimble test
```

25 tests, all offline: soname parsing, link-command parsing, dynlib discovery
in generated C, package mapping, APKINDEX parsing, dependency resolution
through `so:`/`pkgconfig` provides, manifest round-trips and flag emission
(including link order and the dropped-override case).