nandi/nimstaticpublic Fork 0
7e028da
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.

dist: nimstatic 0.1.0 x86_64-linux

Stripped, fully static, built by nimstatic itself. Lives here because this
forge's releases carry notes but no assets; pin by commit sha for an immutable
download URL. xz rather than gzip: raw file serving truncates at 2 MiB and the
gzipped binary was 2.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T12:25:35-07:00 Browse files
7e028da
added .gitignore +4 -0
new file mode 100644
@@ -0,0 +1,4 @@
1+/nimstatic
2+/zigcc
3+tests/test_nimstatic
4+nimcache/
new file mode 100644
@@ -0,0 +1,4 @@
1+/nimstatic
2+/zigcc
3+tests/test_nimstatic
4+nimcache/
added README.md +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+# nimstatic — release binaries
2+
3+This branch carries built artifacts only; the source lives on `main`.
4+
5+Releases are notes-only on this forge, so binaries are committed here and
6+addressed by commit sha, which makes the URL immutable:
7+
8+ https://rickub.com/nandi/nimstatic/raw/<commit-sha>/<file>
9+
10+Every file is an xz-compressed, stripped, fully static `x86_64-linux` binary
11+built by nimstatic itself. (xz rather than gzip because this forge's raw file
12+serving truncates at 2 MiB, and the gzipped binary was just over.) Verify before running:
13+
14+ sha256sum -c SHA256SUMS
new file mode 100644
@@ -0,0 +1,14 @@
1+# nimstatic — release binaries
2+
3+This branch carries built artifacts only; the source lives on `main`.
4+
5+Releases are notes-only on this forge, so binaries are committed here and
6+addressed by commit sha, which makes the URL immutable:
7+
8+ https://rickub.com/nandi/nimstatic/raw/<commit-sha>/<file>
9+
10+Every file is an xz-compressed, stripped, fully static `x86_64-linux` binary
11+built by nimstatic itself. (xz rather than gzip because this forge's raw file
12+serving truncates at 2 MiB, and the gzipped binary was just over.) Verify before running:
13+
14+ sha256sum -c SHA256SUMS
added SHA256SUMS +1 -0
new file mode 100644
@@ -0,0 +1 @@
1+8cd024a96a0d47d148576d730c11e2c81cdebde777389f08f8bd7faa59a9e185 nimstatic-0.1.0-x86_64-linux.xz
new file mode 100644
@@ -0,0 +1 @@
1+8cd024a96a0d47d148576d730c11e2c81cdebde777389f08f8bd7faa59a9e185 nimstatic-0.1.0-x86_64-linux.xz
added config.nims +2 -0
new file mode 100644
@@ -0,0 +1,2 @@
1+# The mirror is HTTPS-only, so the stdlib client needs TLS.
2+switch("define", "ssl")
new file mode 100644
@@ -0,0 +1,2 @@
1+# The mirror is HTTPS-only, so the stdlib client needs TLS.
2+switch("define", "ssl")
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
added nimstatic-0.1.0-x86_64-linux.xz +0 -0
new file mode 100644
Binary files /dev/null and b/nimstatic-0.1.0-x86_64-linux.xz differ
new file mode 100644
Binary files /dev/null and b/nimstatic-0.1.0-x86_64-linux.xz differBinary files /dev/null and b/nimstatic-0.1.0-x86_64-linux.xz differ
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"
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"
added src/nimstatic/index.nim +113 -0
new file mode 100644
@@ -0,0 +1,113 @@
1+## APKINDEX parsing and dependency resolution.
2+##
3+## An APKINDEX is blank-line-separated records of single-letter fields:
4+##
5+## P:openssl-dev
6+## V:3.3.7-r1
7+## D:libcrypto3=3.3.7-r1 libssl3=3.3.7-r1 pkgconfig
8+## p:openssl3-dev=3.3.7-r1 pc:libssl=3.3.7
9+##
10+## `D:` depends and `p:` provides both carry optional version constraints, and a
11+## dependency may name a *provide* (`so:libssl.so.3`) rather than a package.
12+
13+import std/[sets, strutils, tables]
14+
15+type
16+ Pkg* = object
17+ name*, version*, arch*, description*, origin*, license*: string
18+ size*: int ## download size in bytes, from `S:`
19+ checksum*: string ## `C:` — apk's control-segment hash, not the file's
20+ depends*: seq[string]
21+ provides*: seq[string]
22+ repo*: string ## main | community | …, filled in by the caller
23+
24+ Index* = object
25+ packages*: OrderedTable[string, Pkg]
26+ providers*: Table[string, string] ## provide name → package name
27+
28+func stripConstraint*(dep: string): string =
29+ ## `musl=1.2.5-r11` → `musl`; `so:libssl.so.3=3` → `so:libssl.so.3`.
30+ ## Alpine writes constraints as =, >=, <=, >, < or ~.
31+ for i, c in dep:
32+ if c in {'=', '>', '<', '~'}:
33+ return dep[0 ..< i]
34+ dep
35+
36+func isConflict*(dep: string): bool = dep.startsWith("!")
37+
38+proc parseIndex*(text: string, repo = ""): Index =
39+ ## Parse one APKINDEX body. Later records win, matching apk's own behavior of
40+ ## letting a repository shadow an earlier one.
41+ var
42+ idx: Index
43+ cur: Pkg
44+
45+ proc flush(idx: var Index, cur: var Pkg, repo: string) =
46+ if cur.name.len > 0:
47+ cur.repo = repo
48+ idx.packages[cur.name] = cur
49+ idx.providers[cur.name] = cur.name
50+ for p in cur.provides:
51+ idx.providers[stripConstraint(p)] = cur.name
52+ cur = Pkg()
53+
54+ for rawLine in text.splitLines:
55+ let line = rawLine.strip(leading = false)
56+ if line.len == 0:
57+ flush(idx, cur, repo)
58+ continue
59+ if line.len < 2 or line[1] != ':':
60+ continue
61+ let val = line[2 .. ^1]
62+ case line[0]
63+ of 'P': cur.name = val
64+ of 'V': cur.version = val
65+ of 'A': cur.arch = val
66+ of 'T': cur.description = val
67+ of 'L': cur.license = val
68+ of 'o': cur.origin = val
69+ of 'C': cur.checksum = val
70+ of 'S': cur.size = try: parseInt(val) except ValueError: 0
71+ of 'D': cur.depends = val.splitWhitespace()
72+ of 'p': cur.provides = val.splitWhitespace()
73+ else: discard
74+ flush(idx, cur, repo)
75+ idx
76+
77+proc merge*(a: var Index, b: Index) =
78+ ## Fold another repository's index in. `b` wins on collisions.
79+ for name, pkg in b.packages:
80+ a.packages[name] = pkg
81+ for provide, name in b.providers:
82+ a.providers[provide] = name
83+
84+proc find*(idx: Index, want: string): string =
85+ ## Resolve a name-or-provide to a package name, or "" if nothing supplies it.
86+ let key = stripConstraint(want)
87+ if key in idx.packages: return key
88+ idx.providers.getOrDefault(key, "")
89+
90+proc resolve*(idx: Index, wanted: openArray[string], withDeps = true): seq[Pkg] =
91+ ## Depth-first closure over `wanted`, in install order (dependencies first).
92+ ## Raises KeyError naming the first request nothing in the index supplies.
93+ var seen: HashSet[string]
94+ var order: seq[Pkg]
95+
96+ proc visit(want, requestedBy: string) =
97+ if isConflict(want): return
98+ let name = idx.find(want)
99+ if name.len == 0:
100+ let ctx = if requestedBy.len > 0: " (needed by " & requestedBy & ")" else: ""
101+ raise newException(KeyError, "no package provides '" &
102+ stripConstraint(want) & "'" & ctx)
103+ if name in seen: return
104+ seen.incl name
105+ let pkg = idx.packages[name]
106+ if withDeps:
107+ for dep in pkg.depends:
108+ visit(dep, name)
109+ order.add pkg
110+
111+ for want in wanted:
112+ visit(want, "")
113+ order
new file mode 100644
@@ -0,0 +1,113 @@
1+## APKINDEX parsing and dependency resolution.
2+##
3+## An APKINDEX is blank-line-separated records of single-letter fields:
4+##
5+## P:openssl-dev
6+## V:3.3.7-r1
7+## D:libcrypto3=3.3.7-r1 libssl3=3.3.7-r1 pkgconfig
8+## p:openssl3-dev=3.3.7-r1 pc:libssl=3.3.7
9+##
10+## `D:` depends and `p:` provides both carry optional version constraints, and a
11+## dependency may name a *provide* (`so:libssl.so.3`) rather than a package.
12+
13+import std/[sets, strutils, tables]
14+
15+type
16+ Pkg* = object
17+ name*, version*, arch*, description*, origin*, license*: string
18+ size*: int ## download size in bytes, from `S:`
19+ checksum*: string ## `C:` — apk's control-segment hash, not the file's
20+ depends*: seq[string]
21+ provides*: seq[string]
22+ repo*: string ## main | community | …, filled in by the caller
23+
24+ Index* = object
25+ packages*: OrderedTable[string, Pkg]
26+ providers*: Table[string, string] ## provide name → package name
27+
28+func stripConstraint*(dep: string): string =
29+ ## `musl=1.2.5-r11` → `musl`; `so:libssl.so.3=3` → `so:libssl.so.3`.
30+ ## Alpine writes constraints as =, >=, <=, >, < or ~.
31+ for i, c in dep:
32+ if c in {'=', '>', '<', '~'}:
33+ return dep[0 ..< i]
34+ dep
35+
36+func isConflict*(dep: string): bool = dep.startsWith("!")
37+
38+proc parseIndex*(text: string, repo = ""): Index =
39+ ## Parse one APKINDEX body. Later records win, matching apk's own behavior of
40+ ## letting a repository shadow an earlier one.
41+ var
42+ idx: Index
43+ cur: Pkg
44+
45+ proc flush(idx: var Index, cur: var Pkg, repo: string) =
46+ if cur.name.len > 0:
47+ cur.repo = repo
48+ idx.packages[cur.name] = cur
49+ idx.providers[cur.name] = cur.name
50+ for p in cur.provides:
51+ idx.providers[stripConstraint(p)] = cur.name
52+ cur = Pkg()
53+
54+ for rawLine in text.splitLines:
55+ let line = rawLine.strip(leading = false)
56+ if line.len == 0:
57+ flush(idx, cur, repo)
58+ continue
59+ if line.len < 2 or line[1] != ':':
60+ continue
61+ let val = line[2 .. ^1]
62+ case line[0]
63+ of 'P': cur.name = val
64+ of 'V': cur.version = val
65+ of 'A': cur.arch = val
66+ of 'T': cur.description = val
67+ of 'L': cur.license = val
68+ of 'o': cur.origin = val
69+ of 'C': cur.checksum = val
70+ of 'S': cur.size = try: parseInt(val) except ValueError: 0
71+ of 'D': cur.depends = val.splitWhitespace()
72+ of 'p': cur.provides = val.splitWhitespace()
73+ else: discard
74+ flush(idx, cur, repo)
75+ idx
76+
77+proc merge*(a: var Index, b: Index) =
78+ ## Fold another repository's index in. `b` wins on collisions.
79+ for name, pkg in b.packages:
80+ a.packages[name] = pkg
81+ for provide, name in b.providers:
82+ a.providers[provide] = name
83+
84+proc find*(idx: Index, want: string): string =
85+ ## Resolve a name-or-provide to a package name, or "" if nothing supplies it.
86+ let key = stripConstraint(want)
87+ if key in idx.packages: return key
88+ idx.providers.getOrDefault(key, "")
89+
90+proc resolve*(idx: Index, wanted: openArray[string], withDeps = true): seq[Pkg] =
91+ ## Depth-first closure over `wanted`, in install order (dependencies first).
92+ ## Raises KeyError naming the first request nothing in the index supplies.
93+ var seen: HashSet[string]
94+ var order: seq[Pkg]
95+
96+ proc visit(want, requestedBy: string) =
97+ if isConflict(want): return
98+ let name = idx.find(want)
99+ if name.len == 0:
100+ let ctx = if requestedBy.len > 0: " (needed by " & requestedBy & ")" else: ""
101+ raise newException(KeyError, "no package provides '" &
102+ stripConstraint(want) & "'" & ctx)
103+ if name in seen: return
104+ seen.incl name
105+ let pkg = idx.packages[name]
106+ if withDeps:
107+ for dep in pkg.depends:
108+ visit(dep, name)
109+ order.add pkg
110+
111+ for want in wanted:
112+ visit(want, "")
113+ order
added src/nimstatic/repo.nim +95 -0
new file mode 100644
@@ -0,0 +1,95 @@
1+## Talking to an Alpine mirror: index fetch, package download, local caching.
2+##
3+## 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.
5+
6+import std/[httpclient, os, strutils, times]
7+import ./index
8+
9+const
10+ defaultMirror* = "https://dl-cdn.alpinelinux.org/alpine"
11+ defaultBranch* = "v3.21"
12+ defaultRepos* = @["main", "community"]
13+ defaultArch* = "x86_64"
14+ indexMaxAge* = initDuration(hours = 24)
15+
16+type Remote* = object
17+ mirror*, branch*, arch*: string
18+ repos*: seq[string]
19+ cacheDir*: string
20+ quiet*: bool
21+
22+proc initRemote*(mirror = defaultMirror, branch = defaultBranch,
23+ arch = defaultArch, repos = defaultRepos,
24+ cacheDir = "", quiet = false): Remote =
25+ Remote(mirror: mirror, branch: branch, arch: arch, repos: repos,
26+ cacheDir: if cacheDir.len > 0: cacheDir
27+ else: getCacheDir() / "nimstatic",
28+ quiet: quiet)
29+
30+proc repoUrl*(r: Remote, repo: string): string =
31+ r.mirror & "/" & r.branch & "/" & repo & "/" & r.arch
32+
33+proc note(r: Remote, msg: string) =
34+ if not r.quiet: stderr.writeLine msg
35+
36+proc download(r: Remote, url, dest: string) =
37+ createDir dest.parentDir
38+ let tmp = dest & ".part"
39+ var client = newHttpClient(timeout = 60_000)
40+ defer: client.close()
41+ try:
42+ client.downloadFile(url, tmp)
43+ except CatchableError as e:
44+ removeFile tmp
45+ raise newException(IOError, "fetching " & url & " failed: " & e.msg)
46+ moveFile(tmp, dest)
47+
48+proc needsRefresh(path: string, maxAge: Duration): bool =
49+ if not fileExists(path): return true
50+ getTime() - path.getLastModificationTime() > maxAge
51+
52+proc untar(archive, dest: string, members: openArray[string] = []) =
53+ ## Shell out to tar: an .apk is concatenated gzip streams, which GNU tar
54+ ## already reads, and vendoring a decompressor would be the larger evil.
55+ createDir dest
56+ var cmd = "tar -xzf " & quoteShell(archive) & " -C " & quoteShell(dest)
57+ for m in members:
58+ cmd.add " " & quoteShell(m)
59+ # apk metadata entries are not part of the package's file list.
60+ cmd.add " --exclude=.PKGINFO --exclude=.SIGN.* --warning=no-unknown-keyword"
61+ if execShellCmd(cmd) != 0:
62+ raise newException(IOError, "tar failed on " & archive)
63+
64+proc fetchIndex*(r: Remote, refresh = false): Index =
65+ ## Fetch (or reuse) every configured repository's APKINDEX, merged.
66+ for repo in r.repos:
67+ let
68+ dir = r.cacheDir / r.branch / repo / r.arch
69+ gz = dir / "APKINDEX.tar.gz"
70+ plain = dir / "APKINDEX"
71+ if refresh or needsRefresh(gz, indexMaxAge):
72+ r.note "fetching index: " & repo & "/" & r.arch
73+ r.download(r.repoUrl(repo) & "/APKINDEX.tar.gz", gz)
74+ removeFile plain
75+ if not fileExists(plain):
76+ untar(gz, dir, ["APKINDEX"])
77+ result.merge parseIndex(readFile(plain), repo)
78+
79+proc apkFile*(pkg: Pkg): string = pkg.name & "-" & pkg.version & ".apk"
80+
81+proc fetchPackage*(r: Remote, pkg: Pkg): string =
82+ ## Download one package if it is not already cached; returns the local path.
83+ let dest = r.cacheDir / r.branch / pkg.repo / r.arch / apkFile(pkg)
84+ if fileExists(dest) and getFileSize(dest) == pkg.size:
85+ return dest
86+ r.note "downloading " & pkg.name & " " & pkg.version &
87+ " (" & $(pkg.size div 1024) & " KiB)"
88+ r.download(r.repoUrl(pkg.repo) & "/" & apkFile(pkg), dest)
89+ if pkg.size > 0 and getFileSize(dest) != pkg.size:
90+ removeFile dest
91+ raise newException(IOError, pkg.name & ": size mismatch against the index")
92+ dest
93+
94+proc unpack*(archive, sysroot: string) =
95+ untar(archive, sysroot)
new file mode 100644
@@ -0,0 +1,95 @@
1+## Talking to an Alpine mirror: index fetch, package download, local caching.
2+##
3+## 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.
5+
6+import std/[httpclient, os, strutils, times]
7+import ./index
8+
9+const
10+ defaultMirror* = "https://dl-cdn.alpinelinux.org/alpine"
11+ defaultBranch* = "v3.21"
12+ defaultRepos* = @["main", "community"]
13+ defaultArch* = "x86_64"
14+ indexMaxAge* = initDuration(hours = 24)
15+
16+type Remote* = object
17+ mirror*, branch*, arch*: string
18+ repos*: seq[string]
19+ cacheDir*: string
20+ quiet*: bool
21+
22+proc initRemote*(mirror = defaultMirror, branch = defaultBranch,
23+ arch = defaultArch, repos = defaultRepos,
24+ cacheDir = "", quiet = false): Remote =
25+ Remote(mirror: mirror, branch: branch, arch: arch, repos: repos,
26+ cacheDir: if cacheDir.len > 0: cacheDir
27+ else: getCacheDir() / "nimstatic",
28+ quiet: quiet)
29+
30+proc repoUrl*(r: Remote, repo: string): string =
31+ r.mirror & "/" & r.branch & "/" & repo & "/" & r.arch
32+
33+proc note(r: Remote, msg: string) =
34+ if not r.quiet: stderr.writeLine msg
35+
36+proc download(r: Remote, url, dest: string) =
37+ createDir dest.parentDir
38+ let tmp = dest & ".part"
39+ var client = newHttpClient(timeout = 60_000)
40+ defer: client.close()
41+ try:
42+ client.downloadFile(url, tmp)
43+ except CatchableError as e:
44+ removeFile tmp
45+ raise newException(IOError, "fetching " & url & " failed: " & e.msg)
46+ moveFile(tmp, dest)
47+
48+proc needsRefresh(path: string, maxAge: Duration): bool =
49+ if not fileExists(path): return true
50+ getTime() - path.getLastModificationTime() > maxAge
51+
52+proc untar(archive, dest: string, members: openArray[string] = []) =
53+ ## Shell out to tar: an .apk is concatenated gzip streams, which GNU tar
54+ ## already reads, and vendoring a decompressor would be the larger evil.
55+ createDir dest
56+ var cmd = "tar -xzf " & quoteShell(archive) & " -C " & quoteShell(dest)
57+ for m in members:
58+ cmd.add " " & quoteShell(m)
59+ # apk metadata entries are not part of the package's file list.
60+ cmd.add " --exclude=.PKGINFO --exclude=.SIGN.* --warning=no-unknown-keyword"
61+ if execShellCmd(cmd) != 0:
62+ raise newException(IOError, "tar failed on " & archive)
63+
64+proc fetchIndex*(r: Remote, refresh = false): Index =
65+ ## Fetch (or reuse) every configured repository's APKINDEX, merged.
66+ for repo in r.repos:
67+ let
68+ dir = r.cacheDir / r.branch / repo / r.arch
69+ gz = dir / "APKINDEX.tar.gz"
70+ plain = dir / "APKINDEX"
71+ if refresh or needsRefresh(gz, indexMaxAge):
72+ r.note "fetching index: " & repo & "/" & r.arch
73+ r.download(r.repoUrl(repo) & "/APKINDEX.tar.gz", gz)
74+ removeFile plain
75+ if not fileExists(plain):
76+ untar(gz, dir, ["APKINDEX"])
77+ result.merge parseIndex(readFile(plain), repo)
78+
79+proc apkFile*(pkg: Pkg): string = pkg.name & "-" & pkg.version & ".apk"
80+
81+proc fetchPackage*(r: Remote, pkg: Pkg): string =
82+ ## Download one package if it is not already cached; returns the local path.
83+ let dest = r.cacheDir / r.branch / pkg.repo / r.arch / apkFile(pkg)
84+ if fileExists(dest) and getFileSize(dest) == pkg.size:
85+ return dest
86+ r.note "downloading " & pkg.name & " " & pkg.version &
87+ " (" & $(pkg.size div 1024) & " KiB)"
88+ r.download(r.repoUrl(pkg.repo) & "/" & apkFile(pkg), dest)
89+ if pkg.size > 0 and getFileSize(dest) != pkg.size:
90+ removeFile dest
91+ raise newException(IOError, pkg.name & ": size mismatch against the index")
92+ dest
93+
94+proc unpack*(archive, sysroot: string) =
95+ untar(archive, sysroot)
added src/nimstatic/sysroot.nim +48 -0
new file mode 100644
@@ -0,0 +1,48 @@
1+## The sysroot: a plain directory tree of unpacked Alpine packages, plus a
2+## manifest of what was installed so `list` and re-runs are cheap.
3+
4+import std/[algorithm, os, sequtils, strutils]
5+import ./index
6+
7+const manifestPath* = ".nimstatic/installed.tsv"
8+
9+type Installed* = object
10+ name*, version*, repo*: string
11+
12+proc manifestFile*(sysroot: string): string = sysroot / manifestPath
13+
14+proc readManifest*(sysroot: string): seq[Installed] =
15+ let path = manifestFile(sysroot)
16+ if not fileExists(path): return
17+ for line in readFile(path).splitLines:
18+ if line.len == 0 or line.startsWith("#"): continue
19+ let f = line.split('\t')
20+ if f.len >= 3:
21+ result.add Installed(name: f[0], version: f[1], repo: f[2])
22+
23+proc writeManifest*(sysroot: string, entries: seq[Installed]) =
24+ createDir manifestFile(sysroot).parentDir
25+ var lines = @["# name\tversion\trepo — written by nimstatic"]
26+ for e in entries.sortedByIt(it.name):
27+ lines.add e.name & "\t" & e.version & "\t" & e.repo
28+ writeFile(manifestFile(sysroot), lines.join("\n") & "\n")
29+
30+proc record*(sysroot: string, pkgs: openArray[Pkg]) =
31+ ## Merge freshly installed packages into the manifest, newest version winning.
32+ var entries = readManifest(sysroot)
33+ for pkg in pkgs:
34+ entries.keepItIf(it.name != pkg.name)
35+ entries.add Installed(name: pkg.name, version: pkg.version, repo: pkg.repo)
36+ writeManifest(sysroot, entries)
37+
38+proc installed*(sysroot, name: string): bool =
39+ readManifest(sysroot).anyIt(it.name == name)
40+
41+proc staticLibs*(sysroot: string): seq[string] =
42+ ## Every .a in the sysroot, as absolute paths.
43+ for dir in ["usr/lib", "lib", "usr/lib64"]:
44+ let d = sysroot / dir
45+ if dirExists(d):
46+ for path in walkFiles(d / "*.a"):
47+ result.add path
48+ result.sort()
new file mode 100644
@@ -0,0 +1,48 @@
1+## The sysroot: a plain directory tree of unpacked Alpine packages, plus a
2+## manifest of what was installed so `list` and re-runs are cheap.
3+
4+import std/[algorithm, os, sequtils, strutils]
5+import ./index
6+
7+const manifestPath* = ".nimstatic/installed.tsv"
8+
9+type Installed* = object
10+ name*, version*, repo*: string
11+
12+proc manifestFile*(sysroot: string): string = sysroot / manifestPath
13+
14+proc readManifest*(sysroot: string): seq[Installed] =
15+ let path = manifestFile(sysroot)
16+ if not fileExists(path): return
17+ for line in readFile(path).splitLines:
18+ if line.len == 0 or line.startsWith("#"): continue
19+ let f = line.split('\t')
20+ if f.len >= 3:
21+ result.add Installed(name: f[0], version: f[1], repo: f[2])
22+
23+proc writeManifest*(sysroot: string, entries: seq[Installed]) =
24+ createDir manifestFile(sysroot).parentDir
25+ var lines = @["# name\tversion\trepo — written by nimstatic"]
26+ for e in entries.sortedByIt(it.name):
27+ lines.add e.name & "\t" & e.version & "\t" & e.repo
28+ writeFile(manifestFile(sysroot), lines.join("\n") & "\n")
29+
30+proc record*(sysroot: string, pkgs: openArray[Pkg]) =
31+ ## Merge freshly installed packages into the manifest, newest version winning.
32+ var entries = readManifest(sysroot)
33+ for pkg in pkgs:
34+ entries.keepItIf(it.name != pkg.name)
35+ entries.add Installed(name: pkg.name, version: pkg.version, repo: pkg.repo)
36+ writeManifest(sysroot, entries)
37+
38+proc installed*(sysroot, name: string): bool =
39+ readManifest(sysroot).anyIt(it.name == name)
40+
41+proc staticLibs*(sysroot: string): seq[string] =
42+ ## Every .a in the sysroot, as absolute paths.
43+ for dir in ["usr/lib", "lib", "usr/lib64"]:
44+ let d = sysroot / dir
45+ if dirExists(d):
46+ for path in walkFiles(d / "*.a"):
47+ result.add path
48+ result.sort()
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
added tests/test_nimstatic.nim +246 -0
new file mode 100644
@@ -0,0 +1,246 @@
1+import std/[os, sequtils, strutils, tables, unittest]
2+import ../src/nimstatic
3+
4+const sampleIndex = """
5+C:Q1aaa=
6+P:openssl-libs-static
7+V:3.3.7-r1
8+A:x86_64
9+S:13040623
10+T:Toolkit for Transport Layer Security (TLS) (static library)
11+L:Apache-2.0
12+o:openssl
13+
14+C:Q1bbb=
15+P:openssl-dev
16+V:3.3.7-r1
17+A:x86_64
18+S:3000
19+T:TLS toolkit (development files)
20+D:libcrypto3=3.3.7-r1 pkgconfig
21+p:openssl3-dev=3.3.7-r1 pc:libssl=3.3.7
22+
23+C:Q1ccc=
24+P:libcrypto3
25+V:3.3.7-r1
26+A:x86_64
27+S:2000
28+T:crypto library
29+D:so:libc.musl-x86_64.so.1
30+p:so:libcrypto.so.3=3
31+
32+C:Q1ddd=
33+P:musl
34+V:1.2.5-r11
35+A:x86_64
36+S:400
37+T:the musl c library
38+p:so:libc.musl-x86_64.so.1=1
39+
40+C:Q1eee=
41+P:pkgconf
42+V:2.3.0-r0
43+A:x86_64
44+S:100
45+T:pkg-config compatible
46+p:pkgconfig=2.3.0-r0
47+"""
48+
49+suite "index parsing":
50+ let idx = parseIndex(sampleIndex, "main")
51+
52+ test "records and fields":
53+ check idx.packages.len == 5
54+ let p = idx.packages["openssl-libs-static"]
55+ check p.version == "3.3.7-r1"
56+ check p.size == 13040623
57+ check p.repo == "main"
58+ check p.description.endsWith("(static library)")
59+
60+ test "provides are indexed":
61+ check idx.find("pkgconfig") == "pkgconf"
62+ check idx.find("so:libcrypto.so.3") == "libcrypto3"
63+ check idx.find("openssl3-dev") == "openssl-dev"
64+ check idx.find("nope") == ""
65+
66+ test "version constraints are stripped":
67+ check stripConstraint("musl=1.2.5-r11") == "musl"
68+ check stripConstraint("so:libssl.so.3=3") == "so:libssl.so.3"
69+ check stripConstraint("cmd>=2") == "cmd"
70+ check stripConstraint("plain") == "plain"
71+
72+ test "conflicts are recognized":
73+ check isConflict("!busybox")
74+ check not isConflict("busybox")
75+
76+suite "resolution":
77+ let idx = parseIndex(sampleIndex, "main")
78+
79+ test "dependencies come before dependents":
80+ let order = idx.resolve(["openssl-dev"])
81+ let names = block:
82+ var s: seq[string]
83+ for p in order: s.add p.name
84+ s
85+ check names[^1] == "openssl-dev"
86+ check "libcrypto3" in names
87+ check "musl" in names # reached through so:libc.musl-x86_64.so.1
88+ check "pkgconf" in names # reached through the pkgconfig provide
89+ check names.find("libcrypto3") < names.find("openssl-dev")
90+
91+ test "a package is visited once":
92+ let order = idx.resolve(["openssl-dev", "libcrypto3", "openssl-dev"])
93+ var seen: CountTable[string]
94+ for p in order: seen.inc p.name
95+ for name, n in seen: check n == 1
96+
97+ test "--no-deps stops at the request":
98+ let order = idx.resolve(["openssl-dev"], withDeps = false)
99+ check order.len == 1
100+ check order[0].name == "openssl-dev"
101+
102+ test "an unsatisfiable request names its requester":
103+ expect KeyError:
104+ discard parseIndex("C:Q1\nP:lonely\nV:1\nD:ghost\n").resolve(["lonely"])
105+
106+ test "merge lets a later repo shadow an earlier one":
107+ var a = parseIndex("C:Q1\nP:foo\nV:1\n", "main")
108+ a.merge parseIndex("C:Q1\nP:foo\nV:2\n", "community")
109+ check a.packages["foo"].version == "2"
110+ check a.packages["foo"].repo == "community"
111+
112+suite "sysroot manifest":
113+ let root = getTempDir() / "nimstatic-test-root"
114+ removeDir root
115+
116+ test "record then read back":
117+ record(root, [Pkg(name: "zlib-static", version: "1.3.2-r0", repo: "main")])
118+ record(root, [Pkg(name: "openssl-libs-static", version: "3.3.7-r1", repo: "main")])
119+ let entries = readManifest(root)
120+ check entries.len == 2
121+ check installed(root, "zlib-static")
122+ check not installed(root, "absent")
123+
124+ test "re-recording replaces rather than duplicates":
125+ record(root, [Pkg(name: "zlib-static", version: "1.3.3-r0", repo: "main")])
126+ let entries = readManifest(root)
127+ check entries.len == 2
128+ for e in entries:
129+ if e.name == "zlib-static": check e.version == "1.3.3-r0"
130+
131+ test "static libs are found and sorted":
132+ createDir root / "usr" / "lib"
133+ for name in ["libz.a", "libcrypto.a"]:
134+ writeFile(root / "usr" / "lib" / name, "")
135+ let libs = staticLibs(root)
136+ check libs.len == 2
137+ check libs[0].extractFilename == "libcrypto.a"
138+
139+ removeDir root
140+
141+suite "flags":
142+ let root = getTempDir() / "nimstatic-test-flags"
143+ removeDir root
144+ createDir root / "usr" / "lib"
145+
146+ test "plain sysroot":
147+ let f = nimFlags(root).join(" ")
148+ check ("--passC:-I" & root & "/usr/include") in f
149+ check "--passL:-static" in f
150+ check "dynlibOverride" notin f
151+
152+ test "openssl archives switch on the static-TLS workarounds":
153+ for name in ["libssl.a", "libcrypto.a"]:
154+ writeFile(root / "usr" / "lib" / name, "")
155+ let f = nimFlags(root, overridesIn(root), ["z"]).join(" ")
156+ check "--dynlibOverride:ssl" in f
157+ check "--dynlibOverride:crypto" in f
158+ # OpenSSL 3 dropped the symbol Nim's wrapper still names
159+ check "-DSSL_get_peer_certificate=SSL_get1_peer_certificate" in f
160+ check "--passL:-lz" in f
161+
162+ test "cc flags and pkg-config env":
163+ check ccFlags(root, ["ssl"]) == @["-I" & root & "/usr/include",
164+ "-L" & root & "/usr/lib",
165+ "-static", "-lssl"]
166+ check pkgConfigEnv(root)[0] == "PKG_CONFIG_SYSROOT_DIR=" & root
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+
177+ test "nimcfg carries the compiler and the flags":
178+ let cfg = nimCfg(root, "/tmp/zigcc")
179+ check "--cc:clang" in cfg
180+ check "--clang.exe:\"/tmp/zigcc\"" in cfg
181+ check "--passL:-static" in cfg
182+
183+ test "zigcc script targets the right triple":
184+ let s = zigccScript("aarch64-linux-musl")
185+ check "zig cc -target aarch64-linux-musl" in s
186+ check "\"$@\"" in s
187+
188+ removeDir root
189+
190+suite "remote":
191+ test "urls are built from mirror, branch, repo and arch":
192+ let r = initRemote(branch = "edge", arch = "aarch64")
193+ check r.repoUrl("community") ==
194+ "https://dl-cdn.alpinelinux.org/alpine/edge/community/aarch64"
195+ check apkFile(Pkg(name: "zlib-static", version: "1.3.2-r0")) ==
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
new file mode 100644
@@ -0,0 +1,246 @@
1+import std/[os, sequtils, strutils, tables, unittest]
2+import ../src/nimstatic
3+
4+const sampleIndex = """
5+C:Q1aaa=
6+P:openssl-libs-static
7+V:3.3.7-r1
8+A:x86_64
9+S:13040623
10+T:Toolkit for Transport Layer Security (TLS) (static library)
11+L:Apache-2.0
12+o:openssl
13+
14+C:Q1bbb=
15+P:openssl-dev
16+V:3.3.7-r1
17+A:x86_64
18+S:3000
19+T:TLS toolkit (development files)
20+D:libcrypto3=3.3.7-r1 pkgconfig
21+p:openssl3-dev=3.3.7-r1 pc:libssl=3.3.7
22+
23+C:Q1ccc=
24+P:libcrypto3
25+V:3.3.7-r1
26+A:x86_64
27+S:2000
28+T:crypto library
29+D:so:libc.musl-x86_64.so.1
30+p:so:libcrypto.so.3=3
31+
32+C:Q1ddd=
33+P:musl
34+V:1.2.5-r11
35+A:x86_64
36+S:400
37+T:the musl c library
38+p:so:libc.musl-x86_64.so.1=1
39+
40+C:Q1eee=
41+P:pkgconf
42+V:2.3.0-r0
43+A:x86_64
44+S:100
45+T:pkg-config compatible
46+p:pkgconfig=2.3.0-r0
47+"""
48+
49+suite "index parsing":
50+ let idx = parseIndex(sampleIndex, "main")
51+
52+ test "records and fields":
53+ check idx.packages.len == 5
54+ let p = idx.packages["openssl-libs-static"]
55+ check p.version == "3.3.7-r1"
56+ check p.size == 13040623
57+ check p.repo == "main"
58+ check p.description.endsWith("(static library)")
59+
60+ test "provides are indexed":
61+ check idx.find("pkgconfig") == "pkgconf"
62+ check idx.find("so:libcrypto.so.3") == "libcrypto3"
63+ check idx.find("openssl3-dev") == "openssl-dev"
64+ check idx.find("nope") == ""
65+
66+ test "version constraints are stripped":
67+ check stripConstraint("musl=1.2.5-r11") == "musl"
68+ check stripConstraint("so:libssl.so.3=3") == "so:libssl.so.3"
69+ check stripConstraint("cmd>=2") == "cmd"
70+ check stripConstraint("plain") == "plain"
71+
72+ test "conflicts are recognized":
73+ check isConflict("!busybox")
74+ check not isConflict("busybox")
75+
76+suite "resolution":
77+ let idx = parseIndex(sampleIndex, "main")
78+
79+ test "dependencies come before dependents":
80+ let order = idx.resolve(["openssl-dev"])
81+ let names = block:
82+ var s: seq[string]
83+ for p in order: s.add p.name
84+ s
85+ check names[^1] == "openssl-dev"
86+ check "libcrypto3" in names
87+ check "musl" in names # reached through so:libc.musl-x86_64.so.1
88+ check "pkgconf" in names # reached through the pkgconfig provide
89+ check names.find("libcrypto3") < names.find("openssl-dev")
90+
91+ test "a package is visited once":
92+ let order = idx.resolve(["openssl-dev", "libcrypto3", "openssl-dev"])
93+ var seen: CountTable[string]
94+ for p in order: seen.inc p.name
95+ for name, n in seen: check n == 1
96+
97+ test "--no-deps stops at the request":
98+ let order = idx.resolve(["openssl-dev"], withDeps = false)
99+ check order.len == 1
100+ check order[0].name == "openssl-dev"
101+
102+ test "an unsatisfiable request names its requester":
103+ expect KeyError:
104+ discard parseIndex("C:Q1\nP:lonely\nV:1\nD:ghost\n").resolve(["lonely"])
105+
106+ test "merge lets a later repo shadow an earlier one":
107+ var a = parseIndex("C:Q1\nP:foo\nV:1\n", "main")
108+ a.merge parseIndex("C:Q1\nP:foo\nV:2\n", "community")
109+ check a.packages["foo"].version == "2"
110+ check a.packages["foo"].repo == "community"
111+
112+suite "sysroot manifest":
113+ let root = getTempDir() / "nimstatic-test-root"
114+ removeDir root
115+
116+ test "record then read back":
117+ record(root, [Pkg(name: "zlib-static", version: "1.3.2-r0", repo: "main")])
118+ record(root, [Pkg(name: "openssl-libs-static", version: "3.3.7-r1", repo: "main")])
119+ let entries = readManifest(root)
120+ check entries.len == 2
121+ check installed(root, "zlib-static")
122+ check not installed(root, "absent")
123+
124+ test "re-recording replaces rather than duplicates":
125+ record(root, [Pkg(name: "zlib-static", version: "1.3.3-r0", repo: "main")])
126+ let entries = readManifest(root)
127+ check entries.len == 2
128+ for e in entries:
129+ if e.name == "zlib-static": check e.version == "1.3.3-r0"
130+
131+ test "static libs are found and sorted":
132+ createDir root / "usr" / "lib"
133+ for name in ["libz.a", "libcrypto.a"]:
134+ writeFile(root / "usr" / "lib" / name, "")
135+ let libs = staticLibs(root)
136+ check libs.len == 2
137+ check libs[0].extractFilename == "libcrypto.a"
138+
139+ removeDir root
140+
141+suite "flags":
142+ let root = getTempDir() / "nimstatic-test-flags"
143+ removeDir root
144+ createDir root / "usr" / "lib"
145+
146+ test "plain sysroot":
147+ let f = nimFlags(root).join(" ")
148+ check ("--passC:-I" & root & "/usr/include") in f
149+ check "--passL:-static" in f
150+ check "dynlibOverride" notin f
151+
152+ test "openssl archives switch on the static-TLS workarounds":
153+ for name in ["libssl.a", "libcrypto.a"]:
154+ writeFile(root / "usr" / "lib" / name, "")
155+ let f = nimFlags(root, overridesIn(root), ["z"]).join(" ")
156+ check "--dynlibOverride:ssl" in f
157+ check "--dynlibOverride:crypto" in f
158+ # OpenSSL 3 dropped the symbol Nim's wrapper still names
159+ check "-DSSL_get_peer_certificate=SSL_get1_peer_certificate" in f
160+ check "--passL:-lz" in f
161+
162+ test "cc flags and pkg-config env":
163+ check ccFlags(root, ["ssl"]) == @["-I" & root & "/usr/include",
164+ "-L" & root & "/usr/lib",
165+ "-static", "-lssl"]
166+ check pkgConfigEnv(root)[0] == "PKG_CONFIG_SYSROOT_DIR=" & root
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+
177+ test "nimcfg carries the compiler and the flags":
178+ let cfg = nimCfg(root, "/tmp/zigcc")
179+ check "--cc:clang" in cfg
180+ check "--clang.exe:\"/tmp/zigcc\"" in cfg
181+ check "--passL:-static" in cfg
182+
183+ test "zigcc script targets the right triple":
184+ let s = zigccScript("aarch64-linux-musl")
185+ check "zig cc -target aarch64-linux-musl" in s
186+ check "\"$@\"" in s
187+
188+ removeDir root
189+
190+suite "remote":
191+ test "urls are built from mirror, branch, repo and arch":
192+ let r = initRemote(branch = "edge", arch = "aarch64")
193+ check r.repoUrl("community") ==
194+ "https://dl-cdn.alpinelinux.org/alpine/edge/community/aarch64"
195+ check apkFile(Pkg(name: "zlib-static", version: "1.3.2-r0")) ==
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