|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com> |
| 3 | +# |
| 4 | +# SPDX-License-Identifier: MIT |
| 5 | +"""Fail when a shipped native library needs a runtime library it did not need before. |
| 6 | +
|
| 7 | +Every jllama library is ONE file with llama.cpp and ggml linked in statically, so its dynamic |
| 8 | +dependencies are exactly what a consumer's machine must provide. A new one is a silent break on |
| 9 | +every machine that lacks it -- the case this guards against is ggml-rpc's RDMA transport, which |
| 10 | +upstream switches on whenever the build host has libibverbs/librdma and which would make the |
| 11 | +library unloadable without rdma-core. It reads the dependency list straight from the file (ELF |
| 12 | +DT_NEEDED, PE import table, Mach-O LC_LOAD_DYLIB) with the standard library only, so it runs on |
| 13 | +any runner and checks every architecture, including the ones binutils cannot read (Windows arm64, |
| 14 | +Mach-O). |
| 15 | +
|
| 16 | +Usage: |
| 17 | + verify-native-deps.py --default <resources-root> # exact allowlist per <OS>/<ARCH> |
| 18 | + verify-native-deps.py --deny <dir>... # classifier trees: only the denylist |
| 19 | +
|
| 20 | +--default checks net/ladenthin/llama/<OS>/<ARCH>/ under the root against ALLOWED below: a |
| 21 | +dependency outside the list fails, and so does an <OS>/<ARCH> without a list (a new platform |
| 22 | +must be listed consciously). --deny scans every native library under the directories for DENIED |
| 23 | +names only, because GPU classifiers legitimately need their vendor runtime. |
| 24 | +
|
| 25 | +Exit codes: 0 clean, 1 violation, 2 nothing found to check. |
| 26 | +""" |
| 27 | + |
| 28 | +import os |
| 29 | +import struct |
| 30 | +import sys |
| 31 | + |
| 32 | +# What each default-JAR library needed when this check was introduced (5.1.0 plus the RPC backend, |
| 33 | +# which adds nothing: its sockets are libc/libSystem/WS2_32, all already present). |
| 34 | +ALLOWED = { |
| 35 | + "Linux/x86_64": {"libdl.so.2", "libgomp.so.1", "libpthread.so.0", "librt.so.1", "libstdc++.so.6", |
| 36 | + "libm.so.6", "libgcc_s.so.1", "libc.so.6", "ld-linux-x86-64.so.2"}, |
| 37 | + "Linux/aarch64": {"libgomp.so.1", "libstdc++.so.6", "libm.so.6", "libgcc_s.so.1", "libc.so.6", |
| 38 | + "ld-linux-aarch64.so.1"}, |
| 39 | + "Linux/s390x": {"libstdc++.so.6", "libm.so.6", "libgcc_s.so.1", "libc.so.6", "ld64.so.1"}, |
| 40 | + "Linux-Android/aarch64": {"liblog.so", "libm.so", "libdl.so", "libc.so", "libandroid.so"}, |
| 41 | + "Linux-Android/x86_64": {"liblog.so", "libm.so", "libdl.so", "libc.so", "libandroid.so"}, |
| 42 | + "Windows/x86_64": {"ws2_32.dll", "kernel32.dll", "shell32.dll", "advapi32.dll", "vcomp140.dll"}, |
| 43 | + "Windows/x86": {"ws2_32.dll", "kernel32.dll", "shell32.dll", "advapi32.dll", "vcomp140.dll"}, |
| 44 | + "Windows/aarch64": {"ws2_32.dll", "kernel32.dll", "shell32.dll", "advapi32.dll"}, |
| 45 | + "Mac/aarch64": {"/usr/lib/libc++.1.dylib", "/usr/lib/libSystem.B.dylib", |
| 46 | + "/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation", |
| 47 | + "/System/Library/Frameworks/Metal.framework/Versions/A/Metal", |
| 48 | + "/System/Library/Frameworks/MetalKit.framework/Versions/A/MetalKit", |
| 49 | + "/System/Library/Frameworks/Accelerate.framework/Versions/A/Accelerate", |
| 50 | + "/usr/lib/libobjc.A.dylib", |
| 51 | + "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation", |
| 52 | + "/System/Library/Frameworks/Security.framework/Versions/A/Security", |
| 53 | + # KNOWN DEFECT, allowed only so this check reports NEW dependencies: the macOS |
| 54 | + # build picks up the runner's Homebrew OpenSSL, so the shipped dylib does not load |
| 55 | + # on a Mac without `brew install openssl@3`. See TODO.md ("macOS dylib links |
| 56 | + # Homebrew OpenSSL"); remove these two lines with the fix. |
| 57 | + "/opt/homebrew/opt/openssl@3/lib/libssl.3.dylib", |
| 58 | + "/opt/homebrew/opt/openssl@3/lib/libcrypto.3.dylib"}, |
| 59 | +} |
| 60 | + |
| 61 | +# Never acceptable in any artifact: libraries a consumer cannot be expected to have. |
| 62 | +DENIED = ("libibverbs", "librdma", "rdma.dylib", "libmlx") |
| 63 | + |
| 64 | +LIB_NAMES = ("libjllama.so", "jllama.dll", "libjllama.dylib") |
| 65 | + |
| 66 | + |
| 67 | +def elf_needed(data): |
| 68 | + if data[:4] != b"\x7fELF": |
| 69 | + raise ValueError("not an ELF file") |
| 70 | + is64 = data[4] == 2 |
| 71 | + end = "<" if data[5] == 1 else ">" |
| 72 | + if is64: |
| 73 | + shoff = struct.unpack_from(end + "Q", data, 0x28)[0] |
| 74 | + shentsize, shnum = struct.unpack_from(end + "HH", data, 0x3A) |
| 75 | + else: |
| 76 | + shoff = struct.unpack_from(end + "I", data, 0x20)[0] |
| 77 | + shentsize, shnum = struct.unpack_from(end + "HH", data, 0x2E) |
| 78 | + sections = [] |
| 79 | + for i in range(shnum): |
| 80 | + off = shoff + i * shentsize |
| 81 | + if is64: |
| 82 | + _, sh_type, _, _, sh_offset, sh_size, sh_link = struct.unpack_from(end + "IIQQQQI", data, off) |
| 83 | + else: |
| 84 | + _, sh_type, _, _, sh_offset, sh_size, sh_link = struct.unpack_from(end + "IIIIIII", data, off) |
| 85 | + sections.append((sh_type, sh_offset, sh_size, sh_link)) |
| 86 | + out = [] |
| 87 | + for sh_type, sh_offset, sh_size, sh_link in sections: |
| 88 | + if sh_type != 6: # SHT_DYNAMIC |
| 89 | + continue |
| 90 | + strtab = sections[sh_link] |
| 91 | + entry = 16 if is64 else 8 |
| 92 | + for off in range(sh_offset, sh_offset + sh_size, entry): |
| 93 | + tag, val = struct.unpack_from(end + ("qQ" if is64 else "iI"), data, off) |
| 94 | + if tag == 0: |
| 95 | + break |
| 96 | + if tag == 1: # DT_NEEDED |
| 97 | + start = strtab[1] + val |
| 98 | + out.append(data[start:data.index(b"\0", start)].decode()) |
| 99 | + return out |
| 100 | + |
| 101 | + |
| 102 | +def pe_imports(data): |
| 103 | + if data[:2] != b"MZ": |
| 104 | + raise ValueError("not a PE file") |
| 105 | + pe = struct.unpack_from("<I", data, 0x3C)[0] |
| 106 | + nsections = struct.unpack_from("<H", data, pe + 6)[0] |
| 107 | + opt_size = struct.unpack_from("<H", data, pe + 20)[0] |
| 108 | + opt = pe + 24 |
| 109 | + magic = struct.unpack_from("<H", data, opt)[0] |
| 110 | + dd = opt + (112 if magic == 0x20B else 96) |
| 111 | + import_rva = struct.unpack_from("<I", data, dd + 8)[0] |
| 112 | + sec = opt + opt_size |
| 113 | + table = [] |
| 114 | + for i in range(nsections): |
| 115 | + vsize, vaddr, rsize, raddr = struct.unpack_from("<IIII", data, sec + i * 40 + 8) |
| 116 | + table.append((vaddr, max(vsize, rsize), raddr)) |
| 117 | + |
| 118 | + def to_offset(rva): |
| 119 | + for vaddr, size, raddr in table: |
| 120 | + if vaddr <= rva < vaddr + size: |
| 121 | + return rva - vaddr + raddr |
| 122 | + raise ValueError("RVA outside every section") |
| 123 | + |
| 124 | + out = [] |
| 125 | + if import_rva == 0: |
| 126 | + return out |
| 127 | + off = to_offset(import_rva) |
| 128 | + while True: |
| 129 | + name_rva = struct.unpack_from("<I", data, off + 12)[0] |
| 130 | + if name_rva == 0: |
| 131 | + break |
| 132 | + start = to_offset(name_rva) |
| 133 | + out.append(data[start:data.index(b"\0", start)].decode()) |
| 134 | + off += 20 |
| 135 | + return out |
| 136 | + |
| 137 | + |
| 138 | +def macho_dylibs(data): |
| 139 | + magic = struct.unpack_from("<I", data, 0)[0] |
| 140 | + if magic != 0xFEEDFACF: |
| 141 | + raise ValueError("not a 64-bit Mach-O file") |
| 142 | + ncmds = struct.unpack_from("<I", data, 16)[0] |
| 143 | + off = 32 |
| 144 | + out = [] |
| 145 | + for _ in range(ncmds): |
| 146 | + cmd, size = struct.unpack_from("<II", data, off) |
| 147 | + if cmd in (0xC, 0x80000018, 0x8000001F, 0x80000023): # LOAD_DYLIB, WEAK, REEXPORT, UPWARD |
| 148 | + name_off = struct.unpack_from("<I", data, off + 8)[0] |
| 149 | + start = off + name_off |
| 150 | + out.append(data[start:data.index(b"\0", start)].decode()) |
| 151 | + off += size |
| 152 | + return out |
| 153 | + |
| 154 | + |
| 155 | +def dependencies(path): |
| 156 | + with open(path, "rb") as f: |
| 157 | + data = f.read() |
| 158 | + if path.endswith(".so"): |
| 159 | + return elf_needed(data) |
| 160 | + if path.endswith(".dll"): |
| 161 | + return pe_imports(data) |
| 162 | + return macho_dylibs(data) |
| 163 | + |
| 164 | + |
| 165 | +def find_libraries(root): |
| 166 | + for dirpath, _, files in os.walk(root): |
| 167 | + for name in files: |
| 168 | + if name in LIB_NAMES: |
| 169 | + yield os.path.join(dirpath, name) |
| 170 | + |
| 171 | + |
| 172 | +def denied(deps): |
| 173 | + return [d for d in deps if any(bad in d.lower() for bad in DENIED)] |
| 174 | + |
| 175 | + |
| 176 | +def main(argv): |
| 177 | + if len(argv) < 3 or argv[1] not in ("--default", "--deny"): |
| 178 | + print(__doc__, file=sys.stderr) |
| 179 | + return 2 |
| 180 | + mode, roots = argv[1], argv[2:] |
| 181 | + checked = 0 |
| 182 | + failures = [] |
| 183 | + for root in roots: |
| 184 | + for path in sorted(find_libraries(root)): |
| 185 | + deps = dependencies(path) |
| 186 | + checked += 1 |
| 187 | + rel = os.path.relpath(path, root).replace(os.sep, "/") |
| 188 | + print(f"{rel}: {' '.join(deps)}") |
| 189 | + for d in denied(deps): |
| 190 | + failures.append(f"{rel} needs {d}, which no consumer can be expected to have") |
| 191 | + if mode == "--default": |
| 192 | + parts = rel.split("/") |
| 193 | + key = "/".join(parts[-3:-1]) if len(parts) >= 3 else "" |
| 194 | + allowed = ALLOWED.get(key) |
| 195 | + if allowed is None: |
| 196 | + failures.append(f"{rel}: no dependency allowlist for '{key}' -- add one to ALLOWED") |
| 197 | + continue |
| 198 | + for d in deps: |
| 199 | + if d.lower() not in {a.lower() for a in allowed}: |
| 200 | + failures.append(f"{rel} needs {d}, which it did not need before (allowed: {sorted(allowed)})") |
| 201 | + if checked == 0: |
| 202 | + print(f"no native library found under {roots}", file=sys.stderr) |
| 203 | + return 2 |
| 204 | + for f in failures: |
| 205 | + print(f"::error::{f}", file=sys.stderr) |
| 206 | + print(f"{checked} native libraries checked, {len(failures)} violations") |
| 207 | + return 1 if failures else 0 |
| 208 | + |
| 209 | + |
| 210 | +if __name__ == "__main__": |
| 211 | + sys.exit(main(sys.argv)) |
0 commit comments