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

muslkit: musl static libraries from Alpine, without Alpine

Fetches an APKINDEX, resolves a dependency closure through both package names
and provides (so:, pkgconfig:, …), downloads the .apk files and unpacks them
into a sysroot. No apk, no container, no root.

Flag emission knows the two things that break every static Nim build with TLS:
-d:ssl dlopens libssl (so --dynlibOverride plus the archives), and OpenSSL 3
dropped SSL_get_peer_certificate (so the compat define).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T11:12:49-07:00 Browse files
5feb050
added .gitignore +4 -0
new file mode 100644
@@ -0,0 +1,4 @@
1+/muslkit
2+/zigcc
3+tests/test_muslkit
4+nimcache/
new file mode 100644
@@ -0,0 +1,4 @@
1+/muslkit
2+/zigcc
3+tests/test_muslkit
4+nimcache/
added README.md +120 -0
new file mode 100644
@@ -0,0 +1,120 @@
1+# muslkit
2+
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.
12+
13+```bash
14+muslkit add openssl-libs-static
15+nim c -d:ssl $(muslkit nimflags) --cc:clang --clang.exe:./zigcc app.nim
16+```
17+
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
26+
27+```
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)
40+```
41+
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`.
45+
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.
49+
50+## A full static build, start to finish
51+
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
59+```
60+
61+`muslkit nimflags` emits more than include and library paths, because two
62+things bite every static Nim build with TLS:
63+
64+- **`-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 code
66+ paths that never touch the network. The fix is `--dynlibOverride:ssl
67+ --dynlibOverride:crypto` plus the archives on the link line.
68+- **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
70+ `-DSSL_get_peer_certificate=SSL_get1_peer_certificate`.
71+
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.
75+
76+Cross-compiling is the same command with `--arch`; `muslkit add -a aarch64
77+openssl-libs-static` and `muslkit zigcc -a aarch64` line up.
78+
79+## Trust
80+
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.
86+
87+## Install
88+
89+```bash
90+nimble install
91+```
92+
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`.
96+
97+## Library
98+
99+```nim
100+import muslkit
101+
102+let remote = initRemote(branch = "edge")
103+let idx = remote.fetchIndex()
104+for pkg in idx.resolve(["openssl-libs-static"]):
105+ echo pkg.name, " ", pkg.version, " ", remote.fetchPackage(pkg)
106+```
107+
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).
112+
113+## Tests
114+
115+```bash
116+nimble test
117+```
118+
119+The suite is offline — index parsing, resolution through `so:`/`pkg:` provides,
120+manifest round-trips and flag emission all run against fixtures.
new file mode 100644
@@ -0,0 +1,120 @@
1+# muslkit
2+
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.
12+
13+```bash
14+muslkit add openssl-libs-static
15+nim c -d:ssl $(muslkit nimflags) --cc:clang --clang.exe:./zigcc app.nim
16+```
17+
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
26+
27+```
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)
40+```
41+
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`.
45+
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.
49+
50+## A full static build, start to finish
51+
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
59+```
60+
61+`muslkit nimflags` emits more than include and library paths, because two
62+things bite every static Nim build with TLS:
63+
64+- **`-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 code
66+ paths that never touch the network. The fix is `--dynlibOverride:ssl
67+ --dynlibOverride:crypto` plus the archives on the link line.
68+- **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
70+ `-DSSL_get_peer_certificate=SSL_get1_peer_certificate`.
71+
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.
75+
76+Cross-compiling is the same command with `--arch`; `muslkit add -a aarch64
77+openssl-libs-static` and `muslkit zigcc -a aarch64` line up.
78+
79+## Trust
80+
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.
86+
87+## Install
88+
89+```bash
90+nimble install
91+```
92+
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`.
96+
97+## Library
98+
99+```nim
100+import muslkit
101+
102+let remote = initRemote(branch = "edge")
103+let idx = remote.fetchIndex()
104+for pkg in idx.resolve(["openssl-libs-static"]):
105+ echo pkg.name, " ", pkg.version, " ", remote.fetchPackage(pkg)
106+```
107+
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).
112+
113+## Tests
114+
115+```bash
116+nimble test
117+```
118+
119+The suite is offline — index parsing, resolution through `so:`/`pkg:` provides,
120+manifest round-trips and flag emission all run against fixtures.
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.nimble +12 -0
new file mode 100644
@@ -0,0 +1,12 @@
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"
new file mode 100644
@@ -0,0 +1,12 @@
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 src/muslkit.nim +213 -0
new file mode 100644
@@ -0,0 +1,213 @@
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
new file mode 100644
@@ -0,0 +1,213 @@
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
added src/muslkit/flags.nim +78 -0
new file mode 100644
@@ -0,0 +1,78 @@
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"
new file mode 100644
@@ -0,0 +1,78 @@
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/muslkit/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/muslkit/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 `muslkit 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() / "muslkit",
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 `muslkit 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() / "muslkit",
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/muslkit/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* = ".muslkit/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 muslkit"]
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* = ".muslkit/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 muslkit"]
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.nim +187 -0
new file mode 100644
@@ -0,0 +1,187 @@
1+import std/[os, strutils, tables, unittest]
2+import ../src/muslkit
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() / "muslkit-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() / "muslkit-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, ["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 "nimcfg carries the compiler and the flags":
169+ let cfg = nimCfg(root, "/tmp/zigcc")
170+ check "--cc:clang" in cfg
171+ check "--clang.exe:\"/tmp/zigcc\"" in cfg
172+ check "--passL:-static" in cfg
173+
174+ test "zigcc script targets the right triple":
175+ let s = zigccScript("aarch64-linux-musl")
176+ check "zig cc -target aarch64-linux-musl" in s
177+ check "\"$@\"" in s
178+
179+ removeDir root
180+
181+suite "remote":
182+ test "urls are built from mirror, branch, repo and arch":
183+ let r = initRemote(branch = "edge", arch = "aarch64")
184+ check r.repoUrl("community") ==
185+ "https://dl-cdn.alpinelinux.org/alpine/edge/community/aarch64"
186+ check apkFile(Pkg(name: "zlib-static", version: "1.3.2-r0")) ==
187+ "zlib-static-1.3.2-r0.apk"
new file mode 100644
@@ -0,0 +1,187 @@
1+import std/[os, strutils, tables, unittest]
2+import ../src/muslkit
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() / "muslkit-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() / "muslkit-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, ["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 "nimcfg carries the compiler and the flags":
169+ let cfg = nimCfg(root, "/tmp/zigcc")
170+ check "--cc:clang" in cfg
171+ check "--clang.exe:\"/tmp/zigcc\"" in cfg
172+ check "--passL:-static" in cfg
173+
174+ test "zigcc script targets the right triple":
175+ let s = zigccScript("aarch64-linux-musl")
176+ check "zig cc -target aarch64-linux-musl" in s
177+ check "\"$@\"" in s
178+
179+ removeDir root
180+
181+suite "remote":
182+ test "urls are built from mirror, branch, repo and arch":
183+ let r = initRemote(branch = "edge", arch = "aarch64")
184+ check r.repoUrl("community") ==
185+ "https://dl-cdn.alpinelinux.org/alpine/edge/community/aarch64"
186+ check apkFile(Pkg(name: "zlib-static", version: "1.3.2-r0")) ==
187+ "zlib-static-1.3.2-r0.apk"