Resolve security audit findings

ober

3123d77c3248d5b753b02d49a322b6ece39b0af0

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d68c952..270e774 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ jobs:
   build-test-audit:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Install system tools
         run: |
diff --git a/.github/workflows/security-baseline.yml b/.github/workflows/security-baseline.yml
index 28a713e..5381c31 100644
--- a/.github/workflows/security-baseline.yml
+++ b/.github/workflows/security-baseline.yml
@@ -13,7 +13,7 @@ jobs:
   baseline:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Required release files
         run: |
diff --git a/Makefile b/Makefile
index 0398897..3bbc6dc 100644
--- a/Makefile
+++ b/Makefile
@@ -16,7 +16,7 @@ LIBS ?=
 SHIM = jerboa_inotify_shim.so
 UNAME_S := $(shell uname -s)
 
-.PHONY: all build transpile test test-linux clean shim ensure-jerboa-tools audit \
+.PHONY: all build transpile test test-linux test-loader-policy clean shim ensure-jerboa-tools audit \
 	platform-check platform-evidence security sbom reproducibility-report verify release-evidence
 
 all: build
@@ -51,11 +51,16 @@ test: transpile
 		echo "Skipping inotify runtime tests on $(UNAME_S); CI runs them on Linux."; \
 	fi
 
-test-linux: build
-	JERBOA_INOTIFY_LIB=$(CURDIR) \
+test-linux: build test-loader-policy
+	JERBOA_INOTIFY_DEV_NATIVE=1 JERBOA_INOTIFY_LIB=$(CURDIR) \
 	LD_LIBRARY_PATH=$(CURDIR) \
 	$(JERBUILD) exec --libdirs "$(LIBDIRS)" tests/inotify-test.ss
 
+test-loader-policy: build
+	@REPO_ROOT="$(CURDIR)" JERBUILD="$(JERBUILD)" \
+	  LIBDIRS="$(LIBDIRS)" SHIM="$(CURDIR)/$(SHIM)" \
+	  sh tests/loader-policy.sh
+
 audit: build
 	@echo "==> Platform"
 	@uname -srm
diff --git a/README.md b/README.md
index c29c558..64d4de8 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,12 @@ make release-evidence
 On non-Linux hosts, `make test` transpiles the Jerboa source and skips runtime
 tests. CI runs the real kernel tests on Ubuntu.
 
+Dynamic builds must set `JERBOA_INOTIFY_LIB` to the absolute path of the
+trusted directory containing `jerboa_inotify_shim.so`. The loader rejects
+relative paths, current-working-directory searches, bare library names, and a
+final shim path that is a symbolic link. Statically linked hosts are detected
+from the registered native entry points and do not need this variable.
+
 If `jerbuild` is not installed, the Makefile bootstraps the pinned Jerboa
 release into `.jerboa/bin`.
 
diff --git a/jerboa_inotify_shim.c b/jerboa_inotify_shim.c
index 18634fc..2c867ce 100644
--- a/jerboa_inotify_shim.c
+++ b/jerboa_inotify_shim.c
@@ -102,13 +102,14 @@ int jerboa_inotify_poll(int fd, int timeout_ms) {
 }
 
 int jerboa_inotify_close(int fd) {
-    int rc;
-
     if (fd < 0) return -EINVAL;
-    do {
-        rc = close(fd);
-    } while (rc < 0 && errno == EINTR);
-    if (rc < 0) return -errno;
+    /* On Linux the descriptor is consumed by the first close call even when
+     * EINTR is reported. Retrying can close an unrelated descriptor that was
+     * allocated with the same number by another thread. */
+    if (close(fd) < 0) {
+        if (errno == EINTR) return 0;
+        return -errno;
+    }
     return 0;
 }
 
diff --git a/scripts/security-check.sh b/scripts/security-check.sh
index 5fe2783..ae58a1b 100755
--- a/scripts/security-check.sh
+++ b/scripts/security-check.sh
@@ -64,6 +64,13 @@ if ! rg -q 'load-shared-object[*]' src ||
   fail=1
 fi
 
