temp: raw size probe
159b56c 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
big.bin +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/big.bin differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/big.bin differ | Binary files /dev/null and b/big.bin differ | ||
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
exactly2mib.bin +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/exactly2mib.bin differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/exactly2mib.bin differ | Binary files /dev/null and b/exactly2mib.bin differ | ||
added
justover.bin +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/justover.bin differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/justover.bin differ | Binary files /dev/null and b/justover.bin differ | ||
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 differ | Binary files /dev/null and b/muslkit 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 differ | Binary 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 | ||