diff --git a/crates/autopack-providers/src/support.rs b/crates/autopack-providers/src/support.rs index 7dab9e9..8c139ae 100644 --- a/crates/autopack-providers/src/support.rs +++ b/crates/autopack-providers/src/support.rs @@ -151,23 +151,85 @@ pub fn shell_quote(value: &str) -> String { /// Record which Debian packages own the shared libraries `glob` links against. /// -/// Runs in the build stage, where the `-dev` packages are still installed: -/// `ldd` reports a missing library as "not found" with no path, so the lookup -/// cannot be deferred to the runtime image. +/// Runs in the build stage, where the `-dev` packages are still installed. /// /// This exists because hardcoding runtime package names does not survive a /// Debian release bump — ICU is `libicu72` on bookworm and `libicu76` on /// trixie, and the `t64` transition renamed a whole set of others. Asking the -/// linker and then dpkg is release-agnostic. +/// linker and then dpkg is release-agnostic, and it installs exactly what the +/// binary links rather than a hand-maintained superset. +/// +/// `ldd` answers in two shapes, and both matter: +/// +/// - `libfoo.so.1 => /usr/lib/.../libfoo.so.1` — resolved, so the file is on +/// disk and `dpkg-query -S` names its owner. +/// - `libfoo.so.1 => not found` — absent from the build image too, so there is +/// no path to ask dpkg about. These are resolved with `apt-file`, which maps +/// a filename to its providing package without needing the file present. +/// +/// A soname reaches this from a binary the app produced, so it is untrusted +/// input, and it is handled twice over. +/// +/// It is validated against the characters a library name can actually contain, +/// because `|` would otherwise escape the path anchor through alternation and +/// let a crafted `DT_NEEDED` select any package in the archive, which then gets +/// installed as root in the runtime image. +/// +/// The survivors are then escaped, because two characters a library name +/// legitimately contains are also regex metacharacters. `+` is a quantifier, +/// so `libxml++-2.6.so.2` matches nothing and the build fails claiming no +/// package provides a library that plainly exists — 238 sonames in bookworm +/// carry a `+`. And `.` matches any character including `/`, so +/// `gio.modules.libgioremote-volume-monitor.so` reaches `gvfs` two directories +/// below the anchor. Escaping both collapses the query to the exact basename. +/// +/// `apt-file` and its ~90MB index are only fetched when something is actually +/// missing. +/// +/// `glob` is expanded by the shell, so it may name several binaries — pathname +/// expansion stays on for that line and is turned off only around the loop +/// over sonames, where a `*` from a crafted `DT_NEEDED` would otherwise expand +/// against the build directory. pub fn record_runtime_libraries(glob: &str, record_to: &str) -> String { format!( "set -eu; \ + export LC_ALL=C; \ mkdir -p \"$(dirname {record_to})\"; \ ldd {glob} 2>/dev/null \ | awk '/=> \\// {{ print $3 }}' | sort -u \ | xargs -r readlink -f 2>/dev/null | sort -u \ | xargs -r dpkg-query -S 2>/dev/null \ | cut -d: -f1 | sort -u > {record_to}; \ + missing=$(ldd {glob} 2>/dev/null | awk '/not found/ {{ print $1 }}' | sort -u); \ + if [ -n \"$missing\" ]; then \ + apt-get update >/dev/null; \ + apt-get install -y --no-install-recommends apt-file >/dev/null; \ + apt-file update >/dev/null; \ + set -f; \ + for lib in $missing; do \ + case \"$lib\" in \ + *[!A-Za-z0-9._+-]*) \ + echo \"autopack: refusing to look up '$lib': not a library name\" >&2; \ + exit 1 ;; \ + esac; \ + pattern=$(printf '%s' \"$lib\" | sed 's/[.+]/\\\\&/g'); \ + owners=$(apt-file search -x \"^/(usr/)?lib/[a-z0-9_]*-linux-gnu[a-z0-9]*/$pattern\\$\" \ + | cut -d: -f1 | sort -u); \ + count=$(printf '%s' \"$owners\" | grep -c . || true); \ + if [ \"$count\" -eq 0 ]; then \ + echo \"autopack: no package provides $lib\" >&2; \ + exit 1; \ + fi; \ + if [ \"$count\" -gt 1 ]; then \ + echo \"autopack: $lib is provided by more than one package:\" >&2; \ + printf ' %s\\n' $owners >&2; \ + echo \"Choose one and add it to apt_packages in autopack.json.\" >&2; \ + exit 1; \ + fi; \ + printf '%s\\n' \"$owners\" >> {record_to}; \ + done; \ + sort -u -o {record_to} {record_to}; \ + fi; \ cat {record_to}" ) } @@ -203,6 +265,55 @@ mod tests { (dir, app) } + #[test] + fn missing_libraries_are_resolved_and_hostile_sonames_refused() { + let script = record_runtime_libraries("/app/bin/app", "/tmp/deps"); + + // Resolved libraries: unchanged path through dpkg. + assert!(script.contains("dpkg-query -S")); + // Missing ones are looked up rather than dropped on the floor. + assert!(script.contains("/not found/")); + assert!(script.contains("apt-file search")); + + // A soname comes from a binary the app produced. Anything outside the + // character set a library name can hold is refused before it reaches + // the regex — `|` alone escapes the anchor through alternation. + assert!(script.contains("*[!A-Za-z0-9._+-]*")); + assert!(script.contains("refusing to look up")); + + // `.` and `+` survive that guard because a library name may contain + // them, and both are regex metacharacters — `+` quantifies, so a C++ + // soname matches nothing, and `.` matches `/`, reaching packages below + // the anchored directory. + assert!(script.contains("sed 's/[.+]/")); + + // Globbing off, so a soname of `*` cannot expand against the build + // directory; deterministic collation, so the same source does not + // resolve differently on two builders. + assert!(script.contains("set -eu")); + // Globbing is off for the soname loop but must stay on for `ldd`, + // whose argument is a glob for callers that inspect several binaries. + let ldd = script.find("ldd ").unwrap(); + let disable = script.find("set -f;").unwrap(); + assert!(ldd < disable); + assert!(script.contains("LC_ALL=C")); + + // The anchor covers /lib as well as /usr/lib: on Debian the essential + // libraries are still recorded unmerged, so a /usr-only anchor fails + // the build for libc, libz and friends. + assert!(script.contains("^/(usr/)?lib/[a-z0-9_]*-linux-gnu[a-z0-9]*/")); + + // Ambiguity and absence both stop the build with something actionable + // rather than guessing. + assert!(script.contains("no package provides")); + assert!(script.contains("provided by more than one package")); + assert!(script.contains("add it to apt_packages")); + + // The ~90MB index is only fetched when there is something to look up. + let guard = script.find("if [ -n \"$missing\" ]").unwrap(); + assert!(guard < script.find("apt-file update").unwrap()); + } + #[test] fn parses_the_web_process() { let (_dir, app) = app_with(&[(