nandi/nimstaticpublic Fork 0
53cd879
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

The headline command is now `nimstatic app.nim`: detect, fetch, build.

Detection asks the compiler rather than reading import lines, which would be
wrong both ways — a transitive import three modules deep still needs its
library, and an import behind a dead `when` does not. A --compileOnly
--genScript probe yields the nimcache the real build would have, and that says
it twice: the build json's link command gives every -lfoo, and the generated C
holds the dynlib candidate strings Nim would dlopen at runtime. The second set
never reaches a link line and is exactly what breaks a static binary.

Libraries musl already provides are skipped, the rest map to Alpine packages
(--map teaches it new ones), and unmapped libraries are reported rather than
silently dropped. An override is only emitted for a library whose archive is
actually in the sysroot, since an override without one turns a runtime failure
into a link failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T11:36:31-07:00 Browse files
53cd879 parent: 5feb050
modified .gitignore +2 -2
@@ -1,4 +1,4 @@
1-/muslkit
1+/nimstatic
22 /zigcc
3-tests/test_muslkit
3+tests/test_nimstatic
44 nimcache/
@@ -1,4 +1,4 @@
1-/muslkit1+/nimstatic
2 /zigcc2 /zigcc
3-tests/test_muslkit3+tests/test_nimstatic
4 nimcache/4 nimcache/
modified README.md +130 -77
@@ -1,114 +1,165 @@
1-# muslkit
1+# nimstatic
22
3-musl-linked static libraries from Alpine, without Alpine.
4-
5-Alpine builds everything against musl and ships `*-static` packages for most of
6-it. An `.apk` is just a tarball, and an `APKINDEX` is just a text file — so you
7-don't need apk, a chroot, a container or a distro to get `libcrypto.a` built
8-for musl. muslkit fetches the index, resolves a dependency closure, downloads
9-the packages and unpacks them into a sysroot directory you own.
10-
11-Nothing is installed system-wide. Nothing needs root.
3+Fully static Nim binaries, dependencies and all.
124
135 ```bash
14-muslkit add openssl-libs-static
15-nim c -d:ssl $(muslkit nimflags) --cc:clang --clang.exe:./zigcc app.nim
6+nimstatic app.nim
167 ```
178
18-## Why
19-
20-Getting a fully static Nim (or C, or Zig) binary with TLS on a glibc distro
21-means finding musl-built archives, and the usual answers are "install Alpine",
22-"run Docker" or "use Nix". Alpine's mirrors already serve exactly the files —
23-this just takes them.
24-
25-## Usage
9+That's the whole thing. nimstatic asks the Nim compiler what `app.nim` actually
10+needs, fetches musl-built static libraries for it from Alpine's mirrors, and
11+compiles against a sysroot it owns. No apk, no container, no root, no Nix.
2612
2713 ```
28-muslkit add <pkg>... Download packages and unpack into the sysroot
29-muslkit list Show what the sysroot holds
30-muslkit libs Show the static libraries in the sysroot
31-muslkit search <text> Search the index (names and descriptions)
32-muslkit show <pkg> Index record for one package
33-muslkit nimflags [-l lib] Print nim flags for a static build
34-muslkit ccflags [-l lib] Print cc/clang flags
35-muslkit nimcfg [-o file] Write a nim.cfg fragment
36-muslkit zigcc [-o file] Write a `zig cc -target …-musl` wrapper script
37-muslkit env Shell exports (PKG_CONFIG_*, MUSLKIT_ROOT)
38-muslkit path Print the sysroot path
39-muslkit clean Remove the sysroot (cache is kept)
14+$ nimstatic freeqsay.nim -- -d:ssl
15+probing freeqsay.nim …
16+ ssl dlopen openssl-libs-static
17+ crypto dlopen openssl-libs-static
18+downloading openssl-libs-static 3.3.7-r1 (12734 KiB)
19+building …
20+wrote freeqsay (20687 KiB, static)
4021 ```
4122
42-Options: `-r/--root`, `-b/--branch` (default `v3.21`, `edge` for rolling),
43-`-a/--arch`, `-m/--mirror`, `--repo main,community`, `-l/--lib`, `--cc`,
44-`--no-deps`, `--refresh`, `-q/--quiet`.
23+The result runs anywhere with a Linux kernel — `ldd` says *not a dynamic
24+executable* — and its HTTPS still works.
4525
46-The sysroot defaults to `$XDG_DATA_HOME/muslkit/sysroot` and honors
47-`MUSLKIT_ROOT`. Downloads are cached under `$XDG_CACHE_HOME/muslkit`, so a
48-second `add` is offline and the index is re-fetched only once a day.
26+## How it knows what you need
4927
50-## A full static build, start to finish
28+Guessing from `import` lines would be wrong in both directions: a transitive
29+import three modules deep still needs its library, and an import behind a
30+`when` that never fires does not. So nimstatic asks the compiler instead. A
31+`--compileOnly --genScript` probe produces the nimcache your real build would
32+have, and that cache answers twice over:
5133
52-```bash
53-muslkit add openssl-libs-static zlib-static
54-muslkit zigcc # writes ./zigcc (needs zig on PATH)
55-nim c -d:release -d:ssl \
56- --cc:clang --clang.exe:./zigcc --clang.linkerexe:./zigcc \
57- $(muslkit nimflags) --passL:-s \
58- -o:app src/app.nim
34+- **`<project>.json`** carries the link command, so every `-lfoo` is explicit.
35+- **The generated C** contains the dynlib candidate strings Nim will `dlopen`
36+ at runtime — `"libssl.so(.3|.1.1|…)"`. These never appear on a link line, and
37+ they are exactly what breaks a static binary.
38+
39+Libraries musl already provides (`m`, `rt`, `dl`, `pthread`, …) are skipped.
40+Anything else is looked up in a table of Alpine packages; whatever isn't mapped
41+is reported rather than silently dropped:
42+
43+```
44+$ nimstatic detect app.nim
45+library how alpine package
46+sqlite3 link sqlite-static
47+ssl dlopen openssl-libs-static
48+mystery dlopen (unmapped)
49+
50+unmapped: mystery
51+search for one with: nimstatic search mystery
5952 ```
6053
61-`muslkit nimflags` emits more than include and library paths, because two
62-things bite every static Nim build with TLS:
54+Then `--map mystery=mystery-static` teaches it, for that build.
55+
56+## The two things that silently break static Nim
57+
58+Both are handled automatically; they're documented here because they cost
59+everyone an afternoon at least once.
6360
6461 - **`-d:ssl` makes Nim `dlopen` libssl at runtime.** A static binary cannot,
6562 and dies at startup with `could not load: libcrypto.so(...)` — even on code
66- paths that never touch the network. The fix is `--dynlibOverride:ssl
67- --dynlibOverride:crypto` plus the archives on the link line.
63+ paths that never touch the network. Fix: `--dynlibOverride:ssl
64+ --dynlibOverride:crypto` plus the archives on the link line. nimstatic emits
65+ an override only for libraries it actually has an archive for, since an
66+ override without one turns a runtime failure into a link failure.
6867 - **OpenSSL 3 removed `SSL_get_peer_certificate`,** which Nim's wrapper still
69- references, so the link fails on one undefined symbol. The fix is
68+ names, so the link dies on one undefined symbol. Fix:
7069 `-DSSL_get_peer_certificate=SSL_get1_peer_certificate`.
7170
72-Both are emitted automatically when `libssl.a` and `libcrypto.a` are present in
73-the sysroot. A static binary carries no CA trust store, so set `SSL_CERT_FILE`
74-to a bundle on the host that runs it.
71+A static binary also carries no CA trust store, so set `SSL_CERT_FILE` on the
72+host that runs it.
7573
76-Cross-compiling is the same command with `--arch`; `muslkit add -a aarch64
77-openssl-libs-static` and `muslkit zigcc -a aarch64` line up.
74+## Usage
7875
79-## Trust
76+```
77+nimstatic <file.nim> [-- <nim args>] Detect, fetch, build static
78+nimstatic detect <file.nim> Show what it needs, change nothing
79+nimstatic add <pkg>... Put packages in the sysroot by hand
80+nimstatic list Show what the sysroot holds
81+nimstatic libs Show the sysroot's static libraries
82+nimstatic search <text> Search Alpine's index
83+nimstatic show <pkg> Index record for one package
84+nimstatic nimflags [-l lib] Print nim flags for the sysroot
85+nimstatic ccflags [-l lib] Print cc/clang flags
86+nimstatic nimcfg [-o file] Write a nim.cfg fragment
87+nimstatic zigcc [-o file] Write a `zig cc -target …-musl` wrapper
88+nimstatic env Shell exports (PKG_CONFIG_*, NIMSTATIC_ROOT)
89+nimstatic path Print the sysroot path
90+nimstatic clean Remove the sysroot (cache is kept)
91+```
8092
81-Packages come over HTTPS from the mirror and are unpacked as-is. muslkit checks
82-the size recorded in the index but does **not** verify Alpine's RSA signatures —
83-apk's checksum field covers the package's control segment, not the file, so a
84-real check means implementing apk's signature format. Treat a sysroot as build
85-input, not as a trust root. If that matters for your use, pin a mirror you run.
93+Everything after `--` goes to the Nim compiler for **both** the probe and the
94+build, so conditional imports resolve the same way twice:
8695
87-## Install
96+```bash
97+nimstatic app.nim -- -d:ssl -d:danger
98+```
99+
100+Build options: `-o/--output`, `-d/--debug` (skip `-d:release`), `-n/--dry-run`
101+(print the command instead of running it), `--map lib=pkg`, `--pkg name`,
102+`--cc path`, `--nim path`.
103+
104+Sysroot options: `-r/--root`, `-b/--branch` (default `v3.21`, `edge` for
105+rolling), `-a/--arch`, `-m/--mirror`, `--repo main,community`, `-l/--lib`,
106+`--no-deps`, `--refresh`, `-q/--quiet`.
107+
108+The sysroot defaults to `$XDG_DATA_HOME/nimstatic/sysroot` and honors
109+`NIMSTATIC_ROOT`. Downloads cache under `$XDG_CACHE_HOME/nimstatic`, so the
110+second build is offline and the index is re-fetched once a day.
111+
112+## Cross-compiling
113+
114+Same command, one flag — the sysroot, the packages and the zig target all
115+follow `--arch`:
88116
89117 ```bash
90-nimble install
118+nimstatic app.nim --arch aarch64 -o app-arm64
91119 ```
92120
93-or build in place with `nim c -d:ssl -o:muslkit src/muslkit.nim`. Requires
94-`tar` on PATH (an `.apk` is concatenated gzip streams, which GNU tar reads) and,
95-for the `zigcc` helper, `zig`.
121+## Requirements
122+
123+- **zig** on PATH — used as `zig cc -target x86_64-linux-musl`. Pass `--cc` to
124+ use a musl cross-compiler you already have instead.
125+- **tar** — an `.apk` is concatenated gzip streams, which GNU tar reads.
96126
97-## Library
127+## Using it as a library
98128
99129 ```nim
100-import muslkit
130+import nimstatic
131+
132+let d = detect("app.nim", ["-d:ssl"])
133+echo packages(d) # @["openssl-libs-static"]
134+echo dynlibOverrides(d) # @["ssl", "crypto"]
101135
102136 let remote = initRemote(branch = "edge")
103-let idx = remote.fetchIndex()
104-for pkg in idx.resolve(["openssl-libs-static"]):
137+for pkg in remote.fetchIndex().resolve(packages(d)):
105138 echo pkg.name, " ", pkg.version, " ", remote.fetchPackage(pkg)
106139 ```
107140
108-Modules: `muslkit/index` (APKINDEX parsing, provides and dependency
109-resolution), `muslkit/repo` (mirror, cache, download, unpack),
110-`muslkit/sysroot` (manifest, static-lib discovery), `muslkit/flags` (nim/cc/
111-pkg-config flag emission).
141+Modules: `nimstatic/detect` (probe, parse, map), `nimstatic/build` (the
142+one-command path), `nimstatic/index` (APKINDEX parsing, provides and dependency
143+resolution), `nimstatic/repo` (mirror, cache, download, unpack),
144+`nimstatic/sysroot` (manifest, static-lib discovery), `nimstatic/flags` (flag
145+emission).
146+
147+## Trust
148+
149+Packages come over HTTPS from the mirror and are unpacked as-is. nimstatic
150+checks the size recorded in the index but does **not** verify Alpine's RSA
151+signatures — apk's checksum field covers a package's control segment rather
152+than the file, so a real check means implementing apk's signature format. Treat
153+a sysroot as build input, not as a trust root. If that matters, pin a mirror
154+you run.
155+
156+## Install
157+
158+```bash
159+nimble install
160+```
161+
162+or build in place with `nim c -d:ssl -o:nimstatic src/nimstatic.nim`.
112163
113164 ## Tests
114165
@@ -116,5 +167,7 @@ pkg-config flag emission).
116167 nimble test
117168 ```
118169
119-The suite is offline — index parsing, resolution through `so:`/`pkg:` provides,
120-manifest round-trips and flag emission all run against fixtures.
170+25 tests, all offline: soname parsing, link-command parsing, dynlib discovery
171+in generated C, package mapping, APKINDEX parsing, dependency resolution
172+through `so:`/`pkgconfig` provides, manifest round-trips and flag emission
173+(including link order and the dropped-override case).
@@ -1,114 +1,165 @@
1-# muslkit1+# nimstatic
2 2
3-musl-linked static libraries from Alpine, without Alpine.3+Fully static Nim binaries, dependencies and all.
4-
5-Alpine builds everything against musl and ships `*-static` packages for most of
6-it. An `.apk` is just a tarball, and an `APKINDEX` is just a text file — so you
7-don't need apk, a chroot, a container or a distro to get `libcrypto.a` built
8-for musl. muslkit fetches the index, resolves a dependency closure, downloads
9-the packages and unpacks them into a sysroot directory you own.
10-
11-Nothing is installed system-wide. Nothing needs root.
12 4
13 ```bash5 ```bash
14-muslkit add openssl-libs-static6+nimstatic app.nim
15-nim c -d:ssl $(muslkit nimflags) --cc:clang --clang.exe:./zigcc app.nim
16 ```7 ```
17 8
18-## Why9+That's the whole thing. nimstatic asks the Nim compiler what `app.nim` actually
19-10+needs, fetches musl-built static libraries for it from Alpine's mirrors, and
20-Getting a fully static Nim (or C, or Zig) binary with TLS on a glibc distro11+compiles against a sysroot it owns. No apk, no container, no root, no Nix.
21-means finding musl-built archives, and the usual answers are "install Alpine",
22-"run Docker" or "use Nix". Alpine's mirrors already serve exactly the files —
23-this just takes them.
24-
25-## Usage
26 12
27 ```13 ```
28-muslkit add <pkg>... Download packages and unpack into the sysroot14+$ nimstatic freeqsay.nim -- -d:ssl
29-muslkit list Show what the sysroot holds15+probing freeqsay.nim …
30-muslkit libs Show the static libraries in the sysroot16+ ssl dlopen openssl-libs-static
31-muslkit search <text> Search the index (names and descriptions)17+ crypto dlopen openssl-libs-static
32-muslkit show <pkg> Index record for one package18+downloading openssl-libs-static 3.3.7-r1 (12734 KiB)
33-muslkit nimflags [-l lib] Print nim flags for a static build19+building …
34-muslkit ccflags [-l lib] Print cc/clang flags20+wrote freeqsay (20687 KiB, static)
35-muslkit nimcfg [-o file] Write a nim.cfg fragment
36-muslkit zigcc [-o file] Write a `zig cc -target …-musl` wrapper script
37-muslkit env Shell exports (PKG_CONFIG_*, MUSLKIT_ROOT)
38-muslkit path Print the sysroot path
39-muslkit clean Remove the sysroot (cache is kept)
40 ```21 ```
41 22
42-Options: `-r/--root`, `-b/--branch` (default `v3.21`, `edge` for rolling),23+The result runs anywhere with a Linux kernel — `ldd` says *not a dynamic
43-`-a/--arch`, `-m/--mirror`, `--repo main,community`, `-l/--lib`, `--cc`,24+executable* — and its HTTPS still works.
44-`--no-deps`, `--refresh`, `-q/--quiet`.
45 25
46-The sysroot defaults to `$XDG_DATA_HOME/muslkit/sysroot` and honors26+## How it knows what you need
47-`MUSLKIT_ROOT`. Downloads are cached under `$XDG_CACHE_HOME/muslkit`, so a
48-second `add` is offline and the index is re-fetched only once a day.
49 27
50-## A full static build, start to finish28+Guessing from `import` lines would be wrong in both directions: a transitive
29+import three modules deep still needs its library, and an import behind a
30+`when` that never fires does not. So nimstatic asks the compiler instead. A
31+`--compileOnly --genScript` probe produces the nimcache your real build would
32+have, and that cache answers twice over:
51 33
52-```bash34+- **`<project>.json`** carries the link command, so every `-lfoo` is explicit.
53-muslkit add openssl-libs-static zlib-static35+- **The generated C** contains the dynlib candidate strings Nim will `dlopen`
54-muslkit zigcc # writes ./zigcc (needs zig on PATH)36+ at runtime — `"libssl.so(.3|.1.1|…)"`. These never appear on a link line, and
55-nim c -d:release -d:ssl \37+ they are exactly what breaks a static binary.
56- --cc:clang --clang.exe:./zigcc --clang.linkerexe:./zigcc \38+
57- $(muslkit nimflags) --passL:-s \39+Libraries musl already provides (`m`, `rt`, `dl`, `pthread`, …) are skipped.
58- -o:app src/app.nim40+Anything else is looked up in a table of Alpine packages; whatever isn't mapped
41+is reported rather than silently dropped:
42+
43+```
44+$ nimstatic detect app.nim
45+library how alpine package
46+sqlite3 link sqlite-static
47+ssl dlopen openssl-libs-static
48+mystery dlopen (unmapped)
49+
50+unmapped: mystery
51+search for one with: nimstatic search mystery
59 ```52 ```
60 53
61-`muslkit nimflags` emits more than include and library paths, because two54+Then `--map mystery=mystery-static` teaches it, for that build.
62-things bite every static Nim build with TLS:55+
56+## The two things that silently break static Nim
57+
58+Both are handled automatically; they're documented here because they cost
59+everyone an afternoon at least once.
63 60
64 - **`-d:ssl` makes Nim `dlopen` libssl at runtime.** A static binary cannot,61 - **`-d:ssl` makes Nim `dlopen` libssl at runtime.** A static binary cannot,
65 and dies at startup with `could not load: libcrypto.so(...)` — even on code62 and dies at startup with `could not load: libcrypto.so(...)` — even on code
66- paths that never touch the network. The fix is `--dynlibOverride:ssl63+ paths that never touch the network. Fix: `--dynlibOverride:ssl
67- --dynlibOverride:crypto` plus the archives on the link line.64+ --dynlibOverride:crypto` plus the archives on the link line. nimstatic emits
65+ an override only for libraries it actually has an archive for, since an
66+ override without one turns a runtime failure into a link failure.
68 - **OpenSSL 3 removed `SSL_get_peer_certificate`,** which Nim's wrapper still67 - **OpenSSL 3 removed `SSL_get_peer_certificate`,** which Nim's wrapper still
69- references, so the link fails on one undefined symbol. The fix is68+ names, so the link dies on one undefined symbol. Fix:
70 `-DSSL_get_peer_certificate=SSL_get1_peer_certificate`.69 `-DSSL_get_peer_certificate=SSL_get1_peer_certificate`.
71 70
72-Both are emitted automatically when `libssl.a` and `libcrypto.a` are present in71+A static binary also carries no CA trust store, so set `SSL_CERT_FILE` on the
73-the sysroot. A static binary carries no CA trust store, so set `SSL_CERT_FILE`72+host that runs it.
74-to a bundle on the host that runs it.
75 73
76-Cross-compiling is the same command with `--arch`; `muslkit add -a aarch6474+## Usage
77-openssl-libs-static` and `muslkit zigcc -a aarch64` line up.
78 75
79-## Trust76+```
77+nimstatic <file.nim> [-- <nim args>] Detect, fetch, build static
78+nimstatic detect <file.nim> Show what it needs, change nothing
79+nimstatic add <pkg>... Put packages in the sysroot by hand
80+nimstatic list Show what the sysroot holds
81+nimstatic libs Show the sysroot's static libraries
82+nimstatic search <text> Search Alpine's index
83+nimstatic show <pkg> Index record for one package
84+nimstatic nimflags [-l lib] Print nim flags for the sysroot
85+nimstatic ccflags [-l lib] Print cc/clang flags
86+nimstatic nimcfg [-o file] Write a nim.cfg fragment
87+nimstatic zigcc [-o file] Write a `zig cc -target …-musl` wrapper
88+nimstatic env Shell exports (PKG_CONFIG_*, NIMSTATIC_ROOT)
89+nimstatic path Print the sysroot path
90+nimstatic clean Remove the sysroot (cache is kept)
91+```
80 92
81-Packages come over HTTPS from the mirror and are unpacked as-is. muslkit checks93+Everything after `--` goes to the Nim compiler for **both** the probe and the
82-the size recorded in the index but does **not** verify Alpine's RSA signatures —94+build, so conditional imports resolve the same way twice:
83-apk's checksum field covers the package's control segment, not the file, so a
84-real check means implementing apk's signature format. Treat a sysroot as build
85-input, not as a trust root. If that matters for your use, pin a mirror you run.
86 95
87-## Install96+```bash
97+nimstatic app.nim -- -d:ssl -d:danger
98+```
99+
100+Build options: `-o/--output`, `-d/--debug` (skip `-d:release`), `-n/--dry-run`
101+(print the command instead of running it), `--map lib=pkg`, `--pkg name`,
102+`--cc path`, `--nim path`.
103+
104+Sysroot options: `-r/--root`, `-b/--branch` (default `v3.21`, `edge` for
105+rolling), `-a/--arch`, `-m/--mirror`, `--repo main,community`, `-l/--lib`,
106+`--no-deps`, `--refresh`, `-q/--quiet`.
107+
108+The sysroot defaults to `$XDG_DATA_HOME/nimstatic/sysroot` and honors
109+`NIMSTATIC_ROOT`. Downloads cache under `$XDG_CACHE_HOME/nimstatic`, so the
110+second build is offline and the index is re-fetched once a day.
111+
112+## Cross-compiling
113+
114+Same command, one flag — the sysroot, the packages and the zig target all
115+follow `--arch`:
88 116
89 ```bash117 ```bash
90-nimble install118+nimstatic app.nim --arch aarch64 -o app-arm64
91 ```119 ```
92 120
93-or build in place with `nim c -d:ssl -o:muslkit src/muslkit.nim`. Requires121+## Requirements
94-`tar` on PATH (an `.apk` is concatenated gzip streams, which GNU tar reads) and,122+
95-for the `zigcc` helper, `zig`.123+- **zig** on PATH — used as `zig cc -target x86_64-linux-musl`. Pass `--cc` to
124+ use a musl cross-compiler you already have instead.
125+- **tar** — an `.apk` is concatenated gzip streams, which GNU tar reads.
96 126
97-## Library127+## Using it as a library
98 128
99 ```nim129 ```nim
100-import muslkit130+import nimstatic
131+
132+let d = detect("app.nim", ["-d:ssl"])
133+echo packages(d) # @["openssl-libs-static"]
134+echo dynlibOverrides(d) # @["ssl", "crypto"]
101 135
102 let remote = initRemote(branch = "edge")136 let remote = initRemote(branch = "edge")
103-let idx = remote.fetchIndex()137+for pkg in remote.fetchIndex().resolve(packages(d)):
104-for pkg in idx.resolve(["openssl-libs-static"]):
105 echo pkg.name, " ", pkg.version, " ", remote.fetchPackage(pkg)138 echo pkg.name, " ", pkg.version, " ", remote.fetchPackage(pkg)
106 ```139 ```
107 140
108-Modules: `muslkit/index` (APKINDEX parsing, provides and dependency141+Modules: `nimstatic/detect` (probe, parse, map), `nimstatic/build` (the
109-resolution), `muslkit/repo` (mirror, cache, download, unpack),142+one-command path), `nimstatic/index` (APKINDEX parsing, provides and dependency
110-`muslkit/sysroot` (manifest, static-lib discovery), `muslkit/flags` (nim/cc/143+resolution), `nimstatic/repo` (mirror, cache, download, unpack),
111-pkg-config flag emission).144+`nimstatic/sysroot` (manifest, static-lib discovery), `nimstatic/flags` (flag
145+emission).
146+
147+## Trust
148+
149+Packages come over HTTPS from the mirror and are unpacked as-is. nimstatic
150+checks the size recorded in the index but does **not** verify Alpine's RSA
151+signatures — apk's checksum field covers a package's control segment rather
152+than the file, so a real check means implementing apk's signature format. Treat
153+a sysroot as build input, not as a trust root. If that matters, pin a mirror
154+you run.
155+
156+## Install
157+
158+```bash
159+nimble install
160+```
161+
162+or build in place with `nim c -d:ssl -o:nimstatic src/nimstatic.nim`.
112 163
113 ## Tests164 ## Tests
114 165
@@ -116,5 +167,7 @@ pkg-config flag emission).
116 nimble test167 nimble test
117 ```168 ```
118 169
119-The suite is offline — index parsing, resolution through `so:`/`pkg:` provides,170+25 tests, all offline: soname parsing, link-command parsing, dynlib discovery
120-manifest round-trips and flag emission all run against fixtures.171+in generated C, package mapping, APKINDEX parsing, dependency resolution
172+through `so:`/`pkgconfig` provides, manifest round-trips and flag emission
173+(including link order and the dropped-override case).
added muslkit +0 -0
new file mode 100755
Binary files /dev/null and b/muslkit differ
new file mode 100755
Binary files /dev/null and b/muslkit differBinary files /dev/null and b/muslkit differ
deleted muslkit.nimble +0 -12
deleted file mode 100644
@@ -1,12 +0,0 @@
1-version = "0.1.0"
2-author = "nandi"
3-description = "musl-linked static libraries from Alpine, without Alpine"
4-license = "MIT"
5-srcDir = "src"
6-bin = @["muslkit"]
7-installExt = @["nim"]
8-
9-requires "nim >= 2.0.0"
10-
11-task test, "Run the test suite":
12- exec "nim c -d:ssl --hints:off -r tests/test_muslkit.nim"
deleted file mode 100644
@@ -1,12 +0,0 @@
1-version = "0.1.0"
2-author = "nandi"
3-description = "musl-linked static libraries from Alpine, without Alpine"
4-license = "MIT"
5-srcDir = "src"
6-bin = @["muslkit"]
7-installExt = @["nim"]
8-
9-requires "nim >= 2.0.0"
10-
11-task test, "Run the test suite":
12- exec "nim c -d:ssl --hints:off -r tests/test_muslkit.nim"
added nimstatic.nimble +12 -0
new file mode 100644
@@ -0,0 +1,12 @@
1+version = "0.1.0"
2+author = "nandi"
3+description = "Fully static Nim binaries, dependencies and all — detects what you link and fetches musl archives from Alpine"
4+license = "MIT"
5+srcDir = "src"
6+bin = @["nimstatic"]
7+installExt = @["nim"]
8+
9+requires "nim >= 2.0.0"
10+
11+task test, "Run the test suite":
12+ exec "nim c -d:ssl --hints:off -r tests/test_nimstatic.nim"
new file mode 100644
@@ -0,0 +1,12 @@
1+version = "0.1.0"
2+author = "nandi"
3+description = "Fully static Nim binaries, dependencies and all — detects what you link and fetches musl archives from Alpine"
4+license = "MIT"
5+srcDir = "src"
6+bin = @["nimstatic"]
7+installExt = @["nim"]
8+
9+requires "nim >= 2.0.0"
10+
11+task test, "Run the test suite":
12+ exec "nim c -d:ssl --hints:off -r tests/test_nimstatic.nim"
deleted src/muslkit.nim +0 -213
deleted file mode 100644
@@ -1,213 +0,0 @@
1-## muslkit — musl-linked static libraries from Alpine, without Alpine.
2-##
3-## muslkit add openssl-libs-static zlib-static
4-## muslkit nimflags
5-## eval "$(muslkit env)"
6-##
7-## It fetches Alpine's APKINDEX, resolves a dependency closure, downloads the
8-## .apk files (they are just tarballs) and unpacks them into a sysroot
9-## directory. Nothing is installed system-wide and nothing needs root.
10-
11-import muslkit/[flags, index, repo, sysroot]
12-export flags, index, repo, sysroot
13-
14-when isMainModule:
15- import std/[os, sequtils, strutils, tables]
16-
17- const usageText = """
18-muslkit — musl static libraries from Alpine, without Alpine
19-
20-Usage:
21- muslkit add <pkg>... Download packages and unpack into the sysroot
22- muslkit list Show what the sysroot holds
23- muslkit libs Show the static libraries in the sysroot
24- muslkit search <text> Search the index (names and descriptions)
25- muslkit show <pkg> Index record for one package
26- muslkit nimflags [-l lib] Print nim flags for a static build
27- muslkit ccflags [-l lib] Print cc/clang flags
28- muslkit nimcfg [-o file] Write a nim.cfg fragment (adds --cc when zigcc is set)
29- muslkit zigcc [-o file] Write a `zig cc -target …-musl` wrapper script
30- muslkit env Shell exports (PKG_CONFIG_*, MUSLKIT_ROOT)
31- muslkit path Print the sysroot path
32- muslkit clean Remove the sysroot (cache is kept)
33-
34-Options:
35- -r, --root <dir> Sysroot (default $XDG_DATA_HOME/muslkit/sysroot)
36- -b, --branch <ver> Alpine branch (default v3.21; `edge` for rolling)
37- -a, --arch <arch> Target arch (default x86_64)
38- -m, --mirror <url> Mirror base (default https://dl-cdn.alpinelinux.org/alpine)
39- --repo <names> Comma-separated repositories (default main,community)
40- -l, --lib <name> Add -l<name> to emitted flags (repeatable)
41- --cc <path> Compiler for `nimcfg` (e.g. the script `muslkit zigcc` writes)
42- --no-deps Do not pull dependencies
43- --refresh Re-fetch the index even if it is fresh
44- -q, --quiet No progress on stderr
45- -h, --help Show help
46-
47-Packages are downloaded over HTTPS and unpacked as-is; muslkit does not check
48-Alpine's signatures, so treat a sysroot as build input, not as a trust root.
49-
50-Examples:
51- muslkit add openssl-libs-static
52- nim c -d:ssl $(muslkit nimflags) --cc:clang --clang.exe:./zigcc app.nim
53- muslkit add --branch edge --arch aarch64 zlib-static
54-"""
55-
56- type Opts = object
57- root, mirror, branch, arch, cc, output: string
58- repos, libs, args: seq[string]
59- noDeps, refresh, quiet, help: bool
60-
61- proc defaultRoot(): string = getDataDir() / "muslkit" / "sysroot"
62-
63- proc die(msg: string) =
64- stderr.writeLine "muslkit: " & msg
65- quit(1)
66-
67- proc parseOpts(): Opts =
68- ## Hand-rolled so `--root dir`, `--root=dir` and `-r dir` all work; parseopt
69- ## only accepts the attached forms.
70- result = Opts(root: getEnv("MUSLKIT_ROOT", defaultRoot()),
71- mirror: defaultMirror, branch: defaultBranch,
72- arch: defaultArch, repos: defaultRepos)
73- let argv = commandLineParams()
74- var i = 0
75- while i < argv.len:
76- var arg = argv[i]
77- if not arg.startsWith("-") or arg == "-":
78- result.args.add arg
79- inc i
80- continue
81- var
82- key = arg.strip(leading = true, trailing = false, chars = {'-'})
83- val = ""
84- attached = false
85- for sep in ['=', ':']:
86- let at = key.find(sep)
87- if at >= 0:
88- val = key[at + 1 .. ^1]
89- key = key[0 ..< at]
90- attached = true
91- break
92-
93- proc takeValue(): string =
94- if attached: return val
95- inc i
96- if i >= argv.len: die "option --" & key & " needs a value"
97- argv[i]
98-
99- case key
100- of "r", "root": result.root = takeValue()
101- of "b", "branch": result.branch = takeValue()
102- of "a", "arch": result.arch = takeValue()
103- of "m", "mirror": result.mirror = takeValue()
104- of "repo": result.repos = takeValue().split(',').filterIt(it.len > 0)
105- of "l", "lib": result.libs.add takeValue()
106- of "o", "output": result.output = takeValue()
107- of "cc": result.cc = takeValue()
108- of "no-deps": result.noDeps = true
109- of "refresh": result.refresh = true
110- of "q", "quiet": result.quiet = true
111- of "h", "help": result.help = true
112- else: die "unknown option: " & arg
113- inc i
114-
115- proc emit(o: Opts, text: string) =
116- if o.output.len > 0:
117- createDir o.output.parentDir
118- writeFile(o.output, text)
119- if not o.quiet: stderr.writeLine "wrote " & o.output
120- else:
121- stdout.write text
122-
123- proc cmdAdd(o: Opts, remote: Remote) =
124- if o.args.len == 0: die "add: name at least one package"
125- let
126- idx = remote.fetchIndex(o.refresh)
127- wanted = idx.resolve(o.args, withDeps = not o.noDeps)
128- for pkg in wanted:
129- let archive = remote.fetchPackage(pkg)
130- unpack(archive, o.root)
131- record(o.root, wanted)
132- if not o.quiet:
133- stderr.writeLine "unpacked " & $wanted.len & " package(s) into " & o.root
134- let libs = staticLibs(o.root)
135- if libs.len > 0:
136- stderr.writeLine "static libs: " &
137- libs.mapIt(it.extractFilename).join(" ")
138-
139- proc cmdList(o: Opts) =
140- let entries = readManifest(o.root)
141- if entries.len == 0:
142- stderr.writeLine "nothing installed in " & o.root
143- return
144- for e in entries:
145- echo e.name.alignLeft(32), e.version.alignLeft(20), e.repo
146-
147- proc cmdSearch(o: Opts, remote: Remote) =
148- if o.args.len == 0: die "search: give some text"
149- let
150- idx = remote.fetchIndex(o.refresh)
151- needle = o.args.join(" ").toLowerAscii
152- var hits = 0
153- for name, pkg in idx.packages:
154- if needle in name.toLowerAscii or needle in pkg.description.toLowerAscii:
155- echo name.alignLeft(34), pkg.version.alignLeft(16), pkg.description
156- inc hits
157- if hits == 0: stderr.writeLine "no matches for " & needle
158-
159- proc cmdShow(o: Opts, remote: Remote) =
160- if o.args.len == 0: die "show: name a package"
161- let idx = remote.fetchIndex(o.refresh)
162- for want in o.args:
163- let name = idx.find(want)
164- if name.len == 0: die "no package provides '" & want & "'"
165- let pkg = idx.packages[name]
166- echo "name: ", pkg.name
167- echo "version: ", pkg.version
168- echo "repository: ", pkg.repo, "/", remote.arch
169- echo "size: ", pkg.size div 1024, " KiB"
170- echo "description: ", pkg.description
171- if pkg.depends.len > 0: echo "depends: ", pkg.depends.join(" ")
172- if pkg.provides.len > 0: echo "provides: ", pkg.provides.join(" ")
173- echo "url: ", remote.repoUrl(pkg.repo), "/", apkFile(pkg)
174-
175- proc main() =
176- let o = parseOpts()
177- if o.help or o.args.len == 0:
178- stdout.write usageText
179- quit(if o.help: 0 else: 1)
180- let
181- cmd = o.args[0]
182- rest = Opts(root: o.root, mirror: o.mirror, branch: o.branch,
183- arch: o.arch, cc: o.cc, output: o.output, repos: o.repos,
184- libs: o.libs, args: o.args[1 .. ^1], noDeps: o.noDeps,
185- refresh: o.refresh, quiet: o.quiet)
186- remote = initRemote(o.mirror, o.branch, o.arch, o.repos, quiet = o.quiet)
187- case cmd
188- of "add": cmdAdd(rest, remote)
189- of "list": cmdList(rest)
190- of "libs": stdout.write describe(rest.root)
191- of "search": cmdSearch(rest, remote)
192- of "show": cmdShow(rest, remote)
193- of "nimflags": echo nimFlags(rest.root, rest.libs).join(" ")
194- of "ccflags": echo ccFlags(rest.root, rest.libs).join(" ")
195- of "nimcfg": rest.emit nimCfg(rest.root, rest.cc, rest.libs)
196- of "zigcc":
197- let path = if rest.output.len > 0: rest.output else: "zigcc"
198- writeZigCc(path, rest.arch & "-linux-musl")
199- if not rest.quiet: stderr.writeLine "wrote " & path
200- of "env":
201- for kv in pkgConfigEnv(rest.root):
202- echo "export ", kv
203- echo "export MUSLKIT_ROOT=", rest.root
204- of "path": echo rest.root
205- of "clean":
206- removeDir rest.root
207- if not rest.quiet: stderr.writeLine "removed " & rest.root
208- else: die "unknown command: " & cmd & " (try --help)"
209-
210- try:
211- main()
212- except CatchableError as e:
213- die e.msg
deleted file mode 100644
@@ -1,213 +0,0 @@
1-## muslkit — musl-linked static libraries from Alpine, without Alpine.
2-##
3-## muslkit add openssl-libs-static zlib-static
4-## muslkit nimflags
5-## eval "$(muslkit env)"
6-##
7-## It fetches Alpine's APKINDEX, resolves a dependency closure, downloads the
8-## .apk files (they are just tarballs) and unpacks them into a sysroot
9-## directory. Nothing is installed system-wide and nothing needs root.
10-
11-import muslkit/[flags, index, repo, sysroot]
12-export flags, index, repo, sysroot
13-
14-when isMainModule:
15- import std/[os, sequtils, strutils, tables]
16-
17- const usageText = """
18-muslkit — musl static libraries from Alpine, without Alpine
19-
20-Usage:
21- muslkit add <pkg>... Download packages and unpack into the sysroot
22- muslkit list Show what the sysroot holds
23- muslkit libs Show the static libraries in the sysroot
24- muslkit search <text> Search the index (names and descriptions)
25- muslkit show <pkg> Index record for one package
26- muslkit nimflags [-l lib] Print nim flags for a static build
27- muslkit ccflags [-l lib] Print cc/clang flags
28- muslkit nimcfg [-o file] Write a nim.cfg fragment (adds --cc when zigcc is set)
29- muslkit zigcc [-o file] Write a `zig cc -target …-musl` wrapper script
30- muslkit env Shell exports (PKG_CONFIG_*, MUSLKIT_ROOT)
31- muslkit path Print the sysroot path
32- muslkit clean Remove the sysroot (cache is kept)
33-
34-Options:
35- -r, --root <dir> Sysroot (default $XDG_DATA_HOME/muslkit/sysroot)
36- -b, --branch <ver> Alpine branch (default v3.21; `edge` for rolling)
37- -a, --arch <arch> Target arch (default x86_64)
38- -m, --mirror <url> Mirror base (default https://dl-cdn.alpinelinux.org/alpine)
39- --repo <names> Comma-separated repositories (default main,community)
40- -l, --lib <name> Add -l<name> to emitted flags (repeatable)
41- --cc <path> Compiler for `nimcfg` (e.g. the script `muslkit zigcc` writes)
42- --no-deps Do not pull dependencies
43- --refresh Re-fetch the index even if it is fresh
44- -q, --quiet No progress on stderr
45- -h, --help Show help
46-
47-Packages are downloaded over HTTPS and unpacked as-is; muslkit does not check
48-Alpine's signatures, so treat a sysroot as build input, not as a trust root.
49-
50-Examples:
51- muslkit add openssl-libs-static
52- nim c -d:ssl $(muslkit nimflags) --cc:clang --clang.exe:./zigcc app.nim
53- muslkit add --branch edge --arch aarch64 zlib-static
54-"""
55-
56- type Opts = object
57- root, mirror, branch, arch, cc, output: string
58- repos, libs, args: seq[string]
59- noDeps, refresh, quiet, help: bool
60-
61- proc defaultRoot(): string = getDataDir() / "muslkit" / "sysroot"
62-
63- proc die(msg: string) =
64- stderr.writeLine "muslkit: " & msg
65- quit(1)
66-
67- proc parseOpts(): Opts =
68- ## Hand-rolled so `--root dir`, `--root=dir` and `-r dir` all work; parseopt
69- ## only accepts the attached forms.
70- result = Opts(root: getEnv("MUSLKIT_ROOT", defaultRoot()),
71- mirror: defaultMirror, branch: defaultBranch,
72- arch: defaultArch, repos: defaultRepos)
73- let argv = commandLineParams()
74- var i = 0
75- while i < argv.len:
76- var arg = argv[i]
77- if not arg.startsWith("-") or arg == "-":
78- result.args.add arg
79- inc i
80- continue
81- var
82- key = arg.strip(leading = true, trailing = false, chars = {'-'})
83- val = ""
84- attached = false
85- for sep in ['=', ':']:
86- let at = key.find(sep)
87- if at >= 0:
88- val = key[at + 1 .. ^1]
89- key = key[0 ..< at]
90- attached = true
91- break
92-
93- proc takeValue(): string =
94- if attached: return val
95- inc i
96- if i >= argv.len: die "option --" & key & " needs a value"
97- argv[i]
98-
99- case key
100- of "r", "root": result.root = takeValue()
101- of "b", "branch": result.branch = takeValue()
102- of "a", "arch": result.arch = takeValue()
103- of "m", "mirror": result.mirror = takeValue()
104- of "repo": result.repos = takeValue().split(',').filterIt(it.len > 0)
105- of "l", "lib": result.libs.add takeValue()
106- of "o", "output": result.output = takeValue()
107- of "cc": result.cc = takeValue()
108- of "no-deps": result.noDeps = true
109- of "refresh": result.refresh = true
110- of "q", "quiet": result.quiet = true
111- of "h", "help": result.help = true
112- else: die "unknown option: " & arg
113- inc i
114-
115- proc emit(o: Opts, text: string) =
116- if o.output.len > 0:
117- createDir o.output.parentDir
118- writeFile(o.output, text)
119- if not o.quiet: stderr.writeLine "wrote " & o.output
120- else:
121- stdout.write text
122-
123- proc cmdAdd(o: Opts, remote: Remote) =
124- if o.args.len == 0: die "add: name at least one package"
125- let
126- idx = remote.fetchIndex(o.refresh)
127- wanted = idx.resolve(o.args, withDeps = not o.noDeps)
128- for pkg in wanted:
129- let archive = remote.fetchPackage(pkg)
130- unpack(archive, o.root)
131- record(o.root, wanted)
132- if not o.quiet:
133- stderr.writeLine "unpacked " & $wanted.len & " package(s) into " & o.root
134- let libs = staticLibs(o.root)
135- if libs.len > 0:
136- stderr.writeLine "static libs: " &
137- libs.mapIt(it.extractFilename).join(" ")
138-
139- proc cmdList(o: Opts) =
140- let entries = readManifest(o.root)
141- if entries.len == 0:
142- stderr.writeLine "nothing installed in " & o.root
143- return
144- for e in entries:
145- echo e.name.alignLeft(32), e.version.alignLeft(20), e.repo
146-
147- proc cmdSearch(o: Opts, remote: Remote) =
148- if o.args.len == 0: die "search: give some text"
149- let
150- idx = remote.fetchIndex(o.refresh)
151- needle = o.args.join(" ").toLowerAscii
152- var hits = 0
153- for name, pkg in idx.packages:
154- if needle in name.toLowerAscii or needle in pkg.description.toLowerAscii:
155- echo name.alignLeft(34), pkg.version.alignLeft(16), pkg.description
156- inc hits
157- if hits == 0: stderr.writeLine "no matches for " & needle
158-
159- proc cmdShow(o: Opts, remote: Remote) =
160- if o.args.len == 0: die "show: name a package"
161- let idx = remote.fetchIndex(o.refresh)
162- for want in o.args:
163- let name = idx.find(want)
164- if name.len == 0: die "no package provides '" & want & "'"
165- let pkg = idx.packages[name]
166- echo "name: ", pkg.name
167- echo "version: ", pkg.version
168- echo "repository: ", pkg.repo, "/", remote.arch
169- echo "size: ", pkg.size div 1024, " KiB"
170- echo "description: ", pkg.description
171- if pkg.depends.len > 0: echo "depends: ", pkg.depends.join(" ")
172- if pkg.provides.len > 0: echo "provides: ", pkg.provides.join(" ")
173- echo "url: ", remote.repoUrl(pkg.repo), "/", apkFile(pkg)
174-
175- proc main() =
176- let o = parseOpts()
177- if o.help or o.args.len == 0:
178- stdout.write usageText
179- quit(if o.help: 0 else: 1)
180- let
181- cmd = o.args[0]
182- rest = Opts(root: o.root, mirror: o.mirror, branch: o.branch,
183- arch: o.arch, cc: o.cc, output: o.output, repos: o.repos,
184- libs: o.libs, args: o.args[1 .. ^1], noDeps: o.noDeps,
185- refresh: o.refresh, quiet: o.quiet)
186- remote = initRemote(o.mirror, o.branch, o.arch, o.repos, quiet = o.quiet)
187- case cmd
188- of "add": cmdAdd(rest, remote)
189- of "list": cmdList(rest)
190- of "libs": stdout.write describe(rest.root)
191- of "search": cmdSearch(rest, remote)
192- of "show": cmdShow(rest, remote)
193- of "nimflags": echo nimFlags(rest.root, rest.libs).join(" ")
194- of "ccflags": echo ccFlags(rest.root, rest.libs).join(" ")
195- of "nimcfg": rest.emit nimCfg(rest.root, rest.cc, rest.libs)
196- of "zigcc":
197- let path = if rest.output.len > 0: rest.output else: "zigcc"
198- writeZigCc(path, rest.arch & "-linux-musl")
199- if not rest.quiet: stderr.writeLine "wrote " & path
200- of "env":
201- for kv in pkgConfigEnv(rest.root):
202- echo "export ", kv
203- echo "export MUSLKIT_ROOT=", rest.root
204- of "path": echo rest.root
205- of "clean":
206- removeDir rest.root
207- if not rest.quiet: stderr.writeLine "removed " & rest.root
208- else: die "unknown command: " & cmd & " (try --help)"
209-
210- try:
211- main()
212- except CatchableError as e:
213- die e.msg
deleted src/muslkit/flags.nim +0 -78
deleted file mode 100644
@@ -1,78 +0,0 @@
1-## Turning a sysroot into compiler flags.
2-##
3-## Nim's OpenSSL binding is the one place a static build needs more than paths:
4-## `-d:ssl` makes Nim dlopen libssl at runtime, which a static binary cannot do,
5-## and OpenSSL 3 dropped a symbol Nim still references. Both fixes are emitted
6-## here rather than left as folklore in someone's shell history.
7-
8-import std/[os, strutils]
9-import ./sysroot
10-
11-type Toolchain* = enum
12- tcNim = "nim", tcCc = "cc", tcPkgConfig = "pkg-config"
13-
14-proc includeDir*(root: string): string = root / "usr" / "include"
15-proc libDir*(root: string): string = root / "usr" / "lib"
16-
17-proc hasLib(root, name: string): bool =
18- fileExists(libDir(root) / ("lib" & name & ".a"))
19-
20-proc nimFlags*(root: string, libs: openArray[string] = [],
21- opensslCompat = true): seq[string] =
22- result = @["--passC:-I" & includeDir(root),
23- "--passL:-L" & libDir(root),
24- "--passL:-static"]
25- if hasLib(root, "ssl") and hasLib(root, "crypto"):
26- # Stop Nim dlopen'ing OpenSSL, and link the archives instead.
27- result.add "--dynlibOverride:ssl"
28- result.add "--dynlibOverride:crypto"
29- result.add "--passL:" & libDir(root) / "libssl.a"
30- result.add "--passL:" & libDir(root) / "libcrypto.a"
31- if opensslCompat:
32- # Removed in OpenSSL 3.0; Nim's wrapper still names it.
33- result.add "--passC:-DSSL_get_peer_certificate=SSL_get1_peer_certificate"
34- for lib in libs:
35- result.add "--passL:-l" & lib
36-
37-proc ccFlags*(root: string, libs: openArray[string] = []): seq[string] =
38- result = @["-I" & includeDir(root), "-L" & libDir(root), "-static"]
39- for lib in libs:
40- result.add "-l" & lib
41-
42-proc pkgConfigEnv*(root: string): seq[string] =
43- @["PKG_CONFIG_SYSROOT_DIR=" & root,
44- "PKG_CONFIG_LIBDIR=" & libDir(root) / "pkgconfig"]
45-
46-proc nimCfg*(root, cc: string, libs: openArray[string] = []): string =
47- ## A nim.cfg fragment: cross-compiler plus every flag from `nimFlags`.
48- result = "# Generated by muslkit — static musl build against " & root &
49- "\n# Regenerate with: muslkit nimcfg --root " & root & "\n"
50- if cc.len > 0:
51- result.add "--cc:clang\n"
52- result.add "--clang.exe:\"" & cc & "\"\n"
53- result.add "--clang.linkerexe:\"" & cc & "\"\n"
54- for f in nimFlags(root, libs):
55- result.add f & "\n"
56-
57-const zigccTemplate* = """#!/bin/sh
58-# Generated by muslkit: zig as a musl cross-compiler.
59-exec zig cc -target @TARGET@ "$@"
60-"""
61-
62-proc zigccScript*(target: string): string =
63- zigccTemplate.replace("@TARGET@", target)
64-
65-proc writeZigCc*(path, target: string) =
66- writeFile(path, zigccScript(target))
67- setFilePermissions(path, {fpUserRead, fpUserWrite, fpUserExec,
68- fpGroupRead, fpGroupExec,
69- fpOthersRead, fpOthersExec})
70-
71-proc describe*(root: string): string =
72- let libs = staticLibs(root)
73- if libs.len == 0:
74- return "no static libraries in " & root
75- result = $libs.len & " static libraries in " & libDir(root) & ":\n"
76- for l in libs:
77- result.add " " & l.extractFilename & " (" &
78- $(getFileSize(l) div 1024) & " KiB)\n"
deleted file mode 100644
@@ -1,78 +0,0 @@
1-## Turning a sysroot into compiler flags.
2-##
3-## Nim's OpenSSL binding is the one place a static build needs more than paths:
4-## `-d:ssl` makes Nim dlopen libssl at runtime, which a static binary cannot do,
5-## and OpenSSL 3 dropped a symbol Nim still references. Both fixes are emitted
6-## here rather than left as folklore in someone's shell history.
7-
8-import std/[os, strutils]
9-import ./sysroot
10-
11-type Toolchain* = enum
12- tcNim = "nim", tcCc = "cc", tcPkgConfig = "pkg-config"
13-
14-proc includeDir*(root: string): string = root / "usr" / "include"
15-proc libDir*(root: string): string = root / "usr" / "lib"
16-
17-proc hasLib(root, name: string): bool =
18- fileExists(libDir(root) / ("lib" & name & ".a"))
19-
20-proc nimFlags*(root: string, libs: openArray[string] = [],
21- opensslCompat = true): seq[string] =
22- result = @["--passC:-I" & includeDir(root),
23- "--passL:-L" & libDir(root),
24- "--passL:-static"]
25- if hasLib(root, "ssl") and hasLib(root, "crypto"):
26- # Stop Nim dlopen'ing OpenSSL, and link the archives instead.
27- result.add "--dynlibOverride:ssl"
28- result.add "--dynlibOverride:crypto"
29- result.add "--passL:" & libDir(root) / "libssl.a"
30- result.add "--passL:" & libDir(root) / "libcrypto.a"
31- if opensslCompat:
32- # Removed in OpenSSL 3.0; Nim's wrapper still names it.
33- result.add "--passC:-DSSL_get_peer_certificate=SSL_get1_peer_certificate"
34- for lib in libs:
35- result.add "--passL:-l" & lib
36-
37-proc ccFlags*(root: string, libs: openArray[string] = []): seq[string] =
38- result = @["-I" & includeDir(root), "-L" & libDir(root), "-static"]
39- for lib in libs:
40- result.add "-l" & lib
41-
42-proc pkgConfigEnv*(root: string): seq[string] =
43- @["PKG_CONFIG_SYSROOT_DIR=" & root,
44- "PKG_CONFIG_LIBDIR=" & libDir(root) / "pkgconfig"]
45-
46-proc nimCfg*(root, cc: string, libs: openArray[string] = []): string =
47- ## A nim.cfg fragment: cross-compiler plus every flag from `nimFlags`.
48- result = "# Generated by muslkit — static musl build against " & root &
49- "\n# Regenerate with: muslkit nimcfg --root " & root & "\n"
50- if cc.len > 0:
51- result.add "--cc:clang\n"
52- result.add "--clang.exe:\"" & cc & "\"\n"
53- result.add "--clang.linkerexe:\"" & cc & "\"\n"
54- for f in nimFlags(root, libs):
55- result.add f & "\n"
56-
57-const zigccTemplate* = """#!/bin/sh
58-# Generated by muslkit: zig as a musl cross-compiler.
59-exec zig cc -target @TARGET@ "$@"
60-"""
61-
62-proc zigccScript*(target: string): string =
63- zigccTemplate.replace("@TARGET@", target)
64-
65-proc writeZigCc*(path, target: string) =
66- writeFile(path, zigccScript(target))
67- setFilePermissions(path, {fpUserRead, fpUserWrite, fpUserExec,
68- fpGroupRead, fpGroupExec,
69- fpOthersRead, fpOthersExec})
70-
71-proc describe*(root: string): string =
72- let libs = staticLibs(root)
73- if libs.len == 0:
74- return "no static libraries in " & root
75- result = $libs.len & " static libraries in " & libDir(root) & ":\n"
76- for l in libs:
77- result.add " " & l.extractFilename & " (" &
78- $(getFileSize(l) div 1024) & " KiB)\n"
added src/nimstatic.nim +272 -0
new file mode 100644
@@ -0,0 +1,272 @@
1+## nimstatic — fully static Nim binaries, dependencies and all.
2+##
3+## nimstatic foo.nim
4+##
5+## It asks the Nim compiler what `foo.nim` links against (including the
6+## libraries Nim would `dlopen` at runtime, which never touch the link line),
7+## fetches musl-built static archives for them from Alpine's mirrors, and
8+## compiles against a sysroot it owns. No apk, no container, no root.
9+
10+import nimstatic/[build, detect, flags, index, repo, sysroot]
11+export build, detect, flags, index, repo, sysroot
12+
13+when isMainModule:
14+ import std/[os, sequtils, strutils, tables]
15+
16+ const usageText = """
17+nimstatic — fully static Nim binaries, dependencies and all
18+
19+Usage:
20+ nimstatic <file.nim> [-- <nim args>] Detect, fetch, build static
21+ nimstatic detect <file.nim> Show what it needs, change nothing
22+ nimstatic add <pkg>... Put packages in the sysroot by hand
23+ nimstatic list Show what the sysroot holds
24+ nimstatic libs Show the sysroot's static libraries
25+ nimstatic search <text> Search Alpine's index
26+ nimstatic show <pkg> Index record for one package
27+ nimstatic nimflags [-l lib] Print nim flags for the sysroot
28+ nimstatic ccflags [-l lib] Print cc/clang flags
29+ nimstatic nimcfg [-o file] Write a nim.cfg fragment
30+ nimstatic zigcc [-o file] Write a `zig cc -target …-musl` wrapper
31+ nimstatic env Shell exports (PKG_CONFIG_*, NIMSTATIC_ROOT)
32+ nimstatic path Print the sysroot path
33+ nimstatic clean Remove the sysroot (cache is kept)
34+
35+Build options:
36+ -o, --output <path> Binary to write (default: the source's name)
37+ -d, --debug Skip -d:release
38+ -n, --dry-run Print the build command instead of running it
39+ --map <lib=pkg> Map a library to an Alpine package (repeatable)
40+ --pkg <name> Also install this package (repeatable)
41+ --cc <path> Compiler to use instead of a generated zig wrapper
42+ --nim <path> Nim executable (default: nim)
43+
44+Sysroot options:
45+ -r, --root <dir> Sysroot (default $XDG_DATA_HOME/nimstatic/sysroot)
46+ -b, --branch <ver> Alpine branch (default v3.21; `edge` for rolling)
47+ -a, --arch <arch> Target arch (default x86_64)
48+ -m, --mirror <url> Mirror base URL
49+ --repo <names> Comma-separated repositories (default main,community)
50+ -l, --lib <name> Add -l<name> to emitted flags (repeatable)
51+ --no-deps Do not pull dependencies (add only)
52+ --refresh Re-fetch the index even if it is fresh
53+ -q, --quiet No progress on stderr
54+ -h, --help Show help
55+
56+Everything after `--` goes to the Nim compiler, for both the probe and the
57+build, so conditional imports resolve the same way twice:
58+
59+ nimstatic app.nim -- -d:ssl -d:danger
60+
61+Packages come over HTTPS and are unpacked as-is; nimstatic does not verify
62+Alpine's signatures, so treat a sysroot as build input, not as a trust root.
63+"""
64+
65+ type Opts = object
66+ root, mirror, branch, arch, cc, output, nimExe: string
67+ repos, libs, args, nimArgs, pkgs: seq[string]
68+ maps: Table[string, string]
69+ noDeps, refresh, quiet, help, debug, dryRun: bool
70+
71+ proc defaultRoot(): string = getDataDir() / "nimstatic" / "sysroot"
72+
73+ proc die(msg: string) =
74+ stderr.writeLine "nimstatic: " & msg
75+ quit(1)
76+
77+ proc parseOpts(): Opts =
78+ ## Hand-rolled so `--root dir`, `--root=dir` and `-r dir` all work, and so
79+ ## everything past `--` can be handed to the compiler untouched.
80+ result = Opts(root: getEnv("NIMSTATIC_ROOT", defaultRoot()),
81+ mirror: defaultMirror, branch: defaultBranch,
82+ arch: defaultArch, repos: defaultRepos, nimExe: "nim")
83+ let argv = commandLineParams()
84+ var i = 0
85+ while i < argv.len:
86+ let arg = argv[i]
87+ if arg == "--":
88+ result.nimArgs = argv[i + 1 .. ^1]
89+ break
90+ if not arg.startsWith("-") or arg == "-":
91+ result.args.add arg
92+ inc i
93+ continue
94+ var
95+ key = arg.strip(leading = true, trailing = false, chars = {'-'})
96+ val = ""
97+ attached = false
98+ for sep in ['=', ':']:
99+ let at = key.find(sep)
100+ if at >= 0:
101+ val = key[at + 1 .. ^1]
102+ key = key[0 ..< at]
103+ attached = true
104+ break
105+
106+ proc takeValue(): string =
107+ if attached: return val
108+ inc i
109+ if i >= argv.len: die "option --" & key & " needs a value"
110+ argv[i]
111+
112+ case key
113+ of "r", "root": result.root = takeValue()
114+ of "b", "branch": result.branch = takeValue()
115+ of "a", "arch": result.arch = takeValue()
116+ of "m", "mirror": result.mirror = takeValue()
117+ of "repo": result.repos = takeValue().split(',').filterIt(it.len > 0)
118+ of "l", "lib": result.libs.add takeValue()
119+ of "o", "output": result.output = takeValue()
120+ of "cc": result.cc = takeValue()
121+ of "nim": result.nimExe = takeValue()
122+ of "pkg": result.pkgs.add takeValue()
123+ of "map":
124+ let m = takeValue().split('=', 1)
125+ if m.len != 2: die "--map wants lib=package, got: " & m.join("=")
126+ result.maps[m[0]] = m[1]
127+ of "d", "debug": result.debug = true
128+ of "n", "dry-run": result.dryRun = true
129+ of "no-deps": result.noDeps = true
130+ of "refresh": result.refresh = true
131+ of "q", "quiet": result.quiet = true
132+ of "h", "help": result.help = true
133+ else: die "unknown option: " & arg
134+ inc i
135+
136+ proc emit(o: Opts, text: string) =
137+ if o.output.len > 0:
138+ createDir o.output.parentDir
139+ writeFile(o.output, text)
140+ if not o.quiet: stderr.writeLine "wrote " & o.output
141+ else:
142+ stdout.write text
143+
144+ proc buildOpts(o: Opts): BuildOpts =
145+ BuildOpts(root: o.root, cc: o.cc, output: o.output, nimExe: o.nimExe,
146+ target: o.arch & "-linux-musl", nimArgs: o.nimArgs,
147+ extraMap: o.maps, extraPackages: o.pkgs,
148+ release: not o.debug, dryRun: o.dryRun, quiet: o.quiet)
149+
150+ proc cmdBuild(o: Opts, remote: Remote, source: string) =
151+ let res = buildOpts(o).build(remote, source)
152+ if o.dryRun:
153+ echo res.command.quoteShellCommand
154+ return
155+ if not o.quiet:
156+ let size = if fileExists(res.output): getFileSize(res.output) div 1024 else: 0
157+ stderr.writeLine "wrote " & res.output & " (" & $size & " KiB, static)"
158+
159+ proc cmdDetect(o: Opts, source: string) =
160+ let d = detect(source, o.nimArgs, o.nimExe, o.maps)
161+ if d.needs.len == 0:
162+ echo "no external libraries needed — plain --passL:-static will do"
163+ return
164+ echo "library".alignLeft(16), "how".alignLeft(9), "alpine package"
165+ for need in d.needs:
166+ echo need.lib.alignLeft(16),
167+ (if need.dynlib: "dlopen" else: "link").alignLeft(9),
168+ (if need.package.len > 0: need.package else: "(unmapped)")
169+ if packages(d).len > 0:
170+ echo "\nnimstatic add ", packages(d).join(" ")
171+ if d.unmapped.len > 0:
172+ echo "\nunmapped: ", d.unmapped.join(", "),
173+ "\nsearch for one with: nimstatic search ", d.unmapped[0]
174+
175+ proc cmdAdd(o: Opts, remote: Remote) =
176+ if o.args.len == 0: die "add: name at least one package"
177+ let pkgs = remote.fetchIndex(o.refresh).resolve(o.args, not o.noDeps)
178+ for pkg in pkgs:
179+ unpack(remote.fetchPackage(pkg), o.root)
180+ record(o.root, pkgs)
181+ if not o.quiet:
182+ stderr.writeLine "unpacked " & $pkgs.len & " package(s) into " & o.root
183+
184+ proc cmdList(o: Opts) =
185+ let entries = readManifest(o.root)
186+ if entries.len == 0:
187+ stderr.writeLine "nothing installed in " & o.root
188+ return
189+ for e in entries:
190+ echo e.name.alignLeft(32), e.version.alignLeft(20), e.repo
191+
192+ proc cmdSearch(o: Opts, remote: Remote) =
193+ if o.args.len == 0: die "search: give some text"
194+ let
195+ idx = remote.fetchIndex(o.refresh)
196+ needle = o.args.join(" ").toLowerAscii
197+ var hits = 0
198+ for name, pkg in idx.packages:
199+ if needle in name.toLowerAscii or needle in pkg.description.toLowerAscii:
200+ echo name.alignLeft(34), pkg.version.alignLeft(16), pkg.description
201+ inc hits
202+ if hits == 0: stderr.writeLine "no matches for " & needle
203+
204+ proc cmdShow(o: Opts, remote: Remote) =
205+ if o.args.len == 0: die "show: name a package"
206+ let idx = remote.fetchIndex(o.refresh)
207+ for want in o.args:
208+ let name = idx.find(want)
209+ if name.len == 0: die "no package provides '" & want & "'"
210+ let pkg = idx.packages[name]
211+ echo "name: ", pkg.name
212+ echo "version: ", pkg.version
213+ echo "repository: ", pkg.repo, "/", remote.arch
214+ echo "size: ", pkg.size div 1024, " KiB"
215+ echo "description: ", pkg.description
216+ if pkg.depends.len > 0: echo "depends: ", pkg.depends.join(" ")
217+ if pkg.provides.len > 0: echo "provides: ", pkg.provides.join(" ")
218+ echo "url: ", remote.repoUrl(pkg.repo), "/", apkFile(pkg)
219+
220+ proc main() =
221+ let o = parseOpts()
222+ if o.help or o.args.len == 0:
223+ stdout.write usageText
224+ quit(if o.help: 0 else: 1)
225+ let
226+ head = o.args[0]
227+ rest = block:
228+ var r = o
229+ r.args = o.args[1 .. ^1]
230+ r
231+ remote = initRemote(o.mirror, o.branch, o.arch, o.repos, quiet = o.quiet)
232+
233+ # A .nim path is the whole point, so it needs no subcommand.
234+ if head.endsWith(".nim") or fileExists(head):
235+ cmdBuild(o, remote, head)
236+ return
237+
238+ case head
239+ of "build":
240+ if rest.args.len == 0: die "build: name a .nim file"
241+ cmdBuild(rest, remote, rest.args[0])
242+ of "detect":
243+ if rest.args.len == 0: die "detect: name a .nim file"
244+ cmdDetect(rest, rest.args[0])
245+ of "add": cmdAdd(rest, remote)
246+ of "list": cmdList(rest)
247+ of "libs": stdout.write describe(rest.root)
248+ of "search": cmdSearch(rest, remote)
249+ of "show": cmdShow(rest, remote)
250+ of "nimflags":
251+ echo nimFlags(rest.root, overridesIn(rest.root), rest.libs).join(" ")
252+ of "ccflags": echo ccFlags(rest.root, rest.libs).join(" ")
253+ of "nimcfg":
254+ rest.emit nimCfg(rest.root, rest.cc, overridesIn(rest.root), rest.libs)
255+ of "zigcc":
256+ let path = if rest.output.len > 0: rest.output else: "zigcc"
257+ writeZigCc(path, rest.arch & "-linux-musl")
258+ if not rest.quiet: stderr.writeLine "wrote " & path
259+ of "env":
260+ for kv in pkgConfigEnv(rest.root):
261+ echo "export ", kv
262+ echo "export NIMSTATIC_ROOT=", rest.root
263+ of "path": echo rest.root
264+ of "clean":
265+ removeDir rest.root
266+ if not rest.quiet: stderr.writeLine "removed " & rest.root
267+ else: die "unknown command: " & head & " (try --help)"
268+
269+ try:
270+ main()
271+ except CatchableError as e:
272+ die e.msg
new file mode 100644
@@ -0,0 +1,272 @@
1+## nimstatic — fully static Nim binaries, dependencies and all.
2+##
3+## nimstatic foo.nim
4+##
5+## It asks the Nim compiler what `foo.nim` links against (including the
6+## libraries Nim would `dlopen` at runtime, which never touch the link line),
7+## fetches musl-built static archives for them from Alpine's mirrors, and
8+## compiles against a sysroot it owns. No apk, no container, no root.
9+
10+import nimstatic/[build, detect, flags, index, repo, sysroot]
11+export build, detect, flags, index, repo, sysroot
12+
13+when isMainModule:
14+ import std/[os, sequtils, strutils, tables]
15+
16+ const usageText = """
17+nimstatic — fully static Nim binaries, dependencies and all
18+
19+Usage:
20+ nimstatic <file.nim> [-- <nim args>] Detect, fetch, build static
21+ nimstatic detect <file.nim> Show what it needs, change nothing
22+ nimstatic add <pkg>... Put packages in the sysroot by hand
23+ nimstatic list Show what the sysroot holds
24+ nimstatic libs Show the sysroot's static libraries
25+ nimstatic search <text> Search Alpine's index
26+ nimstatic show <pkg> Index record for one package
27+ nimstatic nimflags [-l lib] Print nim flags for the sysroot
28+ nimstatic ccflags [-l lib] Print cc/clang flags
29+ nimstatic nimcfg [-o file] Write a nim.cfg fragment
30+ nimstatic zigcc [-o file] Write a `zig cc -target …-musl` wrapper
31+ nimstatic env Shell exports (PKG_CONFIG_*, NIMSTATIC_ROOT)
32+ nimstatic path Print the sysroot path
33+ nimstatic clean Remove the sysroot (cache is kept)
34+
35+Build options:
36+ -o, --output <path> Binary to write (default: the source's name)
37+ -d, --debug Skip -d:release
38+ -n, --dry-run Print the build command instead of running it
39+ --map <lib=pkg> Map a library to an Alpine package (repeatable)
40+ --pkg <name> Also install this package (repeatable)
41+ --cc <path> Compiler to use instead of a generated zig wrapper
42+ --nim <path> Nim executable (default: nim)
43+
44+Sysroot options:
45+ -r, --root <dir> Sysroot (default $XDG_DATA_HOME/nimstatic/sysroot)
46+ -b, --branch <ver> Alpine branch (default v3.21; `edge` for rolling)
47+ -a, --arch <arch> Target arch (default x86_64)
48+ -m, --mirror <url> Mirror base URL
49+ --repo <names> Comma-separated repositories (default main,community)
50+ -l, --lib <name> Add -l<name> to emitted flags (repeatable)
51+ --no-deps Do not pull dependencies (add only)
52+ --refresh Re-fetch the index even if it is fresh
53+ -q, --quiet No progress on stderr
54+ -h, --help Show help
55+
56+Everything after `--` goes to the Nim compiler, for both the probe and the
57+build, so conditional imports resolve the same way twice:
58+
59+ nimstatic app.nim -- -d:ssl -d:danger
60+
61+Packages come over HTTPS and are unpacked as-is; nimstatic does not verify
62+Alpine's signatures, so treat a sysroot as build input, not as a trust root.
63+"""
64+
65+ type Opts = object
66+ root, mirror, branch, arch, cc, output, nimExe: string
67+ repos, libs, args, nimArgs, pkgs: seq[string]
68+ maps: Table[string, string]
69+ noDeps, refresh, quiet, help, debug, dryRun: bool
70+
71+ proc defaultRoot(): string = getDataDir() / "nimstatic" / "sysroot"
72+
73+ proc die(msg: string) =
74+ stderr.writeLine "nimstatic: " & msg
75+ quit(1)
76+
77+ proc parseOpts(): Opts =
78+ ## Hand-rolled so `--root dir`, `--root=dir` and `-r dir` all work, and so
79+ ## everything past `--` can be handed to the compiler untouched.
80+ result = Opts(root: getEnv("NIMSTATIC_ROOT", defaultRoot()),
81+ mirror: defaultMirror, branch: defaultBranch,
82+ arch: defaultArch, repos: defaultRepos, nimExe: "nim")
83+ let argv = commandLineParams()
84+ var i = 0
85+ while i < argv.len:
86+ let arg = argv[i]
87+ if arg == "--":
88+ result.nimArgs = argv[i + 1 .. ^1]
89+ break
90+ if not arg.startsWith("-") or arg == "-":
91+ result.args.add arg
92+ inc i
93+ continue
94+ var
95+ key = arg.strip(leading = true, trailing = false, chars = {'-'})
96+ val = ""
97+ attached = false
98+ for sep in ['=', ':']:
99+ let at = key.find(sep)
100+ if at >= 0:
101+ val = key[at + 1 .. ^1]
102+ key = key[0 ..< at]
103+ attached = true
104+ break
105+
106+ proc takeValue(): string =
107+ if attached: return val
108+ inc i
109+ if i >= argv.len: die "option --" & key & " needs a value"
110+ argv[i]
111+
112+ case key
113+ of "r", "root": result.root = takeValue()
114+ of "b", "branch": result.branch = takeValue()
115+ of "a", "arch": result.arch = takeValue()
116+ of "m", "mirror": result.mirror = takeValue()
117+ of "repo": result.repos = takeValue().split(',').filterIt(it.len > 0)
118+ of "l", "lib": result.libs.add takeValue()
119+ of "o", "output": result.output = takeValue()
120+ of "cc": result.cc = takeValue()
121+ of "nim": result.nimExe = takeValue()
122+ of "pkg": result.pkgs.add takeValue()
123+ of "map":
124+ let m = takeValue().split('=', 1)
125+ if m.len != 2: die "--map wants lib=package, got: " & m.join("=")
126+ result.maps[m[0]] = m[1]
127+ of "d", "debug": result.debug = true
128+ of "n", "dry-run": result.dryRun = true
129+ of "no-deps": result.noDeps = true
130+ of "refresh": result.refresh = true
131+ of "q", "quiet": result.quiet = true
132+ of "h", "help": result.help = true
133+ else: die "unknown option: " & arg
134+ inc i
135+
136+ proc emit(o: Opts, text: string) =
137+ if o.output.len > 0:
138+ createDir o.output.parentDir
139+ writeFile(o.output, text)
140+ if not o.quiet: stderr.writeLine "wrote " & o.output
141+ else:
142+ stdout.write text
143+
144+ proc buildOpts(o: Opts): BuildOpts =
145+ BuildOpts(root: o.root, cc: o.cc, output: o.output, nimExe: o.nimExe,
146+ target: o.arch & "-linux-musl", nimArgs: o.nimArgs,
147+ extraMap: o.maps, extraPackages: o.pkgs,
148+ release: not o.debug, dryRun: o.dryRun, quiet: o.quiet)
149+
150+ proc cmdBuild(o: Opts, remote: Remote, source: string) =
151+ let res = buildOpts(o).build(remote, source)
152+ if o.dryRun:
153+ echo res.command.quoteShellCommand
154+ return
155+ if not o.quiet:
156+ let size = if fileExists(res.output): getFileSize(res.output) div 1024 else: 0
157+ stderr.writeLine "wrote " & res.output & " (" & $size & " KiB, static)"
158+
159+ proc cmdDetect(o: Opts, source: string) =
160+ let d = detect(source, o.nimArgs, o.nimExe, o.maps)
161+ if d.needs.len == 0:
162+ echo "no external libraries needed — plain --passL:-static will do"
163+ return
164+ echo "library".alignLeft(16), "how".alignLeft(9), "alpine package"
165+ for need in d.needs:
166+ echo need.lib.alignLeft(16),
167+ (if need.dynlib: "dlopen" else: "link").alignLeft(9),
168+ (if need.package.len > 0: need.package else: "(unmapped)")
169+ if packages(d).len > 0:
170+ echo "\nnimstatic add ", packages(d).join(" ")
171+ if d.unmapped.len > 0:
172+ echo "\nunmapped: ", d.unmapped.join(", "),
173+ "\nsearch for one with: nimstatic search ", d.unmapped[0]
174+
175+ proc cmdAdd(o: Opts, remote: Remote) =
176+ if o.args.len == 0: die "add: name at least one package"
177+ let pkgs = remote.fetchIndex(o.refresh).resolve(o.args, not o.noDeps)
178+ for pkg in pkgs:
179+ unpack(remote.fetchPackage(pkg), o.root)
180+ record(o.root, pkgs)
181+ if not o.quiet:
182+ stderr.writeLine "unpacked " & $pkgs.len & " package(s) into " & o.root
183+
184+ proc cmdList(o: Opts) =
185+ let entries = readManifest(o.root)
186+ if entries.len == 0:
187+ stderr.writeLine "nothing installed in " & o.root
188+ return
189+ for e in entries:
190+ echo e.name.alignLeft(32), e.version.alignLeft(20), e.repo
191+
192+ proc cmdSearch(o: Opts, remote: Remote) =
193+ if o.args.len == 0: die "search: give some text"
194+ let
195+ idx = remote.fetchIndex(o.refresh)
196+ needle = o.args.join(" ").toLowerAscii
197+ var hits = 0
198+ for name, pkg in idx.packages:
199+ if needle in name.toLowerAscii or needle in pkg.description.toLowerAscii:
200+ echo name.alignLeft(34), pkg.version.alignLeft(16), pkg.description
201+ inc hits
202+ if hits == 0: stderr.writeLine "no matches for " & needle
203+
204+ proc cmdShow(o: Opts, remote: Remote) =
205+ if o.args.len == 0: die "show: name a package"
206+ let idx = remote.fetchIndex(o.refresh)
207+ for want in o.args:
208+ let name = idx.find(want)
209+ if name.len == 0: die "no package provides '" & want & "'"
210+ let pkg = idx.packages[name]
211+ echo "name: ", pkg.name
212+ echo "version: ", pkg.version
213+ echo "repository: ", pkg.repo, "/", remote.arch
214+ echo "size: ", pkg.size div 1024, " KiB"
215+ echo "description: ", pkg.description
216+ if pkg.depends.len > 0: echo "depends: ", pkg.depends.join(" ")
217+ if pkg.provides.len > 0: echo "provides: ", pkg.provides.join(" ")
218+ echo "url: ", remote.repoUrl(pkg.repo), "/", apkFile(pkg)
219+
220+ proc main() =
221+ let o = parseOpts()
222+ if o.help or o.args.len == 0:
223+ stdout.write usageText
224+ quit(if o.help: 0 else: 1)
225+ let
226+ head = o.args[0]
227+ rest = block:
228+ var r = o
229+ r.args = o.args[1 .. ^1]
230+ r
231+ remote = initRemote(o.mirror, o.branch, o.arch, o.repos, quiet = o.quiet)
232+
233+ # A .nim path is the whole point, so it needs no subcommand.
234+ if head.endsWith(".nim") or fileExists(head):
235+ cmdBuild(o, remote, head)
236+ return
237+
238+ case head
239+ of "build":
240+ if rest.args.len == 0: die "build: name a .nim file"
241+ cmdBuild(rest, remote, rest.args[0])
242+ of "detect":
243+ if rest.args.len == 0: die "detect: name a .nim file"
244+ cmdDetect(rest, rest.args[0])
245+ of "add": cmdAdd(rest, remote)
246+ of "list": cmdList(rest)
247+ of "libs": stdout.write describe(rest.root)
248+ of "search": cmdSearch(rest, remote)
249+ of "show": cmdShow(rest, remote)
250+ of "nimflags":
251+ echo nimFlags(rest.root, overridesIn(rest.root), rest.libs).join(" ")
252+ of "ccflags": echo ccFlags(rest.root, rest.libs).join(" ")
253+ of "nimcfg":
254+ rest.emit nimCfg(rest.root, rest.cc, overridesIn(rest.root), rest.libs)
255+ of "zigcc":
256+ let path = if rest.output.len > 0: rest.output else: "zigcc"
257+ writeZigCc(path, rest.arch & "-linux-musl")
258+ if not rest.quiet: stderr.writeLine "wrote " & path
259+ of "env":
260+ for kv in pkgConfigEnv(rest.root):
261+ echo "export ", kv
262+ echo "export NIMSTATIC_ROOT=", rest.root
263+ of "path": echo rest.root
264+ of "clean":
265+ removeDir rest.root
266+ if not rest.quiet: stderr.writeLine "removed " & rest.root
267+ else: die "unknown command: " & head & " (try --help)"
268+
269+ try:
270+ main()
271+ except CatchableError as e:
272+ die e.msg
added src/nimstatic/build.nim +90 -0
new file mode 100644
@@ -0,0 +1,90 @@
1+## The one-command path: source file in, static binary out.
2+##
3+## detect → fetch what it named → compile against the sysroot.
4+
5+import std/[os, osproc, sequtils, strutils, tables]
6+import ./detect, ./flags, ./index, ./repo, ./sysroot
7+
8+type
9+ BuildOpts* = object
10+ root*, cc*, output*, nimExe*, target*: string
11+ nimArgs*: seq[string]
12+ extraMap*: Table[string, string]
13+ extraPackages*: seq[string]
14+ release*, dryRun*, quiet*: bool
15+
16+ BuildResult* = object
17+ detection*: Detection
18+ fetched*: seq[string]
19+ command*: seq[string]
20+ output*: string
21+
22+proc note(o: BuildOpts, msg: string) =
23+ if not o.quiet: stderr.writeLine msg
24+
25+proc ensurePackages*(o: BuildOpts, remote: Remote, wanted: seq[string]): seq[string] =
26+ ## Install whatever the sysroot is missing. Already-present packages are not
27+ ## re-downloaded, so a second build is offline.
28+ let missing = wanted.filterIt(not installed(o.root, it))
29+ if missing.len == 0: return
30+ let
31+ idx = remote.fetchIndex()
32+ pkgs = idx.resolve(missing)
33+ for pkg in pkgs:
34+ unpack(remote.fetchPackage(pkg), o.root)
35+ record(o.root, pkgs)
36+ pkgs.mapIt(it.name)
37+
38+proc resolveCc*(o: BuildOpts): string =
39+ ## An explicit --cc wins; otherwise generate a zig wrapper beside the sysroot.
40+ if o.cc.len > 0: return o.cc
41+ if findExe("zig").len == 0:
42+ raise newException(OSError,
43+ "no C compiler for musl: install zig (used as `zig cc -target " &
44+ o.target & "`) or pass --cc with a musl-targeting compiler")
45+ let path = o.root.parentDir / ("zigcc-" & o.target)
46+ writeZigCc(path, o.target)
47+ path
48+
49+proc buildCommand*(o: BuildOpts, source: string, detection: Detection,
50+ cc: string): seq[string] =
51+ result = @[o.nimExe, "c"]
52+ if o.release: result.add "-d:release"
53+ result.add o.nimArgs
54+ result.add ["--cc:clang", "--clang.exe:" & cc, "--clang.linkerexe:" & cc]
55+ result.add nimFlags(o.root, dynlibOverrides(detection))
56+ if o.output.len > 0:
57+ result.add "-o:" & o.output
58+ result.add source
59+
60+proc build*(o: BuildOpts, remote: Remote, source: string): BuildResult =
61+ if not fileExists(source):
62+ raise newException(IOError, "no such file: " & source)
63+
64+ o.note "probing " & source.extractFilename & ""
65+ result.detection = detect(source, o.nimArgs, o.nimExe, o.extraMap)
66+ let det = result.detection
67+
68+ if det.needs.len == 0:
69+ o.note "no external libraries needed"
70+ else:
71+ for need in det.needs:
72+ let how = if need.dynlib: "dlopen" else: "link"
73+ o.note " " & need.lib.alignLeft(14) & how.alignLeft(8) &
74+ (if need.package.len > 0: need.package else: "UNMAPPED")
75+ if det.unmapped.len > 0:
76+ o.note "warning: no Alpine package known for: " & det.unmapped.join(", ") &
77+ "\n map it with --map " & det.unmapped[0] & "=<package>" &
78+ ", or the link will fail"
79+
80+ result.fetched = ensurePackages(o, remote, packages(det) & o.extraPackages)
81+ let cc = resolveCc(o)
82+ result.command = buildCommand(o, source, det, cc)
83+ result.output = if o.output.len > 0: o.output
84+ else: source.changeFileExt("")
85+
86+ if o.dryRun:
87+ return
88+ o.note "building …"
89+ if execCmd(result.command.quoteShellCommand) != 0:
90+ raise newException(OSError, "the static build failed")
new file mode 100644
@@ -0,0 +1,90 @@
1+## The one-command path: source file in, static binary out.
2+##
3+## detect → fetch what it named → compile against the sysroot.
4+
5+import std/[os, osproc, sequtils, strutils, tables]
6+import ./detect, ./flags, ./index, ./repo, ./sysroot
7+
8+type
9+ BuildOpts* = object
10+ root*, cc*, output*, nimExe*, target*: string
11+ nimArgs*: seq[string]
12+ extraMap*: Table[string, string]
13+ extraPackages*: seq[string]
14+ release*, dryRun*, quiet*: bool
15+
16+ BuildResult* = object
17+ detection*: Detection
18+ fetched*: seq[string]
19+ command*: seq[string]
20+ output*: string
21+
22+proc note(o: BuildOpts, msg: string) =
23+ if not o.quiet: stderr.writeLine msg
24+
25+proc ensurePackages*(o: BuildOpts, remote: Remote, wanted: seq[string]): seq[string] =
26+ ## Install whatever the sysroot is missing. Already-present packages are not
27+ ## re-downloaded, so a second build is offline.
28+ let missing = wanted.filterIt(not installed(o.root, it))
29+ if missing.len == 0: return
30+ let
31+ idx = remote.fetchIndex()
32+ pkgs = idx.resolve(missing)
33+ for pkg in pkgs:
34+ unpack(remote.fetchPackage(pkg), o.root)
35+ record(o.root, pkgs)
36+ pkgs.mapIt(it.name)
37+
38+proc resolveCc*(o: BuildOpts): string =
39+ ## An explicit --cc wins; otherwise generate a zig wrapper beside the sysroot.
40+ if o.cc.len > 0: return o.cc
41+ if findExe("zig").len == 0:
42+ raise newException(OSError,
43+ "no C compiler for musl: install zig (used as `zig cc -target " &
44+ o.target & "`) or pass --cc with a musl-targeting compiler")
45+ let path = o.root.parentDir / ("zigcc-" & o.target)
46+ writeZigCc(path, o.target)
47+ path
48+
49+proc buildCommand*(o: BuildOpts, source: string, detection: Detection,
50+ cc: string): seq[string] =
51+ result = @[o.nimExe, "c"]
52+ if o.release: result.add "-d:release"
53+ result.add o.nimArgs
54+ result.add ["--cc:clang", "--clang.exe:" & cc, "--clang.linkerexe:" & cc]
55+ result.add nimFlags(o.root, dynlibOverrides(detection))
56+ if o.output.len > 0:
57+ result.add "-o:" & o.output
58+ result.add source
59+
60+proc build*(o: BuildOpts, remote: Remote, source: string): BuildResult =
61+ if not fileExists(source):
62+ raise newException(IOError, "no such file: " & source)
63+
64+ o.note "probing " & source.extractFilename & ""
65+ result.detection = detect(source, o.nimArgs, o.nimExe, o.extraMap)
66+ let det = result.detection
67+
68+ if det.needs.len == 0:
69+ o.note "no external libraries needed"
70+ else:
71+ for need in det.needs:
72+ let how = if need.dynlib: "dlopen" else: "link"
73+ o.note " " & need.lib.alignLeft(14) & how.alignLeft(8) &
74+ (if need.package.len > 0: need.package else: "UNMAPPED")
75+ if det.unmapped.len > 0:
76+ o.note "warning: no Alpine package known for: " & det.unmapped.join(", ") &
77+ "\n map it with --map " & det.unmapped[0] & "=<package>" &
78+ ", or the link will fail"
79+
80+ result.fetched = ensurePackages(o, remote, packages(det) & o.extraPackages)
81+ let cc = resolveCc(o)
82+ result.command = buildCommand(o, source, det, cc)
83+ result.output = if o.output.len > 0: o.output
84+ else: source.changeFileExt("")
85+
86+ if o.dryRun:
87+ return
88+ o.note "building …"
89+ if execCmd(result.command.quoteShellCommand) != 0:
90+ raise newException(OSError, "the static build failed")
added src/nimstatic/detect.nim +155 -0
new file mode 100644
@@ -0,0 +1,155 @@
1+## Working out what a Nim program actually links against.
2+##
3+## Guessing from `import` lines would be wrong in both directions: a transitive
4+## import three modules deep still needs its library, and an import behind a
5+## `when` that never fires does not. So instead we ask the compiler — a
6+## `--compileOnly --genScript` probe writes a nimcache the real build would have
7+## produced, and that cache states the answer twice over:
8+##
9+## * `<project>.json` carries the link command, so every `-lfoo` is explicit.
10+## * The generated C contains the dynlib candidate strings Nim will `dlopen`
11+## at runtime — `"libssl.so(.3|.1.1|…)"` — which never reach the link line
12+## at all, and are exactly what breaks a static binary.
13+
14+import std/[json, os, osproc, sequtils, sets, strutils, tables]
15+
16+type
17+ Need* = object
18+ lib*: string ## base name: `ssl`, `crypto`, `pcre`
19+ dynlib*: bool ## dlopen'd at runtime rather than linked
20+ package*: string ## Alpine package, "" when unmapped
21+
22+ Detection* = object
23+ needs*: seq[Need]
24+ unmapped*: seq[string]
25+ nimcache*: string
26+
27+const libcProvided* = ["c", "m", "rt", "dl", "pthread", "util", "resolv", "crypt"]
28+ ## Linked by name on glibc, but part of musl itself — asking Alpine for a
29+ ## package would find nothing, and none is needed.
30+
31+const packageFor* = {
32+ "ssl": "openssl-libs-static",
33+ "crypto": "openssl-libs-static",
34+ "z": "zlib-static",
35+ "bz2": "bzip2-static",
36+ "lzma": "xz-static",
37+ "zstd": "zstd-static",
38+ "pcre": "pcre-dev", # Alpine keeps pcre's .a in -dev
39+ "pcre2-8": "pcre2-dev",
40+ "sqlite3": "sqlite-static",
41+ "curl": "curl-static",
42+ "ffi": "libffi-dev",
43+ "readline": "readline-static",
44+ "ncurses": "ncurses-static",
45+ "ncursesw": "ncurses-static",
46+ "expat": "expat-static",
47+ "xml2": "libxml2-dev",
48+ "yaml": "yaml-static",
49+ "pq": "libpq-dev",
50+ "mysqlclient": "mariadb-dev",
51+ "gmp": "gmp-dev",
52+ "sodium": "libsodium-static",
53+ "uv": "libuv-static",
54+ "brotlidec": "brotli-static",
55+ "brotlienc": "brotli-static",
56+}.toTable
57+
58+func libFromSoname*(soname: string): string =
59+ ## `libssl.so.3` → `ssl`; `libpcre2-8.so(.0|)` → `pcre2-8`.
60+ var s = soname
61+ if not s.startsWith("lib"): return ""
62+ s = s[3 .. ^1]
63+ let dot = s.find(".so")
64+ if dot < 0: return ""
65+ s[0 ..< dot]
66+
67+proc linkLibs*(linkcmd: string): seq[string] =
68+ ## Every `-lfoo` on the link line, deduplicated, order preserved.
69+ var seen: HashSet[string]
70+ for token in linkcmd.splitWhitespace():
71+ if token.startsWith("-l") and token.len > 2:
72+ let lib = token[2 .. ^1]
73+ if lib notin seen:
74+ seen.incl lib
75+ result.add lib
76+
77+proc dynlibsIn*(text: string): seq[string] =
78+ ## Base library names from the dynlib candidate strings Nim emits into C.
79+ ## One `loadLib` site spells out every soname it would try, so the same
80+ ## library shows up many times; we want it once.
81+ var seen: HashSet[string]
82+ var i = 0
83+ while true:
84+ let start = text.find("\"lib", i)
85+ if start < 0: break
86+ let stop = text.find('"', start + 1)
87+ if stop < 0: break
88+ let lib = libFromSoname(text[start + 1 ..< stop])
89+ if lib.len > 0 and lib notin seen:
90+ seen.incl lib
91+ result.add lib
92+ i = stop + 1
93+
94+proc probe*(source: string, nimArgs: openArray[string] = [],
95+ nimExe = "nim", cacheDir = ""): string =
96+ ## Run the compile-only probe; returns the nimcache directory.
97+ let cache = if cacheDir.len > 0: cacheDir
98+ else: getTempDir() / "nimstatic-probe" / source.extractFilename
99+ removeDir cache
100+ createDir cache
101+ var cmd = @[nimExe, "c", "--compileOnly", "--genScript",
102+ "--nimcache:" & cache, "--hints:off"]
103+ for a in nimArgs: cmd.add a
104+ cmd.add source
105+ let (output, code) = execCmdEx(cmd.quoteShellCommand)
106+ if code != 0:
107+ raise newException(OSError, "the probe compile failed:\n" & output.strip())
108+ cache
109+
110+proc inspect*(cache: string, extraMap: Table[string, string] = initTable[string, string]()): Detection =
111+ ## Read a nimcache and say which libraries the program needs.
112+ result.nimcache = cache
113+ var jsonFile = ""
114+ for path in walkFiles(cache / "*.json"):
115+ jsonFile = path
116+ break
117+ if jsonFile.len == 0:
118+ raise newException(IOError, "no build json in " & cache &
119+ " — did the probe compile run?")
120+
121+ var linked: seq[string]
122+ let data = parseFile(jsonFile)
123+ if data.hasKey("linkcmd"):
124+ linked = linkLibs(data["linkcmd"].getStr)
125+
126+ var dynamic: seq[string]
127+ for path in walkFiles(cache / "*.c"):
128+ for lib in dynlibsIn(readFile(path)):
129+ if lib notin dynamic:
130+ dynamic.add lib
131+
132+ for (libs, isDyn) in [(linked, false), (dynamic, true)]:
133+ for lib in libs:
134+ if lib in libcProvided: continue
135+ if result.needs.anyIt(it.lib == lib): continue
136+ let pkg = if lib in extraMap: extraMap[lib]
137+ else: packageFor.getOrDefault(lib, "")
138+ result.needs.add Need(lib: lib, dynlib: isDyn, package: pkg)
139+ if pkg.len == 0 and lib notin result.unmapped:
140+ result.unmapped.add lib
141+
142+proc packages*(d: Detection): seq[string] =
143+ for need in d.needs:
144+ if need.package.len > 0 and need.package notin result:
145+ result.add need.package
146+
147+proc dynlibOverrides*(d: Detection): seq[string] =
148+ ## Libraries Nim would dlopen — each needs --dynlibOverride to link instead.
149+ for need in d.needs:
150+ if need.dynlib and need.package.len > 0:
151+ result.add need.lib
152+
153+proc detect*(source: string, nimArgs: openArray[string] = [],
154+ nimExe = "nim", extraMap = initTable[string, string]()): Detection =
155+ inspect(probe(source, nimArgs, nimExe), extraMap)
new file mode 100644
@@ -0,0 +1,155 @@
1+## Working out what a Nim program actually links against.
2+##
3+## Guessing from `import` lines would be wrong in both directions: a transitive
4+## import three modules deep still needs its library, and an import behind a
5+## `when` that never fires does not. So instead we ask the compiler — a
6+## `--compileOnly --genScript` probe writes a nimcache the real build would have
7+## produced, and that cache states the answer twice over:
8+##
9+## * `<project>.json` carries the link command, so every `-lfoo` is explicit.
10+## * The generated C contains the dynlib candidate strings Nim will `dlopen`
11+## at runtime — `"libssl.so(.3|.1.1|…)"` — which never reach the link line
12+## at all, and are exactly what breaks a static binary.
13+
14+import std/[json, os, osproc, sequtils, sets, strutils, tables]
15+
16+type
17+ Need* = object
18+ lib*: string ## base name: `ssl`, `crypto`, `pcre`
19+ dynlib*: bool ## dlopen'd at runtime rather than linked
20+ package*: string ## Alpine package, "" when unmapped
21+
22+ Detection* = object
23+ needs*: seq[Need]
24+ unmapped*: seq[string]
25+ nimcache*: string
26+
27+const libcProvided* = ["c", "m", "rt", "dl", "pthread", "util", "resolv", "crypt"]
28+ ## Linked by name on glibc, but part of musl itself — asking Alpine for a
29+ ## package would find nothing, and none is needed.
30+
31+const packageFor* = {
32+ "ssl": "openssl-libs-static",
33+ "crypto": "openssl-libs-static",
34+ "z": "zlib-static",
35+ "bz2": "bzip2-static",
36+ "lzma": "xz-static",
37+ "zstd": "zstd-static",
38+ "pcre": "pcre-dev", # Alpine keeps pcre's .a in -dev
39+ "pcre2-8": "pcre2-dev",
40+ "sqlite3": "sqlite-static",
41+ "curl": "curl-static",
42+ "ffi": "libffi-dev",
43+ "readline": "readline-static",
44+ "ncurses": "ncurses-static",
45+ "ncursesw": "ncurses-static",
46+ "expat": "expat-static",
47+ "xml2": "libxml2-dev",
48+ "yaml": "yaml-static",
49+ "pq": "libpq-dev",
50+ "mysqlclient": "mariadb-dev",
51+ "gmp": "gmp-dev",
52+ "sodium": "libsodium-static",
53+ "uv": "libuv-static",
54+ "brotlidec": "brotli-static",
55+ "brotlienc": "brotli-static",
56+}.toTable
57+
58+func libFromSoname*(soname: string): string =
59+ ## `libssl.so.3` → `ssl`; `libpcre2-8.so(.0|)` → `pcre2-8`.
60+ var s = soname
61+ if not s.startsWith("lib"): return ""
62+ s = s[3 .. ^1]
63+ let dot = s.find(".so")
64+ if dot < 0: return ""
65+ s[0 ..< dot]
66+
67+proc linkLibs*(linkcmd: string): seq[string] =
68+ ## Every `-lfoo` on the link line, deduplicated, order preserved.
69+ var seen: HashSet[string]
70+ for token in linkcmd.splitWhitespace():
71+ if token.startsWith("-l") and token.len > 2:
72+ let lib = token[2 .. ^1]
73+ if lib notin seen:
74+ seen.incl lib
75+ result.add lib
76+
77+proc dynlibsIn*(text: string): seq[string] =
78+ ## Base library names from the dynlib candidate strings Nim emits into C.
79+ ## One `loadLib` site spells out every soname it would try, so the same
80+ ## library shows up many times; we want it once.
81+ var seen: HashSet[string]
82+ var i = 0
83+ while true:
84+ let start = text.find("\"lib", i)
85+ if start < 0: break
86+ let stop = text.find('"', start + 1)
87+ if stop < 0: break
88+ let lib = libFromSoname(text[start + 1 ..< stop])
89+ if lib.len > 0 and lib notin seen:
90+ seen.incl lib
91+ result.add lib
92+ i = stop + 1
93+
94+proc probe*(source: string, nimArgs: openArray[string] = [],
95+ nimExe = "nim", cacheDir = ""): string =
96+ ## Run the compile-only probe; returns the nimcache directory.
97+ let cache = if cacheDir.len > 0: cacheDir
98+ else: getTempDir() / "nimstatic-probe" / source.extractFilename
99+ removeDir cache
100+ createDir cache
101+ var cmd = @[nimExe, "c", "--compileOnly", "--genScript",
102+ "--nimcache:" & cache, "--hints:off"]
103+ for a in nimArgs: cmd.add a
104+ cmd.add source
105+ let (output, code) = execCmdEx(cmd.quoteShellCommand)
106+ if code != 0:
107+ raise newException(OSError, "the probe compile failed:\n" & output.strip())
108+ cache
109+
110+proc inspect*(cache: string, extraMap: Table[string, string] = initTable[string, string]()): Detection =
111+ ## Read a nimcache and say which libraries the program needs.
112+ result.nimcache = cache
113+ var jsonFile = ""
114+ for path in walkFiles(cache / "*.json"):
115+ jsonFile = path
116+ break
117+ if jsonFile.len == 0:
118+ raise newException(IOError, "no build json in " & cache &
119+ " — did the probe compile run?")
120+
121+ var linked: seq[string]
122+ let data = parseFile(jsonFile)
123+ if data.hasKey("linkcmd"):
124+ linked = linkLibs(data["linkcmd"].getStr)
125+
126+ var dynamic: seq[string]
127+ for path in walkFiles(cache / "*.c"):
128+ for lib in dynlibsIn(readFile(path)):
129+ if lib notin dynamic:
130+ dynamic.add lib
131+
132+ for (libs, isDyn) in [(linked, false), (dynamic, true)]:
133+ for lib in libs:
134+ if lib in libcProvided: continue
135+ if result.needs.anyIt(it.lib == lib): continue
136+ let pkg = if lib in extraMap: extraMap[lib]
137+ else: packageFor.getOrDefault(lib, "")
138+ result.needs.add Need(lib: lib, dynlib: isDyn, package: pkg)
139+ if pkg.len == 0 and lib notin result.unmapped:
140+ result.unmapped.add lib
141+
142+proc packages*(d: Detection): seq[string] =
143+ for need in d.needs:
144+ if need.package.len > 0 and need.package notin result:
145+ result.add need.package
146+
147+proc dynlibOverrides*(d: Detection): seq[string] =
148+ ## Libraries Nim would dlopen — each needs --dynlibOverride to link instead.
149+ for need in d.needs:
150+ if need.dynlib and need.package.len > 0:
151+ result.add need.lib
152+
153+proc detect*(source: string, nimArgs: openArray[string] = [],
154+ nimExe = "nim", extraMap = initTable[string, string]()): Detection =
155+ inspect(probe(source, nimArgs, nimExe), extraMap)
added src/nimstatic/flags.nim +95 -0
new file mode 100644
@@ -0,0 +1,95 @@
1+## Turning a sysroot plus a detection into compiler flags.
2+##
3+## Two things beyond paths are needed for a static Nim binary that talks TLS,
4+## and both are silent failures otherwise:
5+##
6+## * `-d:ssl` (and any other dynlib binding) makes Nim `dlopen` the library at
7+## runtime. A static binary cannot, and dies at startup — even on code paths
8+## that never use it. `--dynlibOverride:<lib>` plus the archive on the link
9+## line is the fix.
10+## * OpenSSL 3 removed `SSL_get_peer_certificate`, which Nim's wrapper still
11+## names, so the link fails on one undefined symbol.
12+
13+import std/[algorithm, os, sequtils, strutils]
14+import ./sysroot
15+
16+proc includeDir*(root: string): string = root / "usr" / "include"
17+proc libDir*(root: string): string = root / "usr" / "lib"
18+
19+proc archive*(root, lib: string): string = libDir(root) / ("lib" & lib & ".a")
20+proc hasLib*(root, lib: string): bool = fileExists(archive(root, lib))
21+
22+func linkOrder(libs: seq[string]): seq[string] =
23+ ## A static linker resolves left to right, so a dependent archive has to come
24+ ## before the one it draws from: libssl needs libcrypto, not the other way.
25+ const first = ["ssl", "crypto"]
26+ result = libs.filterIt(it in first)
27+ result.sort(proc (a, b: string): int = cmp(first.find(a), first.find(b)))
28+ result.add libs.filterIt(it notin first).sorted()
29+
30+proc overridesIn*(root: string): seq[string] =
31+ ## Fallback when nothing was detected: assume archives present in the sysroot
32+ ## are there to be linked.
33+ for lib in ["ssl", "crypto"]:
34+ if hasLib(root, lib): result.add lib
35+
36+proc nimFlags*(root: string, overrides: openArray[string] = [],
37+ libs: openArray[string] = [], opensslCompat = true): seq[string] =
38+ result = @["--passC:-I" & includeDir(root),
39+ "--passL:-L" & libDir(root),
40+ "--passL:-static"]
41+ let present = linkOrder(overrides.toSeq.filterIt(hasLib(root, it)))
42+ for lib in present:
43+ result.add "--dynlibOverride:" & lib
44+ for lib in present:
45+ result.add "--passL:" & archive(root, lib)
46+ if opensslCompat and "ssl" in present:
47+ # Removed in OpenSSL 3.0; Nim's wrapper still names it.
48+ result.add "--passC:-DSSL_get_peer_certificate=SSL_get1_peer_certificate"
49+ for lib in libs:
50+ result.add "--passL:-l" & lib
51+
52+proc ccFlags*(root: string, libs: openArray[string] = []): seq[string] =
53+ result = @["-I" & includeDir(root), "-L" & libDir(root), "-static"]
54+ for lib in libs:
55+ result.add "-l" & lib
56+
57+proc pkgConfigEnv*(root: string): seq[string] =
58+ @["PKG_CONFIG_SYSROOT_DIR=" & root,
59+ "PKG_CONFIG_LIBDIR=" & libDir(root) / "pkgconfig"]
60+
61+proc nimCfg*(root, cc: string, overrides: openArray[string] = [],
62+ libs: openArray[string] = []): string =
63+ ## A nim.cfg fragment: cross-compiler plus every flag from `nimFlags`.
64+ result = "# Generated by nimstatic — static musl build against " & root &
65+ "\n# Regenerate with: nimstatic nimcfg --root " & root & "\n"
66+ if cc.len > 0:
67+ result.add "--cc:clang\n"
68+ result.add "--clang.exe:\"" & cc & "\"\n"
69+ result.add "--clang.linkerexe:\"" & cc & "\"\n"
70+ for f in nimFlags(root, overrides, libs):
71+ result.add f & "\n"
72+
73+const zigccTemplate* = """#!/bin/sh
74+# Generated by nimstatic: zig as a musl cross-compiler.
75+exec zig cc -target @TARGET@ "$@"
76+"""
77+
78+proc zigccScript*(target: string): string =
79+ zigccTemplate.replace("@TARGET@", target)
80+
81+proc writeZigCc*(path, target: string) =
82+ createDir path.parentDir
83+ writeFile(path, zigccScript(target))
84+ setFilePermissions(path, {fpUserRead, fpUserWrite, fpUserExec,
85+ fpGroupRead, fpGroupExec,
86+ fpOthersRead, fpOthersExec})
87+
88+proc describe*(root: string): string =
89+ let libs = staticLibs(root)
90+ if libs.len == 0:
91+ return "no static libraries in " & root & "\n"
92+ result = $libs.len & " static libraries in " & libDir(root) & ":\n"
93+ for l in libs:
94+ result.add " " & l.extractFilename & " (" &
95+ $(getFileSize(l) div 1024) & " KiB)\n"
new file mode 100644
@@ -0,0 +1,95 @@
1+## Turning a sysroot plus a detection into compiler flags.
2+##
3+## Two things beyond paths are needed for a static Nim binary that talks TLS,
4+## and both are silent failures otherwise:
5+##
6+## * `-d:ssl` (and any other dynlib binding) makes Nim `dlopen` the library at
7+## runtime. A static binary cannot, and dies at startup — even on code paths
8+## that never use it. `--dynlibOverride:<lib>` plus the archive on the link
9+## line is the fix.
10+## * OpenSSL 3 removed `SSL_get_peer_certificate`, which Nim's wrapper still
11+## names, so the link fails on one undefined symbol.
12+
13+import std/[algorithm, os, sequtils, strutils]
14+import ./sysroot
15+
16+proc includeDir*(root: string): string = root / "usr" / "include"
17+proc libDir*(root: string): string = root / "usr" / "lib"
18+
19+proc archive*(root, lib: string): string = libDir(root) / ("lib" & lib & ".a")
20+proc hasLib*(root, lib: string): bool = fileExists(archive(root, lib))
21+
22+func linkOrder(libs: seq[string]): seq[string] =
23+ ## A static linker resolves left to right, so a dependent archive has to come
24+ ## before the one it draws from: libssl needs libcrypto, not the other way.
25+ const first = ["ssl", "crypto"]
26+ result = libs.filterIt(it in first)
27+ result.sort(proc (a, b: string): int = cmp(first.find(a), first.find(b)))
28+ result.add libs.filterIt(it notin first).sorted()
29+
30+proc overridesIn*(root: string): seq[string] =
31+ ## Fallback when nothing was detected: assume archives present in the sysroot
32+ ## are there to be linked.
33+ for lib in ["ssl", "crypto"]:
34+ if hasLib(root, lib): result.add lib
35+
36+proc nimFlags*(root: string, overrides: openArray[string] = [],
37+ libs: openArray[string] = [], opensslCompat = true): seq[string] =
38+ result = @["--passC:-I" & includeDir(root),
39+ "--passL:-L" & libDir(root),
40+ "--passL:-static"]
41+ let present = linkOrder(overrides.toSeq.filterIt(hasLib(root, it)))
42+ for lib in present:
43+ result.add "--dynlibOverride:" & lib
44+ for lib in present:
45+ result.add "--passL:" & archive(root, lib)
46+ if opensslCompat and "ssl" in present:
47+ # Removed in OpenSSL 3.0; Nim's wrapper still names it.
48+ result.add "--passC:-DSSL_get_peer_certificate=SSL_get1_peer_certificate"
49+ for lib in libs:
50+ result.add "--passL:-l" & lib
51+
52+proc ccFlags*(root: string, libs: openArray[string] = []): seq[string] =
53+ result = @["-I" & includeDir(root), "-L" & libDir(root), "-static"]
54+ for lib in libs:
55+ result.add "-l" & lib
56+
57+proc pkgConfigEnv*(root: string): seq[string] =
58+ @["PKG_CONFIG_SYSROOT_DIR=" & root,
59+ "PKG_CONFIG_LIBDIR=" & libDir(root) / "pkgconfig"]
60+
61+proc nimCfg*(root, cc: string, overrides: openArray[string] = [],
62+ libs: openArray[string] = []): string =
63+ ## A nim.cfg fragment: cross-compiler plus every flag from `nimFlags`.
64+ result = "# Generated by nimstatic — static musl build against " & root &
65+ "\n# Regenerate with: nimstatic nimcfg --root " & root & "\n"
66+ if cc.len > 0:
67+ result.add "--cc:clang\n"
68+ result.add "--clang.exe:\"" & cc & "\"\n"
69+ result.add "--clang.linkerexe:\"" & cc & "\"\n"
70+ for f in nimFlags(root, overrides, libs):
71+ result.add f & "\n"
72+
73+const zigccTemplate* = """#!/bin/sh
74+# Generated by nimstatic: zig as a musl cross-compiler.
75+exec zig cc -target @TARGET@ "$@"
76+"""
77+
78+proc zigccScript*(target: string): string =
79+ zigccTemplate.replace("@TARGET@", target)
80+
81+proc writeZigCc*(path, target: string) =
82+ createDir path.parentDir
83+ writeFile(path, zigccScript(target))
84+ setFilePermissions(path, {fpUserRead, fpUserWrite, fpUserExec,
85+ fpGroupRead, fpGroupExec,
86+ fpOthersRead, fpOthersExec})
87+
88+proc describe*(root: string): string =
89+ let libs = staticLibs(root)
90+ if libs.len == 0:
91+ return "no static libraries in " & root & "\n"
92+ result = $libs.len & " static libraries in " & libDir(root) & ":\n"
93+ for l in libs:
94+ result.add " " & l.extractFilename & " (" &
95+ $(getFileSize(l) div 1024) & " KiB)\n"
renamed src/nimstatic/index.nim +0 -0
similarity index 100%
rename from src/muslkit/index.nim
rename to src/nimstatic/index.nim
similarity index 100%
rename from src/muslkit/index.nim
rename to src/nimstatic/index.nim
renamed src/nimstatic/repo.nim +2 -2
similarity index 97%
rename from src/muslkit/repo.nim
rename to src/nimstatic/repo.nim
@@ -1,6 +1,6 @@
11 ## Talking to an Alpine mirror: index fetch, package download, local caching.
22 ##
3-## Everything lands in a cache directory first, so a re-run of `muslkit add` is
3+## Everything lands in a cache directory first, so a re-run of `nimstatic add` is
44 ## offline and a sysroot can be rebuilt without touching the network.
55
66 import std/[httpclient, os, strutils, times]
@@ -24,7 +24,7 @@ proc initRemote*(mirror = defaultMirror, branch = defaultBranch,
2424 cacheDir = "", quiet = false): Remote =
2525 Remote(mirror: mirror, branch: branch, arch: arch, repos: repos,
2626 cacheDir: if cacheDir.len > 0: cacheDir
27- else: getCacheDir() / "muslkit",
27+ else: getCacheDir() / "nimstatic",
2828 quiet: quiet)
2929
3030 proc repoUrl*(r: Remote, repo: string): string =
similarity index 97%
rename from src/muslkit/repo.nim
rename to src/nimstatic/repo.nim
@@ -1,6 +1,6 @@
1 ## Talking to an Alpine mirror: index fetch, package download, local caching.1 ## Talking to an Alpine mirror: index fetch, package download, local caching.
2 ##2 ##
3-## Everything lands in a cache directory first, so a re-run of `muslkit add` is3+## Everything lands in a cache directory first, so a re-run of `nimstatic add` is
4 ## offline and a sysroot can be rebuilt without touching the network.4 ## offline and a sysroot can be rebuilt without touching the network.
5 5
6 import std/[httpclient, os, strutils, times]6 import std/[httpclient, os, strutils, times]
@@ -24,7 +24,7 @@ proc initRemote*(mirror = defaultMirror, branch = defaultBranch,
24 cacheDir = "", quiet = false): Remote =24 cacheDir = "", quiet = false): Remote =
25 Remote(mirror: mirror, branch: branch, arch: arch, repos: repos,25 Remote(mirror: mirror, branch: branch, arch: arch, repos: repos,
26 cacheDir: if cacheDir.len > 0: cacheDir26 cacheDir: if cacheDir.len > 0: cacheDir
27- else: getCacheDir() / "muslkit",27+ else: getCacheDir() / "nimstatic",
28 quiet: quiet)28 quiet: quiet)
29 29
30 proc repoUrl*(r: Remote, repo: string): string =30 proc repoUrl*(r: Remote, repo: string): string =
renamed src/nimstatic/sysroot.nim +2 -2
similarity index 93%
rename from src/muslkit/sysroot.nim
rename to src/nimstatic/sysroot.nim
@@ -4,7 +4,7 @@
44 import std/[algorithm, os, sequtils, strutils]
55 import ./index
66
7-const manifestPath* = ".muslkit/installed.tsv"
7+const manifestPath* = ".nimstatic/installed.tsv"
88
99 type Installed* = object
1010 name*, version*, repo*: string
@@ -22,7 +22,7 @@ proc readManifest*(sysroot: string): seq[Installed] =
2222
2323 proc writeManifest*(sysroot: string, entries: seq[Installed]) =
2424 createDir manifestFile(sysroot).parentDir
25- var lines = @["# name\tversion\trepo — written by muslkit"]
25+ var lines = @["# name\tversion\trepo — written by nimstatic"]
2626 for e in entries.sortedByIt(it.name):
2727 lines.add e.name & "\t" & e.version & "\t" & e.repo
2828 writeFile(manifestFile(sysroot), lines.join("\n") & "\n")
similarity index 93%
rename from src/muslkit/sysroot.nim
rename to src/nimstatic/sysroot.nim
@@ -4,7 +4,7 @@
4 import std/[algorithm, os, sequtils, strutils]4 import std/[algorithm, os, sequtils, strutils]
5 import ./index5 import ./index
6 6
7-const manifestPath* = ".muslkit/installed.tsv"7+const manifestPath* = ".nimstatic/installed.tsv"
8 8
9 type Installed* = object9 type Installed* = object
10 name*, version*, repo*: string10 name*, version*, repo*: string
@@ -22,7 +22,7 @@ proc readManifest*(sysroot: string): seq[Installed] =
22 22
23 proc writeManifest*(sysroot: string, entries: seq[Installed]) =23 proc writeManifest*(sysroot: string, entries: seq[Installed]) =
24 createDir manifestFile(sysroot).parentDir24 createDir manifestFile(sysroot).parentDir
25- var lines = @["# name\tversion\trepo — written by muslkit"]25+ var lines = @["# name\tversion\trepo — written by nimstatic"]
26 for e in entries.sortedByIt(it.name):26 for e in entries.sortedByIt(it.name):
27 lines.add e.name & "\t" & e.version & "\t" & e.repo27 lines.add e.name & "\t" & e.version & "\t" & e.repo
28 writeFile(manifestFile(sysroot), lines.join("\n") & "\n")28 writeFile(manifestFile(sysroot), lines.join("\n") & "\n")
added tests/test_muslkit +0 -0
new file mode 100755
Binary files /dev/null and b/tests/test_muslkit differ
new file mode 100755
Binary files /dev/null and b/tests/test_muslkit differBinary files /dev/null and b/tests/test_muslkit differ
renamed tests/test_nimstatic.nim +64 -5
similarity index 67%
rename from tests/test_muslkit.nim
rename to tests/test_nimstatic.nim
@@ -1,5 +1,5 @@
1-import std/[os, strutils, tables, unittest]
2-import ../src/muslkit
1+import std/[os, sequtils, strutils, tables, unittest]
2+import ../src/nimstatic
33
44 const sampleIndex = """
55 C:Q1aaa=
@@ -110,7 +110,7 @@ suite "resolution":
110110 check a.packages["foo"].repo == "community"
111111
112112 suite "sysroot manifest":
113- let root = getTempDir() / "muslkit-test-root"
113+ let root = getTempDir() / "nimstatic-test-root"
114114 removeDir root
115115
116116 test "record then read back":
@@ -139,7 +139,7 @@ suite "sysroot manifest":
139139 removeDir root
140140
141141 suite "flags":
142- let root = getTempDir() / "muslkit-test-flags"
142+ let root = getTempDir() / "nimstatic-test-flags"
143143 removeDir root
144144 createDir root / "usr" / "lib"
145145
@@ -152,7 +152,7 @@ suite "flags":
152152 test "openssl archives switch on the static-TLS workarounds":
153153 for name in ["libssl.a", "libcrypto.a"]:
154154 writeFile(root / "usr" / "lib" / name, "")
155- let f = nimFlags(root, ["z"]).join(" ")
155+ let f = nimFlags(root, overridesIn(root), ["z"]).join(" ")
156156 check "--dynlibOverride:ssl" in f
157157 check "--dynlibOverride:crypto" in f
158158 # OpenSSL 3 dropped the symbol Nim's wrapper still names
@@ -165,6 +165,15 @@ suite "flags":
165165 "-static", "-lssl"]
166166 check pkgConfigEnv(root)[0] == "PKG_CONFIG_SYSROOT_DIR=" & root
167167
168+ test "an override with no archive in the sysroot is dropped":
169+ # Asking Nim not to dlopen a library we cannot link would break the build.
170+ let f = nimFlags(root, ["ghost"]).join(" ")
171+ check "--dynlibOverride:ghost" notin f
172+
173+ test "libssl links before libcrypto":
174+ let f = nimFlags(root, ["crypto", "ssl"]).join(" ")
175+ check f.find("libssl.a") < f.find("libcrypto.a")
176+
168177 test "nimcfg carries the compiler and the flags":
169178 let cfg = nimCfg(root, "/tmp/zigcc")
170179 check "--cc:clang" in cfg
@@ -185,3 +194,53 @@ suite "remote":
185194 "https://dl-cdn.alpinelinux.org/alpine/edge/community/aarch64"
186195 check apkFile(Pkg(name: "zlib-static", version: "1.3.2-r0")) ==
187196 "zlib-static-1.3.2-r0.apk"
197+
198+suite "detection":
199+ test "sonames reduce to library names":
200+ check libFromSoname("libssl.so.3") == "ssl"
201+ check libFromSoname("libpcre2-8.so.0") == "pcre2-8"
202+ check libFromSoname("libcrypto.so(.3|.1.1|.10|)") == "crypto"
203+ check libFromSoname("notalib") == ""
204+ check libFromSoname("libnoso") == ""
205+
206+ test "link libraries come off the link command, once each":
207+ let cmd = "gcc -o app a.o b.o -pthread -lm -lm -lrt -lpcre -ldl"
208+ check linkLibs(cmd) == @["m", "rt", "pcre", "dl"]
209+
210+ test "dynlib candidates are found in generated C":
211+ let c = """
212+ static char* sslCandidates[] = {"libssl.so.3", "libssl.so.1.1"};
213+ static char* cryptoCandidates[] = {"libcrypto.so(.3|.1.1|)"};
214+ const char* unrelated = "libera chat";
215+ """
216+ check dynlibsIn(c) == @["ssl", "crypto"]
217+
218+ test "inspect maps libraries to packages and flags the rest":
219+ let cache = getTempDir() / "nimstatic-test-cache"
220+ removeDir cache
221+ createDir cache
222+ writeFile(cache / "app.json", """{"linkcmd": "gcc -o app a.o -lm -lsqlite3"}""")
223+ writeFile(cache / "app.c", """char* c[] = {"libssl.so.3", "libmystery.so.1"};""")
224+ let d = inspect(cache)
225+
226+ # libc's own are never requested
227+ check not d.needs.anyIt(it.lib == "m")
228+ check d.needs.anyIt(it.lib == "sqlite3" and not it.dynlib)
229+ check d.needs.anyIt(it.lib == "ssl" and it.dynlib)
230+ check "openssl-libs-static" in packages(d)
231+ check "sqlite-static" in packages(d)
232+ check d.unmapped == @["mystery"]
233+ # only dlopen'd libraries need the override
234+ check dynlibOverrides(d) == @["ssl"]
235+ removeDir cache
236+
237+ test "--map overrides the built-in table":
238+ let cache = getTempDir() / "nimstatic-test-cache2"
239+ removeDir cache
240+ createDir cache
241+ writeFile(cache / "app.json", """{"linkcmd": "gcc -o app a.o"}""")
242+ writeFile(cache / "app.c", """char* c[] = {"libmystery.so.1"};""")
243+ let d = inspect(cache, {"mystery": "mystery-static"}.toTable)
244+ check packages(d) == @["mystery-static"]
245+ check d.unmapped.len == 0
246+ removeDir cache
similarity index 67%
rename from tests/test_muslkit.nim
rename to tests/test_nimstatic.nim
@@ -1,5 +1,5 @@
1-import std/[os, strutils, tables, unittest]1+import std/[os, sequtils, strutils, tables, unittest]
2-import ../src/muslkit2+import ../src/nimstatic
3 3
4 const sampleIndex = """4 const sampleIndex = """
5 C:Q1aaa=5 C:Q1aaa=
@@ -110,7 +110,7 @@ suite "resolution":
110 check a.packages["foo"].repo == "community"110 check a.packages["foo"].repo == "community"
111 111
112 suite "sysroot manifest":112 suite "sysroot manifest":
113- let root = getTempDir() / "muslkit-test-root"113+ let root = getTempDir() / "nimstatic-test-root"
114 removeDir root114 removeDir root
115 115
116 test "record then read back":116 test "record then read back":
@@ -139,7 +139,7 @@ suite "sysroot manifest":
139 removeDir root139 removeDir root
140 140
141 suite "flags":141 suite "flags":
142- let root = getTempDir() / "muslkit-test-flags"142+ let root = getTempDir() / "nimstatic-test-flags"
143 removeDir root143 removeDir root
144 createDir root / "usr" / "lib"144 createDir root / "usr" / "lib"
145 145
@@ -152,7 +152,7 @@ suite "flags":
152 test "openssl archives switch on the static-TLS workarounds":152 test "openssl archives switch on the static-TLS workarounds":
153 for name in ["libssl.a", "libcrypto.a"]:153 for name in ["libssl.a", "libcrypto.a"]:
154 writeFile(root / "usr" / "lib" / name, "")154 writeFile(root / "usr" / "lib" / name, "")
155- let f = nimFlags(root, ["z"]).join(" ")155+ let f = nimFlags(root, overridesIn(root), ["z"]).join(" ")
156 check "--dynlibOverride:ssl" in f156 check "--dynlibOverride:ssl" in f
157 check "--dynlibOverride:crypto" in f157 check "--dynlibOverride:crypto" in f
158 # OpenSSL 3 dropped the symbol Nim's wrapper still names158 # OpenSSL 3 dropped the symbol Nim's wrapper still names
@@ -165,6 +165,15 @@ suite "flags":
165 "-static", "-lssl"]165 "-static", "-lssl"]
166 check pkgConfigEnv(root)[0] == "PKG_CONFIG_SYSROOT_DIR=" & root166 check pkgConfigEnv(root)[0] == "PKG_CONFIG_SYSROOT_DIR=" & root
167 167
168+ test "an override with no archive in the sysroot is dropped":
169+ # Asking Nim not to dlopen a library we cannot link would break the build.
170+ let f = nimFlags(root, ["ghost"]).join(" ")
171+ check "--dynlibOverride:ghost" notin f
172+
173+ test "libssl links before libcrypto":
174+ let f = nimFlags(root, ["crypto", "ssl"]).join(" ")
175+ check f.find("libssl.a") < f.find("libcrypto.a")
176+
168 test "nimcfg carries the compiler and the flags":177 test "nimcfg carries the compiler and the flags":
169 let cfg = nimCfg(root, "/tmp/zigcc")178 let cfg = nimCfg(root, "/tmp/zigcc")
170 check "--cc:clang" in cfg179 check "--cc:clang" in cfg
@@ -185,3 +194,53 @@ suite "remote":
185 "https://dl-cdn.alpinelinux.org/alpine/edge/community/aarch64"194 "https://dl-cdn.alpinelinux.org/alpine/edge/community/aarch64"
186 check apkFile(Pkg(name: "zlib-static", version: "1.3.2-r0")) ==195 check apkFile(Pkg(name: "zlib-static", version: "1.3.2-r0")) ==
187 "zlib-static-1.3.2-r0.apk"196 "zlib-static-1.3.2-r0.apk"
197+
198+suite "detection":
199+ test "sonames reduce to library names":
200+ check libFromSoname("libssl.so.3") == "ssl"
201+ check libFromSoname("libpcre2-8.so.0") == "pcre2-8"
202+ check libFromSoname("libcrypto.so(.3|.1.1|.10|)") == "crypto"
203+ check libFromSoname("notalib") == ""
204+ check libFromSoname("libnoso") == ""
205+
206+ test "link libraries come off the link command, once each":
207+ let cmd = "gcc -o app a.o b.o -pthread -lm -lm -lrt -lpcre -ldl"
208+ check linkLibs(cmd) == @["m", "rt", "pcre", "dl"]
209+
210+ test "dynlib candidates are found in generated C":
211+ let c = """
212+ static char* sslCandidates[] = {"libssl.so.3", "libssl.so.1.1"};
213+ static char* cryptoCandidates[] = {"libcrypto.so(.3|.1.1|)"};
214+ const char* unrelated = "libera chat";
215+ """
216+ check dynlibsIn(c) == @["ssl", "crypto"]
217+
218+ test "inspect maps libraries to packages and flags the rest":
219+ let cache = getTempDir() / "nimstatic-test-cache"
220+ removeDir cache
221+ createDir cache
222+ writeFile(cache / "app.json", """{"linkcmd": "gcc -o app a.o -lm -lsqlite3"}""")
223+ writeFile(cache / "app.c", """char* c[] = {"libssl.so.3", "libmystery.so.1"};""")
224+ let d = inspect(cache)
225+
226+ # libc's own are never requested
227+ check not d.needs.anyIt(it.lib == "m")
228+ check d.needs.anyIt(it.lib == "sqlite3" and not it.dynlib)
229+ check d.needs.anyIt(it.lib == "ssl" and it.dynlib)
230+ check "openssl-libs-static" in packages(d)
231+ check "sqlite-static" in packages(d)
232+ check d.unmapped == @["mystery"]
233+ # only dlopen'd libraries need the override
234+ check dynlibOverrides(d) == @["ssl"]
235+ removeDir cache
236+
237+ test "--map overrides the built-in table":
238+ let cache = getTempDir() / "nimstatic-test-cache2"
239+ removeDir cache
240+ createDir cache
241+ writeFile(cache / "app.json", """{"linkcmd": "gcc -o app a.o"}""")
242+ writeFile(cache / "app.c", """char* c[] = {"libmystery.so.1"};""")
243+ let d = inspect(cache, {"mystery": "mystery-static"}.toTable)
244+ check packages(d) == @["mystery-static"]
245+ check d.unmapped.len == 0
246+ removeDir cache