diff --git a/docs/desktop-application-capture.md b/docs/desktop-application-capture.md index 0a8de99b83..40498d160a 100644 --- a/docs/desktop-application-capture.md +++ b/docs/desktop-application-capture.md @@ -272,8 +272,16 @@ looks each soname up as ld.so does: in the RPATH of the needing image and of each image up its chain of loaders to the executable when the needing image has no RUNPATH, then in its RUNPATH (with `$ORIGIN` expanded and paths normalized), and then in the `ldconfig -p` cache entries for the executable's -machine. It asks `dpkg-query -S` for each library's package. Libraries in the -executable's own folder belong to the application: they are walked but not +machine. Each candidate's ELF machine, class and byte order must match the +needing image before it is selected or its dependencies are walked. A foreign +search-path candidate is skipped in favor of later directories or the cache; a +cache architecture flag alone does not establish compatibility. If a candidate's +identity cannot be read, that lookup stays unresolved, without selecting its +package or guessing that a later candidate would load. A compatible identity +remains available if a later header or dependency-table check fails; its package +is retained with an incompleteness reason. REA asks `dpkg-query -S` for each +selected library's package. Libraries in the executable's own folder belong to +the application: they are walked but not listed, and neither is the application's own package. One batched `apt-cache policy` then keeps only the packages the Ubuntu 24.04 archive or a recorded vendor repository with a verified key offers, in any component its diff --git a/src/desktop/DebianPackageInventory.test.ts b/src/desktop/DebianPackageInventory.test.ts index 0298dc9731..ef5d14f72c 100644 --- a/src/desktop/DebianPackageInventory.test.ts +++ b/src/desktop/DebianPackageInventory.test.ts @@ -356,6 +356,27 @@ const inspected = async (options: FixtureOptions = {}) => { }; }; +it.skipIf(process.platform === "win32")( + "carries compatible library ownership through application inventory into the export install plan", + async () => { + const { root, inspect } = await vendorFixture(); + // A foreign same-name library in $ORIGIN must not shadow the native cache entry. + await writeFile( + join(root, "bin/libnss3.so"), + elfImage({ machine: 62, needed: ["libdecoy.so"] }), + ); + const result = await inspect(); + if (!result.ok) throw new Error(result.error.reason); + const installed = result.value.inventory.package; + expect(installed.library_packages).toEqual(["libnspr4", "libnss3"]); + expect(installed.missing_libraries).not.toContain("libdecoy.so"); + expect(installed.library_packages_reason).toBeUndefined(); + const plan = exportInstall(installed, "typora", "arm64"); + if (!plan.ok) throw new Error(plan.error.reason); + expect(plan.value.libraryPackages).toEqual(["libnspr4", "libnss3"]); + }, +); + describe.skipIf(process.platform === "win32")("vendor repositories", () => { it("records the repository apt names with the key gpgv verified, though another signature fails gpgv", async () => { const { diff --git a/src/desktop/LibraryPackageInventory.test.ts b/src/desktop/LibraryPackageInventory.test.ts index 0e87a60e37..4d5313bc68 100644 --- a/src/desktop/LibraryPackageInventory.test.ts +++ b/src/desktop/LibraryPackageInventory.test.ts @@ -10,6 +10,99 @@ import { inspectLibraryPackages } from "./LibraryPackageInventory.js"; import { elfImage } from "../domain/desktop/elfImage.fixture.js"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +it.skipIf(process.platform === "win32")( + "retains a compatible candidate's package when its declared header size is invalid", + async () => { + const root = await createTestTempDirectory("rea-library-header-"); + const bin = join(root, "bin"), + libraries = join(root, "lib"); + await Promise.all([mkdir(bin), mkdir(libraries)]); + const executable = join(bin, "app"), + library = join(libraries, "libchoice.so"); + const candidate = elfImage({ needed: ["libunknown.so"] }); + candidate.writeUInt16LE(0, 52); + await Promise.all([ + writeFile( + executable, + elfImage({ needed: ["libchoice.so"], runpath: libraries }), + ), + writeFile(library, candidate), + ]); + const result = await inspectLibraryPackages( + executable, + "app", + async (command, args) => + command === "ldconfig" + ? { stdout: "", exitCode: 0 } + : { + stdout: args + .slice(1) + .map((path) => `native-owner: ${path}`) + .join("\n"), + exitCode: 0, + }, + undefined, + ); + expect(result).toEqual({ + library_packages: ["native-owner"], + missing_libraries: [], + unpackaged_libraries: [], + library_packages_reason: expect.stringContaining( + "invalid ELF header size", + ), + }); + }, +); + +it.skipIf(process.platform === "win32")( + "queries ownership only for the compatible library selected after a foreign RPATH candidate", + async () => { + const root = await createTestTempDirectory("rea-library-architecture-"); + const bin = join(root, "bin"), + foreign = join(root, "foreign"), + native = join(root, "native"); + await Promise.all([mkdir(bin), mkdir(foreign), mkdir(native)]); + const executable = join(bin, "app"); + const rejected = join(foreign, "libchoice.so"), + selected = join(native, "libchoice.so"); + await Promise.all([ + writeFile( + executable, + elfImage({ needed: ["libchoice.so"], runpath: `${foreign}:${native}` }), + ), + writeFile(rejected, elfImage({ machine: 62, needed: ["libdecoy.so"] })), + writeFile(selected, elfImage({})), + ]); + const queried: string[] = []; + const result = await inspectLibraryPackages( + executable, + "app", + async (command, args) => { + if (command === "ldconfig") return { stdout: "", exitCode: 0 }; + queried.push(...args.slice(1)); + return { + stdout: args + .slice(1) + .map( + (path) => + `${path === selected ? "native-owner" : "foreign-owner"}: ${path}`, + ) + .join("\n"), + exitCode: 0, + }; + }, + undefined, + ); + expect(result).toEqual({ + library_packages: ["native-owner"], + missing_libraries: [], + unpackaged_libraries: [], + }); + expect(queried).toContain(selected); + expect(queried).not.toContain(rejected); + }, +); + describe.skipIf( process.platform !== "linux" || !existsSync("/var/lib/dpkg/status"), )("inspectLibraryPackages on a Debian host", () => { diff --git a/src/desktop/LibraryPackageInventory.ts b/src/desktop/LibraryPackageInventory.ts index cd25f32f7e..6b179c93ec 100644 --- a/src/desktop/LibraryPackageInventory.ts +++ b/src/desktop/LibraryPackageInventory.ts @@ -9,6 +9,8 @@ import type { DesktopPackage } from "../domain/desktop/desktopCapture.js"; import { readElfDependencies, type ElfDependencies, + type ElfDependenciesFailure, + type ElfLibraryIdentity, } from "../domain/desktop/elfDependencies.js"; import { installableLibraryPackages, @@ -46,7 +48,8 @@ const elfDependencies = async ( path: string, signal: AbortSignal | undefined, policy: DesktopLibraryPolicy, -): Promise> => { + expected?: ElfLibraryIdentity, +): Promise> => { const options = { symlinks: "follow" as const, ...(signal === undefined ? {} : { signal }), @@ -57,6 +60,7 @@ const elfDependencies = async ( (offset, length) => readFileHandleRange(handle, offset, length), initial.size, policy, + expected, ), ); } catch (cause: unknown) { @@ -64,7 +68,11 @@ const elfDependencies = async ( throw hostCloseFailure(cause); signal?.throwIfAborted(); if (!isRefusedRead(cause)) throw cause; - return err("the file could not be read"); + return err({ + kind: "unreadable", + identity: null, + reason: "the file could not be read", + }); } }; @@ -157,7 +165,9 @@ export const inspectLibraryPackages = async ( }); const root = await elfDependencies(executable, signal, policy); if (!root.ok) - return none(`the libraries of ${executable} were not read: ${root.error}`); + return none( + `the libraries of ${executable} were not read: ${root.error.reason}`, + ); const cache = await linkerCache(runQuery, signal); if (cache === undefined) return none("ldconfig -p did not list the dynamic linker cache"); @@ -165,7 +175,8 @@ export const inspectLibraryPackages = async ( { path: executable, dependencies: root.value }, parseLinkerCache(cache), { - dependencies: (path) => elfDependencies(path, signal, policy), + dependencies: (path, expected) => + elfDependencies(path, signal, policy, expected), isFile: isHostFile, }, policy.libraries, diff --git a/src/domain/desktop/elfDependencies.test.ts b/src/domain/desktop/elfDependencies.test.ts index c68c02b498..e8102ed85d 100644 --- a/src/domain/desktop/elfDependencies.test.ts +++ b/src/domain/desktop/elfDependencies.test.ts @@ -24,6 +24,7 @@ describe("readElfDependencies", () => { value: { machine: 183, bits: 64, + byte_order: "little", needed: [ "libffmpeg.so", "libnss3.so", @@ -65,7 +66,9 @@ describe("readElfDependencies", () => { const image = elfImage({ needed: names }); expect(await dependencies(image)).toMatchObject({ ok: false, - error: expect.stringContaining("library-policy.libraries=512"), + error: { + reason: expect.stringContaining("library-policy.libraries=512"), + }, }); expect( await readElfDependencies( @@ -81,7 +84,11 @@ describe("readElfDependencies", () => { const image = elfImage({ needed: [needed] }); expect(await dependencies(image)).toMatchObject({ ok: false, - error: expect.stringContaining("library-policy.string_bytes=4096 bytes"), + error: { + reason: expect.stringContaining( + "library-policy.string_bytes=4096 bytes", + ), + }, }); expect( await readElfDependencies( @@ -98,12 +105,102 @@ describe("readElfDependencies", () => { value: { needed: [], runpath: undefined, rpath: [] }, }); }); +}); + +describe("ELF dependency identity and failures", () => { + it.each(["size", "version"])( + "retains decoded identity when fixed-header %s admission fails", + async (field) => { + const image = elfImage({ needed: ["libcore.so"] }); + if (field === "size") image.writeUInt16LE(0, 52); + else image.writeUInt32LE(2, 20); + expect(await dependencies(image)).toMatchObject({ + ok: false, + error: { + kind: "unreadable", + identity: { machine: 183, bits: 64, byte_order: "little" }, + reason: expect.stringContaining( + field === "size" ? "header size" : "header version", + ), + }, + }); + }, + ); + + it("classifies a foreign candidate before its declared header extent is admitted", async () => { + const image = elfImage({}); + image.writeUInt16LE(0, 52); + expect( + await readElfDependencies( + readImage(image), + image.length, + desktopLibraryPolicy(), + { machine: 62, bits: 64, byte_order: "little" }, + ), + ).toMatchObject({ + ok: false, + error: { + kind: "incompatible", + identity: { machine: 183, bits: 64, byte_order: "little" }, + }, + }); + }); + + it.each([ + { machine: 62, bits: 64 as const, byte_order: "little" as const }, + { machine: 183, bits: 32 as const, byte_order: "little" as const }, + { machine: 183, bits: 64 as const, byte_order: "big" as const }, + ])( + "rejects incompatible identity before reading dependency tables ($machine / $bits / $byte_order)", + async (expected) => { + const image = elfImage({ needed: ["libdecoy.so"], unterminated: true }); + const reads: [number, number][] = []; + const result = await readElfDependencies( + async (offset, length) => { + reads.push([offset, length]); + return readImage(image)(offset, length); + }, + image.length, + desktopLibraryPolicy({ table_bytes: 1 }), + expected, + ); + expect(result).toMatchObject({ + ok: false, + error: { + kind: "incompatible", + identity: { machine: 183, bits: 64, byte_order: "little" }, + }, + }); + expect(reads).toEqual([[0, 64]]); + }, + ); + + it("retains fixed-header identity when dependency parsing or its table allowance fails", async () => { + const image = elfImage({ needed: ["libcore.so"], unterminated: true }); + for (const policy of [ + desktopLibraryPolicy(), + desktopLibraryPolicy({ table_bytes: 1 }), + ]) { + const result = await readElfDependencies( + readImage(image), + image.length, + policy, + ); + expect(result).toMatchObject({ + ok: false, + error: { + identity: { machine: 183, bits: 64, byte_order: "little" }, + reason: expect.any(String), + }, + }); + } + }); it("refuses a script, a truncated image and a dynamic table without DT_NULL", async () => { const script = Buffer.from('#!/bin/sh\nexec /opt/app/app "$@"\n'); expect(await dependencies(script)).toEqual({ ok: false, - error: "not an ELF image", + error: { kind: "unreadable", identity: null, reason: "not an ELF image" }, }); const image = elfImage({ needed: ["libc.so.6"] }); expect( @@ -115,7 +212,11 @@ describe("readElfDependencies", () => { ), ).toEqual({ ok: false, - error: "ELF dynamic table has no DT_NULL terminator", + error: { + kind: "unreadable", + identity: { machine: 183, bits: 64, byte_order: "little" }, + reason: "ELF dynamic table has no DT_NULL terminator", + }, }); }); }); diff --git a/src/domain/desktop/elfDependencies.ts b/src/domain/desktop/elfDependencies.ts index 83968d3f85..b9ad386eb4 100644 --- a/src/domain/desktop/elfDependencies.ts +++ b/src/domain/desktop/elfDependencies.ts @@ -4,7 +4,10 @@ import { type DesktopLibraryPolicy, } from "./libraryPolicy.js"; import { readElfDynamicEntry } from "../elfDynamicEntry.js"; -import { readElfFixedHeader } from "../elfFixedHeader.js"; +import { + admitElfFixedHeaderFacts, + readElfHeaderFacts, +} from "../elfFixedHeader.js"; import { readElfProgramHeader, type ElfProgramHeader, @@ -18,10 +21,37 @@ import { err, ok, type Result } from "../result.js"; /** Read up to `length` bytes at `offset`; fewer at the end of the file. */ export type ReadAt = (offset: number, length: number) => Promise; -/** What the dynamic linker needs to load an ELF image's dependencies. */ -export interface ElfDependencies { +/** Fixed-header identity needed before selecting a library candidate. */ +export interface ElfLibraryIdentity { readonly machine: number; readonly bits: 32 | 64; + readonly byte_order: "little" | "big"; +} + +/** A readable identity survives later header or dependency-table admission failures. */ +export type ElfDependenciesFailure = + | { + readonly kind: "incompatible"; + readonly identity: ElfLibraryIdentity; + readonly reason: string; + } + | { + readonly kind: "unreadable"; + readonly identity: ElfLibraryIdentity | null; + readonly reason: string; + }; + +/** The same ABI identity is required before dependency tables can select a library. */ +export const elfLibraryIdentityMatches = ( + expected: ElfLibraryIdentity, + observed: ElfLibraryIdentity, +): boolean => + expected.machine === observed.machine && + expected.bits === observed.bits && + expected.byte_order === observed.byte_order; + +/** What the dynamic linker needs to load an ELF image's dependencies. */ +export interface ElfDependencies extends ElfLibraryIdentity { /** Distinct DT_NEEDED sonames in order. */ readonly needed: readonly string[]; /** DT_RUNPATH directories as written; undefined without DT_RUNPATH. */ @@ -231,15 +261,53 @@ export const readElfDependencies = async ( read: ReadAt, size: number, policy: DesktopLibraryPolicy = desktopLibraryPolicy(), -): Promise> => { + expected?: ElfLibraryIdentity, +): Promise> => { const header = Buffer.from(await read(0, 64)); - const fixed = readElfFixedHeader(header, size); - if (!fixed.ok) return fixed; - const { elfClass: bits, machine } = fixed.value; - const little = fixed.value.byteOrder === "little"; + const facts = readElfHeaderFacts(header, size); + if (!facts.ok) + return err({ kind: "unreadable", identity: null, reason: facts.error }); + const identity: ElfLibraryIdentity = { + machine: facts.value.machine, + bits: facts.value.elfClass, + byte_order: facts.value.byteOrder, + }; + if (expected !== undefined && !elfLibraryIdentityMatches(expected, identity)) + return err({ + kind: "incompatible", + identity, + reason: `Incompatible ELF library: expected machine ${expected.machine}, ELF${expected.bits}, ${expected.byte_order}-endian; observed machine ${identity.machine}, ELF${identity.bits}, ${identity.byte_order}-endian`, + }); + const fixed = admitElfFixedHeaderFacts(facts.value, size); + if (!fixed.ok) + return err({ kind: "unreadable", identity, reason: fixed.error }); + const dependencies = await readDependencyTables( + read, + header, + size, + identity, + policy, + ); + return dependencies.ok + ? ok({ ...identity, ...dependencies.value }) + : err({ kind: "unreadable", identity, reason: dependencies.error }); +}; + +/** Parse dependency tables only after their candidate identity has been retained. */ +const readDependencyTables = async ( + read: ReadAt, + header: Buffer, + size: number, + identity: ElfLibraryIdentity, + policy: DesktopLibraryPolicy, +): Promise< + Result, string> +> => { + const { bits } = identity; + const little = identity.byte_order === "little"; const headers = await programHeaders(read, header, size, bits, policy); if (!headers.ok) return headers; - const none = { machine, bits, needed: [], runpath: undefined, rpath: [] }; + const none = { needed: [], runpath: undefined, rpath: [] }; const dynamic = headers.value.find((program) => program.type === PT_DYNAMIC); if (dynamic === undefined) return ok(none); if (dynamic.fileSize > BigInt(policy.table_bytes)) @@ -271,8 +339,6 @@ export const readElfDependencies = async ( ); if (!search.ok) return search; return ok({ - machine, - bits, needed: [...new Set(names.value)], runpath: runpath.size > 0 ? directories(search.value) : undefined, rpath: runpath.size > 0 ? [] : directories(search.value), diff --git a/src/domain/desktop/libraryPackages.test.ts b/src/domain/desktop/libraryPackages.test.ts index d185899a8b..2a5e4803d9 100644 --- a/src/domain/desktop/libraryPackages.test.ts +++ b/src/domain/desktop/libraryPackages.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import { err, ok } from "../result.js"; -import type { ElfDependencies } from "./elfDependencies.js"; +import { + elfLibraryIdentityMatches, + type ElfLibraryIdentity, + type ElfDependencies, +} from "./elfDependencies.js"; import { installableLibraryPackages, libraryClosure, @@ -27,18 +31,47 @@ const elf = ( ): ElfDependencies => ({ machine: 183, bits: 64, + byte_order: "little", needed, runpath, rpath: runpath === undefined ? rpath : [], }); const TYPORA = "/usr/share/typora/Typora"; +const app = (dependencies: ElfDependencies) => ({ + path: "/opt/app/bin/app", + dependencies, +}); /** Typora's start: libffmpeg.so from its own folder through RPATH $ORIGIN, the rest from the cache. */ -const host = (images: Readonly>) => ({ - dependencies: async (path: string) => { +const host = ( + images: Readonly< + Record< + string, + | ElfDependencies + | { identity: ElfLibraryIdentity | null; reason: string } + | string + > + >, +) => ({ + dependencies: async (path: string, expected: ElfLibraryIdentity) => { const image = images[path]; - return typeof image === "object" ? ok(image) : err(image ?? "missing"); + if (typeof image !== "object") + return err({ + kind: "unreadable" as const, + identity: null, + reason: image ?? "missing", + }); + const identity = "reason" in image ? image.identity : image; + if (identity !== null && !elfLibraryIdentityMatches(expected, identity)) + return err({ + kind: "incompatible" as const, + identity, + reason: "incompatible ELF", + }); + return "reason" in image + ? err({ kind: "unreadable" as const, ...image }) + : ok(image); }, isFile: async (path: string) => path in images, }); @@ -102,7 +135,10 @@ describe("libraryClosure", () => { parseLinkerCache(LINKER_CACHE), host({ "/usr/share/typora/libffmpeg.so": elf([]), - "/lib/aarch64-linux-gnu/libnss3.so": "the file could not be read", + "/lib/aarch64-linux-gnu/libnss3.so": { + identity: elf([]), + reason: "the file could not be read", + }, }), 2, ); @@ -120,12 +156,175 @@ describe("libraryClosure", () => { }); }); -describe("libraryClosure search paths", () => { - const app = (dependencies: ElfDependencies) => ({ - path: "/opt/app/bin/app", - dependencies, +describe("libraryClosure candidate identity", () => { + it.each([ + { machine: 62, bits: 64 as const }, + { machine: 183, bits: 32 as const }, + { machine: 183, bits: 64 as const, byte_order: "big" as const }, + ])( + "skips an incompatible search-path candidate ($machine / $bits) before selecting its owner or walking its dependencies", + async (identity) => { + const closure = await libraryClosure( + app(elf(["libcore.so"], [], ["/foreign", "/native"])), + [], + host({ + "/foreign/libcore.so": { ...elf(["libdecoy.so"]), ...identity }, + "/native/libcore.so": elf(["libhelper.so"], [], ["/native"]), + "/native/libhelper.so": elf([]), + }), + 10, + ); + expect(closure).toEqual({ + libraries: [ + { soname: "libcore.so", path: "/native/libcore.so" }, + { soname: "libhelper.so", path: "/native/libhelper.so" }, + ], + missing: [], + unreadable: [], + truncated: false, + }); + }, + ); + + it("uses the cache after an incompatible candidate even when its dependency table is unreadable", async () => { + const closure = await libraryClosure( + app(elf(["libcore.so"], ["/foreign"])), + [ + { + soname: "libcore.so", + path: "/cached/libcore.so", + flags: ["AArch64"], + }, + ], + host({ + "/foreign/libcore.so": { + identity: { machine: 62, bits: 64, byte_order: "little" }, + reason: "bad dynamic table", + }, + "/cached/libcore.so": elf([]), + }), + 10, + ); + expect(closure).toEqual({ + libraries: [{ soname: "libcore.so", path: "/cached/libcore.so" }], + missing: [], + unreadable: [], + truncated: false, + }); + }); +}); + +describe("libraryClosure unresolved candidates", () => { + it("does not turn unknown candidate identity into a selected library or search past it", async () => { + const closure = await libraryClosure( + app(elf(["libcore.so"], [], ["/unreadable", "/native"])), + [], + host({ + "/unreadable/libcore.so": "cannot read header", + "/native/libcore.so": elf([]), + }), + 10, + ); + expect(closure).toEqual({ + libraries: [], + missing: [], + unreadable: [ + { path: "/unreadable/libcore.so", reason: "cannot read header" }, + ], + truncated: false, + }); }); + it("refuses an incompatible explicit path without claiming its package was selected", async () => { + const path = "/foreign/libcore.so"; + const closure = await libraryClosure( + app(elf([path])), + [], + host({ [path]: { ...elf([]), machine: 62 } }), + 10, + ); + expect(closure).toMatchObject({ + libraries: [], + missing: [], + unreadable: [ + { path, reason: expect.stringContaining("incompatible ELF") }, + ], + truncated: false, + }); + }); + + it("continues to another matching cache candidate after foreign identity", async () => { + const closure = await libraryClosure( + app(elf(["libcore.so"])), + [ + { + soname: "libcore.so", + path: "/foreign/libcore.so", + flags: ["AArch64"], + }, + { soname: "libcore.so", path: "/valid/libcore.so", flags: ["AArch64"] }, + ], + host({ + "/foreign/libcore.so": { ...elf([]), machine: 62 }, + "/valid/libcore.so": elf([]), + }), + 10, + ); + expect(closure).toMatchObject({ + libraries: [{ soname: "libcore.so", path: "/valid/libcore.so" }], + missing: [], + }); + }); + + it("keeps an unreadable first cache candidate unresolved", async () => { + const closure = await libraryClosure( + app(elf(["libcore.so"])), + [ + { + soname: "libcore.so", + path: "/unknown/libcore.so", + flags: ["AArch64"], + }, + { soname: "libcore.so", path: "/valid/libcore.so", flags: ["AArch64"] }, + ], + host({ + "/unknown/libcore.so": "cannot read header", + "/valid/libcore.so": elf([]), + }), + 10, + ); + expect(closure).toMatchObject({ + libraries: [], + missing: [], + unreadable: [ + { path: "/unknown/libcore.so", reason: "cannot read header" }, + ], + }); + }); + + it("does not trust a cache architecture flag over the candidate's own ELF identity", async () => { + const closure = await libraryClosure( + app(elf(["libcore.so"])), + [ + { + soname: "libcore.so", + path: "/cached/libcore.so", + flags: ["AArch64"], + }, + ], + host({ "/cached/libcore.so": { ...elf([]), machine: 62 } }), + 10, + ); + expect(closure).toEqual({ + libraries: [], + missing: ["libcore.so"], + unreadable: [], + truncated: false, + }); + }); +}); + +describe("libraryClosure search paths", () => { it("normalizes $ORIGIN/.. before telling the app's own folder from system libraries", async () => { const closure = await libraryClosure( { diff --git a/src/domain/desktop/libraryPackages.ts b/src/domain/desktop/libraryPackages.ts index 17d0858dd0..c899699c22 100644 --- a/src/domain/desktop/libraryPackages.ts +++ b/src/domain/desktop/libraryPackages.ts @@ -3,7 +3,11 @@ import { posix } from "node:path"; import type { Result } from "../result.js"; import { fromUbuntuNobleArchive } from "./appExportInstall.js"; import { isWithinAny } from "./desktopStateDiff.js"; -import type { ElfDependencies } from "./elfDependencies.js"; +import type { + ElfDependencies, + ElfDependenciesFailure, + ElfLibraryIdentity, +} from "./elfDependencies.js"; /** One library `ldconfig -p` lists from the dynamic linker cache. */ export interface LinkerCacheEntry { @@ -36,20 +40,23 @@ const CACHE_FLAGS: ReadonlyMap = new Map([ ]); const MACHINE_FLAGS = new Set(CACHE_FLAGS.values()); -const cachedPath = ( +const cachedPaths = ( cache: readonly LinkerCacheEntry[], soname: string, machine: number, -): string | undefined => { +): readonly string[] => { const matching = cache.filter((entry) => entry.soname === soname); const flag = CACHE_FLAGS.get(machine); - return ( - flag === undefined - ? (matching.find( - (entry) => !entry.flags.some((name) => MACHINE_FLAGS.has(name)), - ) ?? matching[0]) - : matching.find((entry) => entry.flags.includes(flag)) - )?.path; + if (flag !== undefined) + return matching + .filter((entry) => entry.flags.includes(flag)) + .map((entry) => entry.path); + const unclassified = matching.filter( + (entry) => !entry.flags.some((name) => MACHINE_FLAGS.has(name)), + ); + return (unclassified.length > 0 ? unclassified : matching).map( + (entry) => entry.path, + ); }; /** A loaded object and the object whose DT_NEEDED loaded it, as ld.so links them. */ @@ -89,11 +96,32 @@ const searchDirectories = (object: LoadedObject): readonly string[] => { ]; }; +/** Search order identifies candidates; existence alone does not select a loaded object. */ +async function* libraryCandidates( + soname: string, + directories: readonly string[], + machine: number, + cache: readonly LinkerCacheEntry[], + host: LibraryWalkHost, +): AsyncGenerator { + if (soname.startsWith("/")) { + yield posix.normalize(soname); + return; + } + if (!soname.includes("/")) + for (const directory of directories) { + const candidate = posix.join(directory, soname); + if (await host.isFile(candidate)) yield candidate; + } + yield* cachedPaths(cache, soname, machine); +} + /** Host reads the closure walk needs. */ export interface LibraryWalkHost { readonly dependencies: ( path: string, - ) => Promise>; + expected: ElfLibraryIdentity, + ) => Promise>; readonly isFile: (path: string) => Promise; } @@ -104,9 +132,9 @@ export interface LibraryClosure { readonly soname: string; readonly path: string; }[]; - /** Sonames found neither in an RPATH or RUNPATH directory nor in the linker cache. */ + /** Sonames without a compatible candidate in the searched directories or linker cache. */ readonly missing: readonly string[]; - /** Libraries whose own dependencies could not be read. */ + /** Candidates whose selection or dependency closure could not be established. */ readonly unreadable: readonly { readonly path: string; readonly reason: string; @@ -144,23 +172,55 @@ export const libraryClosure = async ( if (seen.has(soname)) continue; if (seen.size >= limit) return { ...closure, truncated: true }; seen.add(soname); - let path = soname.startsWith("/") ? posix.normalize(soname) : undefined; - for (const directory of soname.includes("/") ? [] : directories) { - const candidate = posix.join(directory, soname); - if (path === undefined && (await host.isFile(candidate))) - path = candidate; + let selected: + | { + path: string; + dependencies: Result; + } + | undefined; + let unresolved = false; + for await (const path of libraryCandidates( + soname, + directories, + object.dependencies.machine, + cache, + host, + )) { + const dependencies = await host.dependencies(path, object.dependencies); + if (!dependencies.ok && dependencies.error.identity === null) { + // A failed identity read does not prove this candidate can be skipped, + // or that its package belongs to the executable's loaded closure. + closure.unreadable.push({ + path, + reason: dependencies.error.reason, + }); + unresolved = true; + break; + } + if (!dependencies.ok && dependencies.error.kind === "incompatible") { + if (soname.startsWith("/")) { + closure.unreadable.push({ + path, + reason: dependencies.error.reason, + }); + unresolved = true; + break; + } + continue; + } + selected = { path, dependencies }; + break; } - path ??= cachedPath(cache, soname, object.dependencies.machine); - if (path === undefined) { - closure.missing.push(soname); + if (selected === undefined) { + if (!unresolved) closure.missing.push(soname); continue; } + const { path, dependencies } = selected; if (!isWithinAny(path, [ownFolder])) closure.libraries.push({ soname, path }); - const dependencies = await host.dependencies(path); if (dependencies.ok) queue.push({ path, dependencies: dependencies.value, loader: object }); - else closure.unreadable.push({ path, reason: dependencies.error }); + else closure.unreadable.push({ path, reason: dependencies.error.reason }); } } return closure; diff --git a/src/domain/elfFixedHeader.ts b/src/domain/elfFixedHeader.ts index 4bc329b44e..fdd44366d8 100644 --- a/src/domain/elfFixedHeader.ts +++ b/src/domain/elfFixedHeader.ts @@ -85,8 +85,16 @@ export const readElfFixedHeader = ( totalBytes = bytes.length, ): Result => { const parsed = readElfHeaderFacts(bytes, totalBytes); - if (!parsed.ok) return parsed; - const facts = parsed.value; + return parsed.ok + ? admitElfFixedHeaderFacts(parsed.value, totalBytes) + : parsed; +}; + +/** Admit decoded facts without rereading or discarding their identity on failure. */ +export const admitElfFixedHeaderFacts = ( + facts: ElfHeaderFacts, + totalBytes: number, +): Result => { // NVIDIA CUBIN uses ELFOSABI_CUDA and a vendor ABI version in e_version. if (facts.version !== 1 && !(facts.machine === 190 && facts.osAbi === 0x33)) return err("unsupported ELF header version");