Skip to content
Draft
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
119 changes: 115 additions & 4 deletions crates/autopack-providers/src/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
)
}
Expand Down Expand Up @@ -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(&[(
Expand Down
Loading