Skip to content
Merged
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
78 changes: 78 additions & 0 deletions scripts/release/packaging/linux/tarball.sh
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,84 @@ if resolved_path="$(readlink -f "$script_path" 2>/dev/null)"; then
fi

bin_dir="$(cd "$(dirname "$script_path")" && pwd)"

case "$(uname -m)" in
x86_64 | amd64)
system_lib_dirs=(
/usr/lib/x86_64-linux-gnu
/usr/lib64
/usr/lib
/usr/local/lib64
/usr/local/lib
/lib/x86_64-linux-gnu
/lib64
/lib
)
;;
aarch64 | arm64)
system_lib_dirs=(
/usr/lib/aarch64-linux-gnu
/usr/lib64
/usr/lib
/usr/local/lib
/lib/aarch64-linux-gnu
/lib64
/lib
)
;;
*)
system_lib_dirs=(
/usr/lib64
/usr/lib
/usr/local/lib64
/usr/local/lib
/lib64
/lib
)
;;
esac

libexec_dir="${bin_dir}/../libexec"
preloads=()

prefer_host_libraries() {
local lib dir host_path
local group_preloads=()

for lib in "$@"; do
[[ -f "${libexec_dir}/${lib}" ]] || continue
host_path=""
for dir in "${system_lib_dirs[@]}"; do
if [[ -f "${dir}/${lib}" ]]; then
host_path="${dir}/${lib}"
break
fi
done

# Keep a dependency group bundled when any required host copy is missing.
[[ -n "$host_path" ]] || return 0
group_preloads+=("$host_path")
done

if [[ ${#group_preloads[@]} -gt 0 ]]; then
preloads+=("${group_preloads[@]}")
fi
}

# Only replace libraries that integrate with host data or runtime modules.
# CEF graphics and compiler runtimes such as libstdc++ must stay bundled.
prefer_host_libraries libxkbcommon.so.0 libxkbcommon-x11.so.0
prefer_host_libraries libfontconfig.so.1
prefer_host_libraries \
libnspr4.so libplc4.so libplds4.so \
libnssutil3.so libnss3.so libsmime3.so libssl3.so \
libfreebl3.so libfreeblpriv3.so libsoftokn3.so libnssckbi.so libnssdbm3.so

if [[ ${#preloads[@]} -gt 0 ]]; then
preload_str="$(IFS=:; echo "${preloads[*]}")"
export LD_PRELOAD="${preload_str}${LD_PRELOAD:+:$LD_PRELOAD}"
fi

exec "${bin_dir}/../libexec/athas" \
--ozone-platform=x11 \
--disable-vulkan \
Expand Down
1 change: 1 addition & 0 deletions scripts/release/tests/linux-packaging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ describe("Linux release packaging", () => {
expect(script).toContain("is_glibc_runtime_library");
expect(script).toContain("patchelf --add-rpath '$ORIGIN'");
expect(script).toContain("${app_dir_name}/libexec/libgdk_pixbuf-2.0.so.0");
expect(script).not.toContain("export LD_LIBRARY_PATH=");
});

it("preserves the root-owned setuid sandbox contract in Debian packages", () => {
Expand Down
180 changes: 180 additions & 0 deletions scripts/release/tests/linux-tarball-launcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";

const packagingScript = fs.readFileSync(
path.resolve(import.meta.dirname, "../packaging/linux/tarball.sh"),
"utf8",
);
const launcher = packagingScript.split("<<'EOF'\n")[1]?.split("\nEOF")[0];

function shellQuote(value: string) {
return `'${value.replace(/'/g, "'\\''")}'`;
}

function runLauncher({
bundled = [],
host = [],
arch = "x86_64",
preload = "",
args = [],
}: {
bundled?: string[];
host?: string[];
arch?: string;
preload?: string;
args?: string[];
}) {
if (!launcher) throw new Error("Tarball launcher heredoc was not found");

const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "athas-launcher-")));
try {
for (const file of [...bundled.map((name) => `/libexec/${name}`), ...host]) {
const filePath = path.join(root, file);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "");
}
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir);
const launcherPath = path.join(binDir, "athas");
// Redirect only the host filesystem and final exec; run the real selection logic.
const fixtureLauncher = launcher
.replace(
/^[ \t]+\/(?:usr\/)?lib\S*$/gm,
(line) => ` ${shellQuote(path.join(root, line.trim()))}`,
)
.replace(
'\ncase "$(uname -m)" in',
`\nexport LD_PRELOAD=${shellQuote(preload)}\ncase "$(uname -m)" in`,
);
fs.writeFileSync(
launcherPath,
[
`uname() { printf '%s\\n' ${shellQuote(arch)}; }`,
`exec() { printf '%s\\0' "\${LD_PRELOAD-}" "$@"; }`,
fixtureLauncher,
].join("\n"),
);
const env = { ...process.env };
delete env.LD_PRELOAD;
const result = spawnSync("bash", [launcherPath, ...args], { env, encoding: "utf8" });
expect(result.error).toBeUndefined();
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
const [selected, executable, ...forwardedArgs] = result.stdout.split("\0").slice(0, -1);
expect(path.normalize(executable!)).toBe(path.join(root, "libexec/athas"));
return {
preloads: selected ? selected.split(":").map((file) => file.replace(root, "")) : [],
args: forwardedArgs,
};
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}

describe("Linux tarball launcher", () => {
it("replaces only host-integration libraries, retaining compiler and CEF runtimes", () => {
const integration = ["libxkbcommon.so.0", "libfontconfig.so.1", "libnssutil3.so"];
const retained = [
"libstdc++.so.6",
"libgcc_s.so.1",
"libcef.so",
"libEGL.so",
"libGLESv2.so",
"libvk_swiftshader.so",
"libvulkan.so.1",
"libglib-2.0.so.0",
"libunrelated.so.1",
];
const libraries = [...integration, ...retained];
const result = runLauncher({
bundled: libraries,
host: libraries.map((lib) => `/usr/lib/${lib}`),
});
expect(result.preloads).toEqual(integration.map((lib) => `/usr/lib/${lib}`));
});

it.each([
["x86_64", "/usr/lib/x86_64-linux-gnu"],
["x86_64", "/usr/lib64"],
["x86_64", "/usr/lib"],
["x86_64", "/lib/x86_64-linux-gnu"],
["aarch64", "/usr/lib/aarch64-linux-gnu"],
["aarch64", "/lib/aarch64-linux-gnu"],
["aarch64", "/lib64"],
["aarch64", "/lib"],
])("finds host libraries on %s in %s", (arch, dir) => {
const file = `${dir}/libfontconfig.so.1`;
expect(runLauncher({ arch, bundled: ["libfontconfig.so.1"], host: [file] }).preloads).toEqual([
file,
]);
});

it("prefers the architecture-specific directory and does not preload both copies", () => {
const file = "/usr/lib/x86_64-linux-gnu/libfontconfig.so.1";
expect(
runLauncher({
bundled: ["libfontconfig.so.1"],
host: [file, "/usr/lib/libfontconfig.so.1"],
}).preloads,
).toEqual([file]);
});

it("keeps bundled libraries when host copies are unavailable", () => {
expect(
runLauncher({ bundled: ["libxkbcommon.so.0", "libfontconfig.so.1", "libnssutil3.so"] })
.preloads,
).toEqual([]);
});

it("does not preload host libraries that are not bundled", () => {
expect(runLauncher({ host: ["/usr/lib/libfontconfig.so.1"] }).preloads).toEqual([]);
});

it.each([
{ name: "XKB", libraries: ["libxkbcommon.so.0", "libxkbcommon-x11.so.0"] },
{
name: "NSS/NSPR",
libraries: ["libnspr4.so", "libplc4.so", "libplds4.so", "libnssutil3.so", "libnss3.so"],
},
])("switches the bundled $name group together", ({ libraries }) => {
const host = libraries.map((lib) => `/usr/lib/${lib}`);
expect(runLauncher({ bundled: libraries, host }).preloads).toEqual(host);
for (const missing of host) {
expect(
runLauncher({
bundled: [...libraries, "libfontconfig.so.1"],
host: [...host.filter((file) => file !== missing), "/usr/lib/libfontconfig.so.1"],
}).preloads,
).toEqual(["/usr/lib/libfontconfig.so.1"]);
}
});

it.each([true, false])(
"preserves caller preloads and arguments with host selection %s",
(found) => {
const file = "/usr/lib/libfontconfig.so.1";
const result = runLauncher({
bundled: ["libfontconfig.so.1"],
host: found ? [file] : [],
preload: "/custom/first.so:/custom/second.so",
args: ["/workspace/with spaces", "--new-window", ""],
});
expect(result.preloads).toEqual([
...(found ? [file] : []),
"/custom/first.so",
"/custom/second.so",
]);
expect(result.args).toEqual([
"--ozone-platform=x11",
"--disable-vulkan",
"--disable-features=Vulkan",
"/workspace/with spaces",
"--new-window",
"",
]);
},
);
});
Loading