* feat(pe): apply hostLibs on the Windows path, and make the claim checkable
HOST_LIBS reached the derivation and then changed nothing on the PE path:
`is_host_lib` was consulted at one single Windows site (the `qt_is_host`
test), while the sweep and Phase 6 asked only `is_windows_system_dll`. For
a Logos module that meant ~36 MB of Qt/OpenSSL/C++ runtime the host already
ships inside every package.
Three parts, and none of them works alone:
* the STRIP, at the top of every sweep, so a host-provided DLL is neither
carried nor made a root whose own imports get chased into the closure;
* the SKIP, at the import level in the sweep, so the closure cannot put
back what the strip removed;
* the ACCEPTANCE, in Phase 6 — both the import verifier and the "every
source bin/ entry survived" check. Without it, stripping makes the
verifier fail on the very DLLs it was told not to carry. Demonstrated
with the two acceptance arms deleted from the tree under test:
`bin/demo_app.exe imports libstdc++-6.dll, which is in neither its own
directory nor bin/ nor the closure` — 5 violations on the app shape,
2 on the module shape.
One `hostLibs` list for all targets, with the caller writing the target's
own spelling; only the MATCH is platform-specific (the PE side folds case
on both sides, because import tables carry KERNEL32.DLL and KERNEL32.dll in
one bundle). The spellings under-match rather than over-match across
platforms — `libz*` cannot match `zlib1.dll` — and under-stripping ships an
inert duplicate where over-stripping is fatal, so the accident falls on the
safe side. Measured: a Windows bundle given Unix globs is byte-identical to
one given none, and says so.
New `hostBundle` makes the list checkable instead of declared. Every entry
is a promise about another repo's output and a broken promise is silent
(ERROR_MOD_NOT_FOUND, with Qt naming the plugin rather than the DLL). Point
it at the host's own bundle and every dropped or host-accepted name has to
be in that tree's APPLICATION directory — not merely somewhere in it, which
is the shape of the mistake. Optional; unverified claims are listed as such.
Fails closed on five new arms, each demonstrated firing: a claim the host
does not keep, a host bundle with no PE in its application directory, a
hostBundle that is not a directory, and a pattern that strips the package's
own payload (both bundle shapes).
Both shapes work: bin/ 4 files -> 1, and lib/<name>.dll with no bin/,
4.8 MB -> 60 KB. The module shape also now builds with no extraClosurePaths
at all, where the same input without hostLibs fails with 2 unresolved
imports.
Non-Windows is untouched: 9 subjects (plain ELF CLI, Qt app, Qt app WITH
hostLibs, empty bin/, no bin/, builtins.storePath, nested extraDirs, and
two subjects that must fail) are byte-identical to origin/main in both
output tree and stdout. A null-change control (base + a comment) reproduces
that; a positive control (this tree + one echo and one marker file) is
flagged on both tree and log.
tests/pe-hostlibs.nix carries the contract: 5 subjects that must build and
assert the resulting PE set, 5 that must fail. Wired into `checks`/`tests`
on x86_64-linux only, since every subject is a pkgsCross.mingwW64 build.
* fix(pe): bound the hostLibs strip by provenance, and check hostBundle
The strip landed as "delete every PE whose name a hostLibs pattern matches",
which is not the operation `hostLibs` names on the other two targets. On ELF
and Mach-O the list only ever FILTERS WHAT GETS ADDED -- `is_host_lib` is read
inside `trace_deps`, and nothing on that path removes something Phase 1 wrote.
The PE path has to delete, because win-dll-link.sh has already staged the
import closure into the derivation's own output before the bundler runs. Those
are different operations and conflating them is why one pattern is inert on
Unix and destructive on Windows.
THE RULE: the strip may only remove a file that came along WITH the package --
one the bundler staged, or one win-dll-link.sh linked in from another store
path -- never a file the derivation's own build produced. The signal needs no
heuristics: win-dll-link stages by `ln -sr`, a symlink into another store path,
while a build's own products are real files in its own output. Phase 1 copies
with `cp -aL`, so the new Phase 1d census asks $DRV_PATH before that
distinction is lost, and everything the bundler adds afterwards is by
construction not payload.
Two measured defects close on that one rule, and neither closes on a rule about
pattern syntax:
* `bundlers.qtPlugin` appends `Qt*` to hostLibs ITSELF -- a pattern the
caller never sees -- and the PE match folds case on both sides, so it reads
`qt*` and matches `qtquick2plugin.dll`. Measured on the real Windows Qt
bundle: 816 PE files in, 796 out, exit 0.
* the strip walked `find "$out"` with no exception for `extraDirs`. Measured:
a module with `extraDirs = [ "share/assets" ]` holding a DLL shipped that
directory EMPTY, rc=0. Exempting extraDirs wholesale would be wrong too --
the sweep legitimately stages a dependency into an extraDir when the
importer lives there. Provenance separates them; a path prefix cannot.
Both are now subjects, and both fail against the pre-fix tree.
Consequences, each of which is its own defect:
* the import arms (sweep and Phase 6) no longer claim a name from the host
when the bundle already satisfies it -- under the payload rule a
host-matched DLL can legitimately still be there, and claiming it as well
failed the build over a file sitting in the directory being checked;
* Phase 6's "every source bin/ entry survived" arm excuses a loss only if
THIS build removed that path (`pe_stripped`), not if the name re-matches
hostLibs, which is now a different question;
* `hostBundle` is validated whenever it is given. It used to be accepted and
never looked at whenever hostLibs was empty -- `pe_strip_host_libs`
returned before `pe_host_index`, so obvious garbage produced a green build
and no output. It is also refused on a non-Windows target, at evaluation
time where Nix knows the answer and in Phase 1c where it does not.
Three comments asserted things that are false, and each is corrected in place
rather than deleted, with what is actually true:
* "Unix globs under-match on Windows, so the accident falls on the safe side"
(bundle.sh, mkBundle.nix, README). `libcrypto*` DOES match
`libcrypto-3-x64.dll`. Safety comes from the payload rule, hostBundle and
the floor -- not from the shape of a glob.
* "this cannot be reached with a host-claimed name" over the Qt-detection
arm. It is reached, it flips qt_is_host, and `pe-hostlibs-payload-qt` vs
`pe-hostlibs-qt-not-host` is the difference between a build and a hard
error.
* "this is the ONLY place a claim is visible" in Phase 6. The sweep's own arm
reports it one phase earlier on exactly the shape named.
The floor (`kept == 0`) is now a backstop rather than the primary guard, and is
demonstrated on the one shape that can still reach it: a derivation whose whole
output is links into other store paths, which by this reading has no payload.
Fixtures now model win-dll-link's real shape (`ln -s` into another store path)
and distinguish it from a derivation that COPIED the same DLLs in; the two must
be treated differently and only one of them was represented before.
CI: `ci.yml` runs tests/smoke.sh and nothing else, so the entire PE contract --
wired only into `checks`/`tests` -- was built by nobody. smoke.sh now builds
all ten must-build subjects and all eight must-fail ones, asserting for each
refusal WHICH arm produced it, and announces the skip on non-x86_64-linux
rather than passing silently.
Non-Windows is untouched: 17 subjects (plain ELF CLI, launcher path, hostLibs
hit and miss, no bin/, empty bin/, shebang bin/, builtins.storePath, jq strict
and permissive, custom systemLibs, no default systemLibs, flat and nested
extraDirs, extraClosurePaths, libxkbcommon, and a subject that must fail) are
byte-identical to origin/main in output NAR and build log. The single
difference is an error message quoting the failing bundle's own store path, and
a null-change control (origin/main plus one comment) reproduces it exactly. A
positive control (this tree plus one echo and one marker file) is flagged on 16
of the 17.
* docs(pe): record what real Windows says about the hostLibs premise
The strip's whole premise is "the host provides this, so do not ship it", and
until now nothing had run it on Windows -- every claim in this feature was
build-time only. It was measured on Windows 11 x86-64: a module bundle stripped
to 52,728 bytes (the same module unstripped is 4,972,150), a host tree carrying
the C++ runtime beside logos-module's real cross-built lm.exe, and a loader
that varies ONLY the LoadLibraryEx search flags.
flags stripped unstripped control
0x0000 default order LOADS LOADS
0x0008 LOAD_WITH_ALTERED_SEARCH_PATH 126 LOADS
0x0100 SEARCH_DLL_LOAD_DIR alone 126 LOADS
0x1100 DLL_LOAD_DIR|DEFAULT_DIRS LOADS LOADS
The premise HOLDS on the mode that matters: 0x1100 is verbatim what
logos-module's preloadPluginWithOwnDirSearch (src/win_dll_search.h) passes, and
under it the stripped module loads and its exported function returns a heap
std::string built by concatenation plus a caught exception's what() -- so code
out of libstdc++-6.dll really executed, from a DLL the bundle does not contain.
Two controls make that mean something. Removing ONE DLL from the host turns
0x1100 into ERROR_MOD_NOT_FOUND, so the DLL demonstrably came from the host's
directory; and in that same broken host the UNSTRIPPED bundle still loads, so
the failure is attributable to the strip rather than to the machine. Without
that second control a pass proves nothing, because a bundle that ships
everything passes either way.
The finding worth writing down is the two failing rows. A stripped bundle is
loadable only under a search mode that still includes the host process's own
directory, and both LOAD_WITH_ALTERED_SEARCH_PATH and SEARCH_DLL_LOAD_DIR-alone
SUBSTITUTE the module's directory for it. So `hostLibs` on PE is conditional on
a property of the HOST, and no build-time check can see it: hostBundle proves
the DLL is there, not that anyone will look. That condition was nowhere in this
repo. It is now in the README (with the table), in mkBundle.nix beside
hostBundle, and in the bundler's own success-path ledger, where it is printed
next to the list of DLLs being relied on.
Nothing behavioural changed on any platform bar one added PE-only log line, and
the 17 non-Windows subjects are byte-identical to origin/main again on this
tree.
* fix(pe): scope the hostLibs strip by DECLARATION, not by provenance
The payload census is gone. It classified "regular file in $DRV_PATH = the
build produced it, symlink = a dependency travelling with it", and on the shape
this feature exists for that reading is simply wrong: in the real
logos-package_manager-module x86_64-w64-mingw32 output, 8 of the 19 DLLs in lib/
(icudt76, icuuc76, libstdc++-6, libgcc_s_seh-1, libmcfgthread-2, libsodium-26,
zlib1 ...) are REGULAR FILES. The rule called the entire host runtime payload
and stripped nothing. It also still emptied an extraDirs entry whose DLL
happened to be a symlink.
In its place, three rules that infer nothing:
* a CALLER's pattern deletes — including one that matches the package's own
build products. `hostLibs = [ "Qt*" ]` means what it says;
* a BUNDLER-injected pattern never reaches the PE path: flake.nix's qtPlugin
appends `Qt*` only on a non-Windows target, where hostLibs can only filter
what gets ADDED. That is where the 816-PEs-in/796-out silent deletion came
from, closed at the source;
* `extraDirs` is never touched. No under-strip follows: a host-matched import
is skipped BEFORE the closure lookup, so the sweep never stages one into an
extraDir in the first place.
An app-shaped bundle is now REFUSED the moment hostLibs would drop anything.
The premise under every claim is that the loading process's own directory is the
host's; when the bundle has an .exe that directory is the bundle's own bin/.
Measured on Windows 11: such a bundle dies 0xC0000135 before main() with no
output, and runs only with the host's bin\ on PATH. Detection is by
IMAGE_FILE_HEADER.Characteristics, not by the .exe suffix.
Defects closed alongside:
* the sixth false comment, written inside the fix for the fifth: a non-PE file
named Qt6Core.dll does NOT reach the Qt-detection arm — the loop's own
`[ -f ] && ! pe_is_pe` skips it. Only a DIRECTORY falls through, and that
is now the demonstrating subject;
* README's "extraDirs is never touched" is true rather than deleted;
* the Phase 1d comment goes with the census, no fragment left;
* two refusal arms nothing reached now have subjects: hostBundle on an
unknown target (stdenv removed) and hostBundle that is not a directory;
* smoke.sh's must-fail table said TAB over an IFS='|' reader.
The LoadLibraryEx table in README/mkBundle/bundle.sh is REPLACED, not annotated.
Re-measured on Windows 11 against the real module with the unstripped bundle as
control: only two rows are attributable to stripping at all, 0x0000 fails on the
control too (a real module keeps private DLLs beside it), and 0x0008 loads when
the host's CWD is its own bin\. The old table came from a synthetic one-DLL
module and reported 0x0000 as loading.
Evidence: 22/22 PE subjects (11 build + 11 refused, each by the message its own
arm prints); the three new guards each demonstrated FIRING by removing them one
at a time; Unix path byte-identical to origin/main across 9 ELF subjects (NAR +
`nix log` stdout), with a positive control flagging all 9 and a null-change
control flagging none; and on real Windows the real module 86M -> 16M, loading
under 0x1100 against its host, failing 126 against a host missing one claimed
DLL while the unstripped control loads from that same broken host.
* fix(pe): test hostLibs before the mirror WRITES, and widen the app refusal
Seven comments on this branch have now described code that is not there, and
two more of them were found by build. This closes both defects, aligns the two
documents that overstated the second one, and applies one rule to every
reachability claim on the PE path: say what is KNOWN to reach an arm, prove it
where the code's structure allows, and never assert a census.
A. ORDERING (behaviour). pe_sweep's beside-the-importer MIRROR is a WRITE — it
copies a DLL out of the importer's own directory into bin/ — and it ran
before the import-level `pe_is_host_lib` test. With `extraDirs` holding an
importer and its host-matched dependency, the strip removed the bin/ copies
and the sweep put them straight back, out of the one directory rule 2 says
it may not touch. Measured on 99a4190: the strip printed
`- bin/libstdc++-6.dll (host-provided)`, the sweep printed
`~ libstdc++-6.dll mirrored into bin/ from share/assets`, the bundle shipped
it, exit 0. The comment on pe_strip_host_libs asserted the opposite. Fixed
by order: the host test now runs before the mirror, with an explicit
"satisfied in place, not claimed from the host, not mirrored" arm. New
subject `extraDirsNotMirrored`; demonstrated firing by putting the old order
back (the pre-existing `extraDirsKept` subject stays green under that
mutation, which is why a new one was needed).
B. THE APP REFUSAL now covers what README.md and mkBundle.nix already claimed.
Coding up to the claim rather than narrowing it: the premise is "this bundle
can be the process", the loader searches whichever directory the .exe sits
in, and a module-shaped output with lib/foo.exe and no bin/ fails in exactly
the documented way — silently. pe_resolve_own_exe walks the whole tree, and
is resolved once before the first removal so the answer cannot depend on
which claim was adjudicated first. New must-fail subject `appInLibRefused`.
C/D. flake.nix. "On Darwin the cross stdenv does not evaluate at all" is false
(aarch64-darwin: isWindows true, drvPath real, and moduleStripped BUILDS
green there), and so is "on aarch64-linux the toolchain is not
substitutable" (dry-run: 5 to build, 68 to fetch). The gate stays, for the
true reason — cost plus evidence, matching smoke.sh. The "built by hand"
note was stale in the commit that wrote it; smoke.sh builds all of them.
E. pe_in_extra_dir pointed at "Phase 1b's copy loop"; the normalisation is
beside the unlabelled extraDirs copy loop in Phase 1.
F. pe_host_declared_total was assigned while pe_host_dropped_total accumulated.
Both are now sizes of path-keyed sets. Not cosmetic: on the shape defect A
was found on, the accumulating counter reported "6 PE(s) removed" for three
files. (First cut of this hit `set -u` on `${#arr[@]}` over an empty
associative array — the guarded form is now used, as elsewhere in the file.)
G. Two claims "stated as measured" that a control falsifies: 0x0100 fails 126
on the UNSTRIPPED bundle too, so it is not attributable to the strip; and a
refused app bundle also runs with CWD = the host's bin\, not only with it on
PATH. Both tables now say what was measured, on which subject.
Evidence: non-Windows tree AND stdout byte-identical to origin/main across 17
real subjects (the single log diff is a store path inside an error message and
reproduces on a null-change control); positive control flags 16 of those 17;
23/23 PE contract subjects; smoke.sh 38 passed with the same 2 environmental
failures the base tree has. Real Windows 11 AMD64, logos-package_manager-module
packages.x86_64-windows.lib (89.4M unstripped -> 16.6M, 19 PEs -> 3): 0x1100
loads stripped and unstripped, 0x0008 fails 126 stripped and LOADS unstripped,
0x0008 with CWD = host bin\ loads, 0x0000/0x0100/0x1000 fail on both, and a
host missing Qt6Core.dll fails 126 stripped while the control still loads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(pe): stop claiming exhaustiveness, and correct the eighth false comment
Round 4 adopted the "state what is KNOWN to reach an arm, do not claim it is
the only thing" policy and then produced another false comment anyway -- in the
fixture whose own comment says bundle.sh "has carried three different wrong
claims about that arm's reachability; this is what makes the current one
checkable". That fixture then said SHAPE 1g "is the one shape that still
reaches" the arm. A subject that exists to make a claim checkable is the last
place to assert a census. It now says it makes ONE route checkable and is not
a census, and the running count is corrected to four.
Six more, each falsified by build or by reading the code it describes:
* Phase 2f's no-bin/ arm replaced "Unreachable as written" with a PROOF whose
premise was "Nothing in this script creates bin/". bundle.sh:66 is
`mkdir -p "$out/bin"`. The proof is salvageable and now says what is true:
that mkdir is guarded on `[ -d "$DRV_PATH/bin" ]`, so the script MIRRORS the
source's bin/ and never invents one -- which is the property the arm needs.
* "A pattern a BUNDLER injects never reaches the PE path" appeared unqualified
in four places, supported by exactly one bundler: this repo's own qtPlugin.
nix-bundle-lgx injects a twelve-entry hostLibs list and is kept off this path
by ROUTING, not by rule. All four now say so, and name what has to be
re-read if that routing changes.
* Two "byte-identical, down to the byte" claims stated a measured result
without saying what was measured. They now name the subject set, the null-
change control used to attribute log noise, and that this is evidence over
those subjects rather than a proof over all inputs.
* Defect G was corrected in bundle.sh, mkBundle.nix and README but not in
tests/pe-hostlibs.nix, which still said the app shape "runs only with the
host's bin/ on PATH" -- falsified by the same round's own measured row
(CWD = the host's bin\ also runs it, because the EXE search order includes
the current directory).
* README's "`extraDirs` is never touched" reads two-directional; the guarantee
is one-directional. Nothing is REMOVED from a declared directory, but the
sweep may still STAGE a dependency into one when the importer lives there --
which is what makes such a bundle loadable, so it is worth saying rather than
hiding.
No code changes. bundle.sh's behaviour is untouched: `bash -n` clean and the
diff is comments, one README section and two fixture comments.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
nix-bundle-dir
Bundle a Nix derivation and all its dependencies into a self-contained, portable directory. Like nix-bundle and nix-appimage, but produces a plain directory instead of a single-file executable, and works on both macOS and Linux.
Getting started
To use this, you will need to have Nix available with flakes enabled.
Then, run this via the nix bundle interface, replacing nixpkgs#hello with the flake you want to bundle:
$ nix bundle --bundler github:logos-co/nix-bundle-dir nixpkgs#hello
This produces a hello-bundle directory containing bin/hello and all its shared library dependencies:
$ ./hello-bundle/bin/hello
Hello, world!
The directory is fully self-contained — you can copy it to any compatible machine and it will work without Nix installed.
Permissive mode
By default, the bundler fails if it finds /nix/ paths embedded in binary data (e.g. compiled-in store paths). If you need to bundle packages that have such references and are okay with them, use the permissive bundler:
$ nix bundle --bundler github:logos-co/nix-bundle-dir#permissive nixpkgs#some-package
This turns those errors into warnings.
Using as a library
The flake also exposes lib.<system>.mkBundle for more control:
let
bundler = inputs.nix-bundle-dir;
bundle = bundler.lib.${system}.mkBundle {
drv = pkgs.hello;
name = "hello"; # optional, defaults to drv.pname or drv.name
excludeLibs = [ "libfoo*" ]; # optional, glob patterns for libs to skip
useDefaultExcludes = true; # optional, include the built-in exclude list (default: true)
warnOnBinaryData = false; # optional, treat embedded /nix/ strings as warnings instead of errors
};
in bundle
See mkBundle.nix for the full interface.
Libraries the host already ships (hostLibs, hostBundle)
A bundle that is loaded into another program — a plugin, a module installed
next to a host application — should not carry a second copy of the runtime that
host already provides. hostLibs is that list, and it applies on every target,
Windows/PE included:
mkBundle {
drv = myModule;
# Written in the TARGET's own spelling. There is one list, not one per
# platform.
hostLibs = [ "Qt*.dll" "libstdc++-*.dll" "libgcc_s_*.dll" "zlib1.dll" ];
# Optional, PE only: the host's own bundle. Every name the bundler drops (or
# accepts as the host's to satisfy) must be in that tree's application
# directory — `bin/` if it has one, else the root — because that is the
# directory Windows searches for the loading process.
hostBundle = myHostApp;
}
Only for a bundle that is loaded into something else. On Windows a
host-provided DLL is found because the loader searches the loading process's
own directory. That is the host's directory when this bundle is a module
(lib/<name>.dll, no executable of its own) and it is the directory this
bundle's .exe sits in when the bundle has one — where the stripped DLLs no
longer are. So a hostLibs claim on a bundle that ships an executable is
refused at build time, wherever in the bundle that executable is: the check
is "does any PE in this tree have an executable image header", not "is there an
.exe in bin/". Measured: such a bundle dies with 0xC0000135 before
main() and prints nothing. It runs only if the deployment puts the host's
bin\ somewhere the loader searches for that process — prepended to PATH,
or as the current directory, which is in the EXE search order too — and that is
not something a build-time check can see.
What hostLibs may remove. On ELF and Mach-O it filters what the bundler
adds: a traced dependency matching the list is not copied in. On Windows the
bundler also has to delete, because nixpkgs' win-dll-link.sh has already
staged the import closure into the derivation's own output before the bundler
sees it. Deleting is the more dangerous operation, so it is bounded by who
declared what:
- A pattern you write deletes.
hostLibs = [ "Qt*" ]on a Qt plugin package removes that package's ownQt6*.dlltoo. That is the contract, not an accident — andhostBundleis how you make it checkable. - No bundler in this repo injects one that does.
bundlers.<sys>.qtPluginappendsQt*for you, and only on non-Windows targets, wherehostLibscannot delete anything. That is a statement about this repo's bundlers, not about every caller:nix-bundle-lgxinjects a list of its own and is kept off this path by routing rather than by rule. - Nothing is removed from
extraDirs. Those directories are named by you, one by one, as "carry this"; a name glob does not overrule that. Note the guarantee is one-directional: the sweep may still STAGE a dependency into a declared directory when the importer lives there, which is what makes such a bundle loadable. - A strip that would empty the bundle fails the build.
Do not read anything into the shape of the patterns. Cross-platform spellings
can over-match — libcrypto* matches libcrypto-3-x64.dll — and the safety
comes from the rules above, from hostBundle, and from the log naming every
dropped file.
The host must also LOOK there. A stripped module finds the host's DLLs
because Windows searches the loading process's own directory, and some
LoadLibraryEx flags remove precisely that directory from the search.
Measured on Windows 11 x86-64 (AMD64) against the real
logos-package_manager-module — packages.x86_64-windows.lib, bundled by this
bundler: 19 PEs examined, 16 removed, 3 kept — a host bundle shipping those 16
in its bin\, and the unstripped bundle of the same module as the control.
The loading process's current directory is a scratch directory holding none of
the DLLs unless a row says otherwise:
| flags | what it means | stripped | control | what the pair says |
|---|---|---|---|---|
0x1100 |
…DLL_LOAD_DIR|…DEFAULT_DIRS |
loads | loads | the mode Logos uses; the strip is invisible |
0x0008 |
LOAD_WITH_ALTERED_SEARCH_PATH |
fails, 126 | loads | the strip, and only the strip |
0x0008 |
same, CWD = the host's own bin\ |
loads | — | the current directory is still in that order |
0x0000 |
default search order | fails, 126 | fails, 126 | not the strip |
0x0100 |
…SEARCH_DLL_LOAD_DIR alone |
fails, 126 | fails, 126 | not the strip |
0x1000 |
…SEARCH_DEFAULT_DIRS alone |
fails, 126 | fails, 126 | not the strip |
0x1100 |
host missing one claimed DLL | fails, 126 | loads | the claim is load-bearing |
0x1100 is what logos-module's preloadPluginWithOwnDirSearch
(src/win_dll_search.h) passes, which is why Logos modules may be stripped.
Read the table as a statement about which directories a mode searches, not as
one verdict per flag — the three "not the strip" rows are why. A real module
keeps private DLLs of its own beside it, and the default order does not search a
loaded DLL's own directory at all, so 0x0000 fails on the unstripped bundle
too; 0x0100 and 0x1000 each name only half of what is needed. And 0x0008
replaces the application directory while leaving the rest of the standard order
in place, current directory included — so the same host, started from its own
bin\, loads the same stripped module through 0x0008.
(An earlier revision of this table was measured on a synthetic single-DLL module
and reported 0x0000 as loading. That is true only for a module with no private
dependencies of its own, which no real Logos module is.)
Nothing at build time can see which mode a host will use, or where it will be
started from, so this is a property of the host you must check once, by hand —
hostBundle checks that the DLL is there, not that anyone will look.
Every hostLibs entry is a promise about another package's output, and on
Windows a broken promise is silent — LoadLibrary fails with
ERROR_MOD_NOT_FOUND (126) and Qt reports only "The specified module could not
be found", naming the plugin rather than the DLL that is absent. hostBundle
turns that promise into a build-time check; without it the build still works and
the log lists every claim, marked UNVERIFIED. Passing hostBundle with an
empty hostLibs, or on a non-Windows target, is refused rather than ignored.
Caveats
-
Graphics/OpenGL on Linux. GPU driver libraries (
libGL,libEGL,libvulkan, etc.) are excluded by default because they must match the host's hardware drivers. This is the same well-known problem that affects AppImages and other bundling approaches. You may need nixGL or similar. -
glibc on Linux. Core glibc libraries (
libc.so,libpthread.so,ld-linux*.so, etc.) are excluded by default because they must match the host kernel. The bundled binaries will use the host's glibc. -
Library-only packages. If the derivation has no
bin/directory, onlylib/contents are bundled. This is useful for bundling shared libraries for use by other programs. -
Shebangs. Scripts with
#!/nix/store/...shebangs are rewritten to#!/usr/bin/env ..., which requires the interpreter to be onPATH.
Default library excludes
On Linux, a set of host-dependent libraries are excluded from bundling by default (inspired by the AppImage excludelist):
- glibc — must match the host kernel
- libstdc++/libgcc_s — C++ runtime
- GPU/graphics — libGL, libEGL, libvulkan, libdrm, etc.
- Display server — libX11, libxcb, libwayland
- Audio — libasound, libjack, libpipewire
- Fonts — libfontconfig, libfreetype, libharfbuzz
On macOS, system libraries under /usr/lib/ and /System/Library/ are implicitly excluded since the dependency tracer only follows /nix/store/ paths.
Set useDefaultExcludes = false in mkBundle to disable these and bundle everything.
Under the hood
The bundler is a Nix derivation that runs a six-phase shell script:
- Copy executables and libraries from the derivation's
bin/andlib/ - Trace shared library dependencies recursively (
otoolon macOS,patchelfon Linux), resolving@rpathreferences and searching the Nix closure - Rewrite all dynamic linking references to use relative paths (
@loader_pathon macOS,$ORIGINon Linux) and remove all absolute rpaths - Re-sign Mach-O binaries (macOS only — required after any modification)
- Rewrite shebangs from
/nix/store/...to#!/usr/bin/env ... - Verify portability by copying the output to a temp directory (outside
/nix/store) and checking that all references are portable
On Linux, phase 3 also does two things worth knowing about as output contract:
- It sets each ELF's interpreter to the path its psABI mandates
(
/lib64/ld-linux-x86-64.so.2on x86-64,/lib/ld-linux-aarch64.so.1on aarch64). Every glibc distro provides those exact paths, so the binaries run directly — there is no launcher indirection throughld.so. Note the two prefixes deliberately differ;/lib64for both would break arm64. - It writes
DT_RPATHrather thanDT_RUNPATH, so bundled libraries take priority over a staleLD_LIBRARY_PATHinstead of losing to it. A consequence worth stating: you can no longer override a bundled library viaLD_LIBRARY_PATH. (nixGLstill works — GPU libraries are on the exclude list, so they are absent from$ORIGIN/../liband the search falls through to your environment.)
Some bundles additionally get a launcher at bin/<name>, with the real
binary beside it as bin/.<name>.elf. It is emitted only when guiApp is set
(the default) and the bundle needs an environment variable that its libraries
cannot derive on their own —
XKB_CONFIG_ROOT (libxkbcommon has its config root baked to a store path) or
QT_QPA_PLATFORMTHEME (the portal file-dialog theme must be chosen by name).
It is a plain exec wrapper, not an ld.so trampoline, so /proc/self/exe is
the real binary rather than the loader. argv[0] is the launcher where the
host shell can preserve it — the wrapper uses exec -a if its own /bin/sh
supports it, else bridges through bash; on a host with neither (no exec -a,
no bash) it falls back to a plain exec and argv[0] becomes the real ELF
path. If you copy a bundle's bin/ by hand, copy the dotfiles too.
Pass guiApp = false for a bundle that never puts anything on screen — a
headless CLI, a daemon — and its bin/ entries stay plain binaries with no
companion. Leave it alone for anything that renders, including a host process
that only loads UI plugins at runtime: the bundler cannot detect that case (the
app's DT_NEEDED never names libxkbcommon, since the platform plugin
dlopens it), and getting it wrong that way is a segfault rather than a
cosmetic wart.