Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: cachix/install-nix-action@v27
- uses: cachix/install-nix-action@v31
with:
extra_nix_config: |
experimental-features = nix-command flakes
Expand All @@ -35,3 +35,23 @@ jobs:

- name: Run test-framework integration tests
run: nix build '.#checks.x86_64-linux.test-framework-integration'

# The dylib-closure validator that standalone apps run exists only on darwin,
# so the Linux job above cannot reach it.
darwin-test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- uses: cachix/install-nix-action@v31
with:
extra_nix_config: |
experimental-features = nix-command flakes

- uses: cachix/cachix-action@v15
with:
name: logos-co
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'

- name: Run dylib closure check tests
run: nix build '.#checks.aarch64-darwin.darwin-bundle-check' -L
23 changes: 23 additions & 0 deletions docs/external-libraries.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,29 @@ For the plugin:
install_name_tool -change "/old/path/libmylib.dylib" "@rpath/libmylib.dylib" my_module_plugin.dylib
```

#### Prebuilt libraries must not load absolute store paths

A library you copy in prebuilt keeps whatever install names it was linked with,
including absolute `/nix/store` paths for its own dependencies. Those paths are
not dependencies of your module, and the LGX archive a module travels in is a
tar, which nix cannot scan for store paths — so nothing installs them alongside
the module and it fails to `dlopen` on any machine that does not already hold
them. This is macOS-only: `autoPatchelfHook` resolves every ELF `DT_NEEDED` at
build time.

Bundle each such dependency beside its consumer and load it from there:

```bash
# in postInstall
OLD=$(otool -L "$out/lib/libmylib.dylib" | awk '/libdependency/{print $1}')
cp -L "$OLD" "$out/lib/$(basename "$OLD")"
install_name_tool -change "$OLD" "@loader_path/$(basename "$OLD")" \
"$out/lib/libmylib.dylib"
```

Standalone apps enforce this: building one fails when a bundled Mach-O loads a
store path outside the app's closure, and names the file and the path.

### Linux

Libraries are found via `$ORIGIN` RPATH:
Expand Down
6 changes: 6 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@
mkLogosModule = lib.mkLogosModule;
fixturesRoot = ./tests/fixtures;
};
} // nixpkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin {
# Integration test: the dylib-closure validator that standalone apps run
darwin-bundle-check = import ./tests/test-darwin-bundle-check.nix {
inherit pkgs;
darwinBundleCheck = import ./lib/darwinBundleCheck.nix { inherit (nixpkgs) lib; };
};
});

# Development shell for working on the builder itself
Expand Down
71 changes: 71 additions & 0 deletions lib/darwinBundleCheck.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# macOS: nothing an assembled app loads may be missing on the machine that runs it.
#
# Modules reach an app inside LGX archives (see mkStandaloneApp), and nix cannot
# scan a tar for store paths, so an absolute path baked into a bundled Mach-O is
# invisible to reference scanning: nix never registers it, never substitutes it,
# and the plugin fails to dlopen wherever it does not already exist — which
# makes the failure look intermittent, since whether a path happens to be on the
# machine depends on what else that machine built. Linux has no equivalent hole
# because autoPatchelfHook resolves and validates every DT_NEEDED at build time.
{ lib }:

rec {
# `logos-check-dylib-closure <store-paths-file> <root>...` exits non-zero,
# naming every offender, when a Mach-O under any <root> loads an absolute
# store path that <store-paths-file> does not list.
validator = pkgs: pkgs.writeShellApplication {
name = "logos-check-dylib-closure";
runtimeInputs = [ pkgs.darwin.cctools ];
text = ''
paths="$1"
shift

loads=$(mktemp)
for root in "$@"; do
find -L "$root" -type f \( -name '*.dylib' -o -name '*.so' -o -perm -u+x \) -print0 |
while IFS= read -r -d "" macho; do
# `find` also turns up scripts and data, which otool rejects — a
# rejected file simply has no load commands to collect. LC_ID_DYLIB
# is left out on purpose: it names the file itself, not a load.
otool -l "$macho" 2>/dev/null | awk -v macho="$macho" '
$1 == "cmd" { collect = ($2 ~ /^LC_(LOAD_DYLIB|LOAD_WEAK_DYLIB|REEXPORT_DYLIB)$/) }
collect && $1 == "name" && $2 ~ "^/nix/store/" { print macho, $2 }
' || true
done
done > "$loads"

dangling=0
while read -r macho dep; do
# /nix/store/<hash>-<name>/lib/libfoo.dylib -> /nix/store/<hash>-<name>
if ! grep -qxF "$(echo "$dep" | cut -d/ -f1-4)" "$paths"; then
if [ "$dangling" -eq 0 ]; then
echo "error: these libraries load paths that are outside the closure:" >&2
fi
printf ' %s\n loads %s\n' "$macho" "$dep" >&2
dangling=1
fi
done < "$loads"

if [ "$dangling" -ne 0 ]; then
echo >&2
echo "Nothing installs those paths alongside the library that wants them, so it" >&2
echo "will fail to load. Bundle each one beside its consumer and point the load" >&2
echo "command at @loader_path (install_name_tool -change), in the postInstall of" >&2
echo "the module that ships the consumer." >&2
exit 1
fi
'';
};

# Fails to build when a Mach-O under `roots` loads an absolute store path
# outside the closure of `closureRoots` — the paths the app actually realises.
# Returns null off darwin, where there is nothing to check.
check = { pkgs, name, roots, closureRoots }:
if !pkgs.stdenv.hostPlatform.isDarwin then null
else pkgs.runCommand "${name}-dylib-closure" {} ''
${lib.getExe (validator pkgs)} \
${pkgs.closureInfo { rootPaths = closureRoots; }}/store-paths \
${lib.escapeShellArgs (map toString roots)}
touch $out
'';
}
18 changes: 16 additions & 2 deletions lib/mkStandaloneApp.nix
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,28 @@ import json; f=open('$extract_dir/manifest.json'); print(json.load(f).get('name'
'') moduleDeps)}
'';

# Running the app realises exactly the closures named on the command line
# below, so nothing it loads may live outside them.
dylibClosureCheck = (import ./darwinBundleCheck.nix { inherit (pkgs) lib; }).check {
inherit pkgs;
name = dirName;
roots = [ pluginDir ] ++ pkgs.lib.optional hasModuleDeps modulesDir;
closureRoots = [ standalone pluginDir ] ++ pkgs.lib.optional hasModuleDeps modulesDir;
};

# Naming the check in the script is what makes it a dependency of the app, so
# the app cannot be built while it fails.
checkedBy = pkgs.lib.optionalString (dylibClosureCheck != null)
"# dylib closure: ${dylibClosureCheck}\n";

run = pkgs.writeShellApplication {
name = "run-logos-standalone-ui";
runtimeInputs = [ standalone ];
text = if hasModuleDeps then ''
text = checkedBy + (if hasModuleDeps then ''
exec ${standalone}/bin/logos-standalone-app --modules-dir "${modulesDir}" "${pluginDir}" "$@"
'' else ''
exec ${standalone}/bin/logos-standalone-app "${pluginDir}" "$@"
'';
'');
};
in {
type = "app";
Expand Down
27 changes: 27 additions & 0 deletions tests/test-darwin-bundle-check.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Integration test: the darwin dylib-closure validator accepts a bundle whose
# loads are all in the closure it is given, and rejects one whose loads are not.
#
# openssl stands in for a bundled module here: libssl loads libcrypto from an
# absolute store path, which is exactly the shape that goes wrong when a module
# ships a prebuilt library.
{ pkgs, darwinBundleCheck }:

pkgs.runCommand "darwin-bundle-check-test" {
nativeBuildInputs = [ (darwinBundleCheck.validator pkgs) ];
} ''
printf '%s\n' ${pkgs.openssl.out} > complete-paths
logos-check-dylib-closure complete-paths ${pkgs.openssl.out}/lib

: > empty-paths
if logos-check-dylib-closure empty-paths ${pkgs.openssl.out}/lib 2> rejection; then
echo "FAIL: a load outside the closure was accepted" >&2
exit 1
fi
if ! grep -q libcrypto rejection; then
echo "FAIL: the rejection does not name the library that is missing" >&2
cat rejection >&2
exit 1
fi

touch $out
''
Loading