+if ! test -f tests/loader-policy.sh ||
+   ! grep -q 'JERBOA_INOTIFY_DEV_NATIVE' src/jerboa-inotify.ss ||
+   ! grep -q 'native-loader-validate-library!' src/jerboa-inotify.ss; then
+  printf '%s\n' "inotify shared-loader trust policy or regression is missing" >&2
+  fail=1
+fi
+
 if ! grep -q 'IN_NONBLOCK | IN_CLOEXEC' jerboa_inotify_shim.c ||
    ! grep -q 'bytes_have_nul' jerboa_inotify_shim.c ||
    ! grep -q 'event_at' jerboa_inotify_shim.c; then
@@ -71,6 +78,16 @@ if ! grep -q 'IN_NONBLOCK | IN_CLOEXEC' jerboa_inotify_shim.c ||
   fail=1
 fi
 
+if rg -U -q 'do[[:space:]]*\{[^}]*close\([^)]*\)[^}]*\}[[:space:]]*while[^;]*EINTR' jerboa_inotify_shim.c; then
+  printf '%s\n' "close(2) must never be retried after EINTR" >&2
+  fail=1
+fi
+
+if rg -q '"(\./)?jerboa_inotify_shim[.]so"|JERBOA_INOTIFY_LIB[^\n]*"[.]"' src/jerboa-inotify.ss; then
+  printf '%s\n' "native shim loading must not search CWD or a bare filename" >&2
+  fail=1
+fi
+
 private_pattern='(/Users/|~/mine|\$\(HOME\)/mine|git@|users-MacBook-Pro)'
 private_matches="$(rg -n -I -e "$private_pattern" . \
   --glob '!dist/**' \
diff --git a/src/jerboa-inotify.ss b/src/jerboa-inotify.ss
index 14055af..64107f4 100644
--- a/src/jerboa-inotify.ss
+++ b/src/jerboa-inotify.ss
@@ -21,7 +21,12 @@
     IN_IGNORED IN_ISDIR IN_Q_OVERFLOW IN_UNMOUNT)
 
   (import (jerboa prelude)
-          (only (jerboa ffi) c-lambda load-shared-object*))
+          (only (jerboa ffi) c-lambda load-shared-object*)
+          (only (std native-loader)
+                native-loader-privileged?
+                native-loader-development-enabled?
+                native-loader-validate-library!
+                native-loader-validate-directory!))
 
   ;; Linux uapi values are stable. Literal constants avoid import-time native
   ;; calls and keep static-binary feature probes safe.
@@ -61,23 +66,23 @@
       (load-shared-object* path)
       #t))
 
-  (def (try-load-any! paths)
-    (let loop ([paths paths])
-      (cond
-        [(null? paths) #f]
-        [(try-load-one! (car paths)) #t]
-        [else (loop (cdr paths))])))
-
-  (def (native-search-paths)
-    (let ([base (or (getenv "JERBOA_INOTIFY_LIB") ".")])
-      (list
-        (string-append base "/jerboa_inotify_shim.so")
-        "./jerboa_inotify_shim.so"
-        "jerboa_inotify_shim.so")))
+  (def (configured-native-path)
+    (let ([base (getenv "JERBOA_INOTIFY_LIB")])
+      (unless (native-loader-development-enabled?
+                "JERBOA_INOTIFY_DEV_NATIVE")
+        (error 'jerboa-inotify
+               "dynamic loading requires JERBOA_INOTIFY_DEV_NATIVE=1"))
+      (native-loader-validate-directory! 'jerboa-inotify base #t)
+      (let ([path (string-append base "/jerboa_inotify_shim.so")])
+        (native-loader-validate-library! 'jerboa-inotify path #t))))
 
   (def (try-load-native!)
     (or *native-loaded?*
-        (and (try-load-any! (native-search-paths))
+        (and (foreign-entry? "jerboa_inotify_init")
+             (begin (set! *native-loaded?* #t) #t))
+        (and (not (native-loader-privileged?))
+             (try-load-one! (configured-native-path))
+             (foreign-entry? "jerboa_inotify_init")
              (begin
                (set! *native-loaded?* #t)
                #t))))
diff --git a/tests/loader-policy.sh b/tests/loader-policy.sh
new file mode 100644
index 0000000..c9a2ae3
--- /dev/null
+++ b/tests/loader-policy.sh
@@ -0,0 +1,66 @@
+#!/bin/sh
+set -eu
+
+: "${REPO_ROOT:?}"
+: "${JERBUILD:?}"
+: "${LIBDIRS:?}"
+: "${SHIM:?}"
+
+unsafe_tmp=$(mktemp -d "${TMPDIR:-/tmp}/jerboa-inotify-loader.XXXXXXXX")
+trusted_tmp=$(mktemp -d "${HOME:?}/.jerboa-inotify-loader.XXXXXXXX")
+trap 'rm -rf "$unsafe_tmp" "$trusted_tmp"' EXIT HUP INT TERM
+chmod 700 "$trusted_tmp"
+probe=$REPO_ROOT/tests/loader-probe.ss
+shim_name=$(basename "$SHIM")
+
+mkdir "$unsafe_tmp/cwd"
+cp "$SHIM" "$unsafe_tmp/cwd/$shim_name"
+if (cd "$unsafe_tmp/cwd" && env -u JERBOA_INOTIFY_LIB -u JERBOA_INOTIFY_DEV_NATIVE \
+    "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1); then
+  echo "inotify loader accepted a shim from the current directory" >&2
+  exit 1
+fi
+
+if (cd "$unsafe_tmp/cwd" && JERBOA_INOTIFY_DEV_NATIVE=1 JERBOA_INOTIFY_LIB=. \
+    "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1); then
+  echo "inotify loader accepted a relative override" >&2
+  exit 1
+fi
+
+if JERBOA_INOTIFY_LIB="$REPO_ROOT" \
+   "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1; then
+  echo "inotify loader accepted an override without development opt-in" >&2
+  exit 1
+fi
+
+mkdir "$trusted_tmp/real"
+cp "$SHIM" "$trusted_tmp/real/$shim_name"
+chmod go-w "$trusted_tmp/real" "$trusted_tmp/real/$shim_name"
+ln -s "$trusted_tmp/real" "$trusted_tmp/link"
+if JERBOA_INOTIFY_DEV_NATIVE=1 JERBOA_INOTIFY_LIB="$trusted_tmp/link" \
+   "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1; then
+  echo "inotify loader accepted a symlink ancestor" >&2
+  exit 1
+fi
+
+mkdir "$trusted_tmp/writable"
+cp "$SHIM" "$trusted_tmp/writable/$shim_name"
+chmod 777 "$trusted_tmp/writable"
+if JERBOA_INOTIFY_DEV_NATIVE=1 JERBOA_INOTIFY_LIB="$trusted_tmp/writable" \
+   "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1; then
+  echo "inotify loader accepted a world-writable ancestor" >&2
+  exit 1
+fi
+
+if test "$(id -u)" -eq 0; then
+  if JERBOA_INOTIFY_DEV_NATIVE=1 JERBOA_INOTIFY_LIB="$REPO_ROOT" \
+     "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1; then
+    echo "inotify loader honored an environment override as root" >&2
+    exit 1
+  fi
+else
+  JERBOA_INOTIFY_DEV_NATIVE=1 JERBOA_INOTIFY_LIB="$REPO_ROOT" \
+    "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null
+fi
+
+echo "inotify-loader-policy: ok"
diff --git a/tests/loader-probe.ss b/tests/loader-probe.ss
new file mode 100644
index 0000000..861721a
--- /dev/null
+++ b/tests/loader-probe.ss
@@ -0,0 +1,5 @@
+(import (jerboa prelude)
+        (jerboa-inotify))
+
+(let ([fd (inotify-init)])
+  (inotify-close fd))