From fa0fcf35ce5b2c0fb51678f8ae40756eebbddf47 Mon Sep 17 00:00:00 2001 From: 2SinceJuly Date: Mon, 9 Nov 2026 21:47:20 +0800 Subject: [PATCH] fix(desktop): resolve desktop entry ids that name a nested entry (#9563) * fix(desktop): resolve desktop entry ids that name a nested entry The desktop file id of an entry below an applications folder replaces the directory separators with dashes, so applications/vendor/foo.desktop advertises vendor-foo.desktop. locateDesktopEntry only probed the flat path, so such an entry was either not found or, when a lower-priority XDG root installed a flat file with the same id, a different application was launched under the requested id. List each applications directory instead and form every entry's id from the path that holds it, so a dash inside a directory name does not change the answer. The walk is bounded and stops reporting absence once it can no longer decide, so a truncated search refuses instead of falling through to a lower-priority root. * fix(desktop): search nested entry directories without skipping answers Three defects in findNestedDesktopEntry, all of which could select a lower-priority application under the same desktop file id: `readdir` failure was treated as an empty directory, so an applications root or nested directory that could not be listed silently yielded to a lower-priority root. Only ENOENT and ENOTDIR now mean the root is not installed; any other failure reports desktop_entry_search_incomplete. A directory whose own name ends in `.desktop` was skipped before it was searched, so `vendor.desktop/app.desktop` (id `vendor.desktop-app.desktop`) was unreachable. The suffix now selects a candidate only for files; directories still descend. The entry budget was applied after `readdir` had materialized every name, so a large directory allocated far more than the 4096 entries it may examine. The walk now enumerates with `opendir` and charges the budget per entry. * fix(desktop): finish the reachable nested search before reporting a gap An unreadable or over-budget subtree is not proof the entry is absent, but returning truncated as soon as one was met stopped the walk before the siblings that could still hold it. The entry then looked incomplete even though this root could answer, so a lower-priority root was never consulted and the search reported a gap instead of the entry. * test(javascript): distinguish package scope in route fixtures * test(apple): separate identity and refusal fixture groups * fix(desktop): resolve only applicable desktop ID paths * test(desktop): cover applicable probe failure before a matching spelling --------- Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/desktop-application-capture.md | 7 + .../desktop/DesktopCaptureRefusals.ts | 6 + src/desktop/LinuxApplicationInventory.test.ts | 357 +++++++++++++++++- src/desktop/LinuxApplicationInventory.ts | 106 +++++- 4 files changed, 471 insertions(+), 5 deletions(-) diff --git a/docs/desktop-application-capture.md b/docs/desktop-application-capture.md index 57a9f8498b..32de989845 100644 --- a/docs/desktop-application-capture.md +++ b/docs/desktop-application-capture.md @@ -47,6 +47,13 @@ computer would not have that entry. An `--executable` runs from the current directory, as it would from a shell, and a relative path to it is resolved there. +A nested desktop file uses a dashed ID: `vendor/app.desktop` has ID +`vendor-app`. REA checks path spellings that can encode that ID within each +XDG root before trying the next root. Unrelated directories do not affect +selection. If an applicable path cannot be examined or the bounded search +cannot establish absence, capture reports an incomplete desktop-entry search +rather than selecting a different application from a later root. + Desktop-entry string values retain literal trailing spaces. For example, `Path=/tmp/workspace ` and `Path=/tmp/workspace\s` name the same directory ending in a space. Use the escaped spelling to make such a suffix visible. diff --git a/src/application/desktop/DesktopCaptureRefusals.ts b/src/application/desktop/DesktopCaptureRefusals.ts index f4b610902b..2b3be56370 100644 --- a/src/application/desktop/DesktopCaptureRefusals.ts +++ b/src/application/desktop/DesktopCaptureRefusals.ts @@ -60,6 +60,12 @@ export const applicationRefusal = ( ["desktop_entry"], "Pass an installed entry's id (for example org.xfce.mousepad) or its absolute path, or name the program with --executable.", ); + case "desktop_entry_search_incomplete": + return inputError( + `The search for a desktop entry with that id under ${refusal.searched.join(", ")} stopped before it could tell whether one is installed.`, + ["desktop_entry"], + "Pass the entry's absolute path, or narrow the installed entries under those folders.", + ); case "desktop_entry_invalid": return inputError( `The desktop entry cannot be launched: ${refusal.detail}.`, diff --git a/src/desktop/LinuxApplicationInventory.test.ts b/src/desktop/LinuxApplicationInventory.test.ts index 974b772430..9e6725e0f8 100644 --- a/src/desktop/LinuxApplicationInventory.test.ts +++ b/src/desktop/LinuxApplicationInventory.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, symlink, writeFile } from "node:fs/promises"; +import { chmod, mkdir, stat, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -194,10 +194,363 @@ describe.skipIf(process.platform === "win32")("inspectLinuxApplication", () => { }); }); +describe.skipIf(process.platform === "win32")( + "inspectLinuxApplication desktop file ids", + () => { + it("resolves a nested entry by its desktop file id before a lower-priority flat one", async () => { + const { home, environment } = await fixture(); + const share = environment.XDG_DATA_DIRS; + await mkdir(join(home, ".local/share/applications/vendor"), { + recursive: true, + }); + await writeFile( + join( + home, + ".local/share/applications/vendor/org.example.nested.desktop", + ), + "[Desktop Entry]\nType=Application\nName=High\nExec=notes-app --high\n", + ); + // The spec's id for the nested entry above, installed at lower priority. + await writeFile( + join(share, "applications/vendor-org.example.nested.desktop"), + "[Desktop Entry]\nType=Application\nName=Low\nExec=notes-app --low\n", + ); + + const inventory = await inventoryOf("vendor-org.example.nested", { + home, + environment, + }); + + expect(inventory.desktop_entry).toMatchObject({ + id: "vendor-org.example.nested", + name: "High", + }); + expect(inventory.argv).toEqual(["notes-app", "--high"]); + }); + + it("resolves an id whose directory name itself contains a dash", async () => { + const { home, environment } = await fixture(); + const directory = join(home, ".local/share/applications/org.example"); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "my-app.desktop"), + "[Desktop Entry]\nType=Application\nName=Dashed\nExec=notes-app\n", + ); + + const inventory = await inventoryOf("org.example-my-app", { + home, + environment, + }); + + expect(inventory.desktop_entry).toMatchObject({ + id: "org.example-my-app", + name: "Dashed", + }); + }); + + it("refuses a lower-priority entry when an applicable path cannot be probed", async () => { + const { home, environment } = await fixture(); + const share = environment.XDG_DATA_DIRS; + const applications = join(home, ".local/share/applications"); + const vendor = join(applications, "vendor"); + await mkdir(vendor, { recursive: true }); + await writeFile( + join(vendor, "org.example.app.desktop"), + "[Desktop Entry]\nType=Application\nName=High\nExec=notes-app --high\n", + ); + await writeFile( + join(share, "applications/vendor-org.example.app.desktop"), + "[Desktop Entry]\nType=Application\nName=Low\nExec=notes-app --low\n", + ); + // The exact higher-priority path cannot be examined without directory + // traversal permission; it must remain unknown rather than absent. + await chmod(vendor, 0o000); + + try { + const inspected = await inspectLinuxApplication({ + desktopEntry: "vendor-org.example.app", + executable: undefined, + arguments: [], + environment, + home, + callerDirectory: home, + }); + + // Root ignores the mode bits, so the directory stays listable and the + // scenario this asserts cannot be built. + if (inspected.ok) expect(process.getuid?.()).toBe(0); + else + expect(inspected.error.reason).toBe( + "desktop_entry_search_incomplete", + ); + } finally { + await chmod(vendor, 0o755); + } + }); + }, +); + +describe.skipIf(process.platform === "win32")( + "unresolved desktop entry link targets", + () => { + it + .skipIf(process.getuid?.() === 0) + .each(["directory", "flat-file", "nested-file"] as const)( + "refuses a lower-priority entry when a %s link target cannot be examined", + async (kind) => { + const { home, environment } = await fixture(); + const applications = join(home, ".local/share/applications"); + const blocked = join(home, "blocked"); + const target = join(blocked, "vendor"); + await mkdir(target, { recursive: true }); + await mkdir(applications, { recursive: true }); + await writeFile( + join(target, "app.desktop"), + "[Desktop Entry]\nType=Application\nName=High\nExec=notes-app\n", + ); + await writeFile( + join(environment.XDG_DATA_DIRS, "applications/vendor-app.desktop"), + "[Desktop Entry]\nType=Application\nName=Low\nExec=notes-app\n", + ); + let link: string; + if (kind === "directory") { + link = join(applications, "vendor"); + await symlink(target, link); + } else { + if (kind === "nested-file") await mkdir(join(applications, "vendor")); + link = join( + applications, + kind === "flat-file" ? "vendor-app.desktop" : "vendor/app.desktop", + ); + await symlink(join(target, "app.desktop"), link); + } + await chmod(blocked, 0o000); + try { + const probe = await stat(link).then( + () => undefined, + (cause: unknown) => cause, + ); + expect(probe).toMatchObject({ code: "EACCES" }); + const result = await inspectLinuxApplication({ + desktopEntry: "vendor-app", + executable: undefined, + arguments: [], + environment, + home, + callerDirectory: home, + }); + expect(result).toMatchObject({ + ok: false, + error: { reason: "desktop_entry_search_incomplete" }, + }); + } finally { + await chmod(blocked, 0o755); + } + }, + ); + }, +); + +describe.skipIf(process.platform === "win32")( + "remaining desktop file id controls", + () => { + it("ignores an unreadable sibling that cannot encode the requested ID", async () => { + const { home, environment } = await fixture(); + const applications = join(home, ".local/share/applications"); + const found = join(applications, "zz-vendor"); + const unreadable = join(applications, "aa-vendor"); + await mkdir(found, { recursive: true }); + await mkdir(unreadable, { recursive: true }); + await writeFile( + join(found, "org.example.app.desktop"), + "[Desktop Entry]\nType=Application\nName=High\nExec=notes-app\n", + ); + await chmod(unreadable, 0o000); + + try { + const inventory = await inventoryOf("zz-vendor-org.example.app", { + home, + environment, + }); + + // This sibling cannot encode the requested ID and is irrelevant to + // selecting the known matching path. + expect(inventory.desktop_entry).toMatchObject({ + id: "zz-vendor-org.example.app", + name: "High", + }); + } finally { + await chmod(unreadable, 0o755); + } + }); + + it.skipIf(process.getuid?.() === 0)( + "finds a same-root spelling after an applicable probe fails", + async () => { + const { home, environment } = await fixture(); + const applications = join(home, ".local/share/applications"); + const blocked = join(home, "blocked"); + await mkdir(join(blocked, "aa"), { recursive: true }); + await mkdir(join(applications, "aa-bb"), { recursive: true }); + await symlink(join(blocked, "aa"), join(applications, "aa")); + await writeFile( + join(applications, "aa-bb/app.desktop"), + "[Desktop Entry]\nType=Application\nName=Matched\nExec=notes-app\n", + ); + await chmod(blocked, 0o000); + try { + await expect(stat(join(applications, "aa"))).rejects.toMatchObject({ + code: "EACCES", + }); + const inventory = await inventoryOf("aa-bb-app", { + home, + environment, + }); + expect(inventory.desktop_entry).toMatchObject({ + id: "aa-bb-app", + name: "Matched", + }); + } finally { + await chmod(blocked, 0o755); + } + }, + ); + + it.each(["plain", "vendor-app", "loop-vendor-app"])( + "ignores unrelated directory-link cycles when resolving %s in a later root", + async (id) => { + const { home, environment } = await fixture(); + const applications = join(home, ".local/share/applications"); + await mkdir(applications, { recursive: true }); + await symlink(".", join(applications, "loop")); + await writeFile( + join(environment.XDG_DATA_DIRS, "applications", id + ".desktop"), + "[Desktop Entry]\nType=Application\nName=Selected\nExec=notes-app\n", + ); + const inventory = await inventoryOf(id, { home, environment }); + expect(inventory.desktop_entry).toMatchObject({ id, name: "Selected" }); + }, + ); + + it("keeps an applicable path beyond the depth allowance unresolved", async () => { + const { home, environment } = await fixture(); + const applications = join(home, ".local/share/applications"); + const directory = join(applications, "one/two/three/four/five"); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "app.desktop"), + "[Desktop Entry]\nType=Application\nName=High\nExec=notes-app\n", + ); + const id = "one-two-three-four-five-app"; + await writeFile( + join(environment.XDG_DATA_DIRS, "applications", id + ".desktop"), + "[Desktop Entry]\nType=Application\nName=Low\nExec=notes-app\n", + ); + const result = await inspectLinuxApplication({ + desktopEntry: id, + executable: undefined, + arguments: [], + environment, + home, + callerDirectory: home, + }); + expect(result).toMatchObject({ + ok: false, + error: { reason: "desktop_entry_search_incomplete" }, + }); + }); + + it("searches a directory whose own name ends in .desktop", async () => { + const { home, environment } = await fixture(); + const directory = join(home, ".local/share/applications/vendor.desktop"); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "app.desktop"), + "[Desktop Entry]\nType=Application\nName=Nested\nExec=notes-app\n", + ); + + const inventory = await inventoryOf("vendor.desktop-app", { + home, + environment, + }); + + expect(inventory.desktop_entry).toMatchObject({ + id: "vendor.desktop-app", + name: "Nested", + }); + }); + + it("keeps a dash inside a directory name, not only inside the file name", async () => { + const { home, environment } = await fixture(); + const directory = join( + home, + ".local/share/applications/org.example-tools", + ); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "my-app.desktop"), + "[Desktop Entry]\nType=Application\nName=Both\nExec=notes-app\n", + ); + + const inventory = await inventoryOf("org.example-tools-my-app", { + home, + environment, + }); + + expect(inventory.desktop_entry).toMatchObject({ + id: "org.example-tools-my-app", + name: "Both", + }); + }); + }, +); + +describe.skipIf(process.platform === "win32")( + "inspectLinuxApplication incomplete desktop file searches", + () => { + it("ignores deep trees that cannot encode the requested desktop ID", async () => { + const { home, environment } = await fixture(); + const share = environment.XDG_DATA_DIRS; + const applications = join(home, ".local/share/applications"); + // An unrelated deep tree cannot change the requested ID's applicability. + for (let index = 0; index < 6; index += 1) + await mkdir( + join(applications, ...Array(index + 1).fill("deep")), + { recursive: true }, + ); + await writeFile( + join(share, "applications/buried-org.example.app.desktop"), + "[Desktop Entry]\nType=Application\nName=Low\nExec=notes-app\n", + ); + + const inspected = await inspectLinuxApplication({ + desktopEntry: "buried-org.example.app", + executable: undefined, + arguments: [], + environment, + home, + callerDirectory: home, + }); + + expect(inspected).toMatchObject({ + ok: true, + value: { + inventory: { + desktop_entry: { id: "buried-org.example.app", name: "Low" }, + }, + }, + }); + }); + }, +); + /** The inventory of an installed desktop entry, launched with no arguments. */ const inventoryOf = async ( desktopEntry: string, - { home, environment }: Awaited>, + { + home, + environment, + }: Pick>, "home" | "environment">, ) => { const inspected = await inspectLinuxApplication({ desktopEntry, diff --git a/src/desktop/LinuxApplicationInventory.ts b/src/desktop/LinuxApplicationInventory.ts index a72261ec41..d6f2f20cbe 100644 --- a/src/desktop/LinuxApplicationInventory.ts +++ b/src/desktop/LinuxApplicationInventory.ts @@ -54,6 +54,10 @@ export type LinuxApplicationRefusal = readonly reason: "desktop_entry_not_found"; readonly searched: readonly string[]; } + | { + readonly reason: "desktop_entry_search_incomplete"; + readonly searched: readonly string[]; + } | { readonly reason: "desktop_entry_invalid"; readonly detail: string } | { readonly reason: "executable_not_found"; readonly program: string } | { @@ -95,9 +99,88 @@ export const desktopEntryDirectories = ( ); }; +/** How deep below one applications directory a nested id search may descend. */ +const NESTED_SEARCH_MAX_DEPTH = 4; +/** How many applicable path probes one applications directory search may issue. */ +const NESTED_SEARCH_MAX_ENTRIES = 4096; + +type DesktopPathKind = "file" | "directory" | "other" | "absent"; + +/** Only missing paths establish absence; failed identity probes remain unresolved. */ +const desktopPathKind = async ( + path: string, +): Promise> => { + try { + const facts = await stat(path); + return ok( + facts.isFile() ? "file" : facts.isDirectory() ? "directory" : "other", + ); + } catch (cause: unknown) { + const code = (cause as NodeJS.ErrnoException).code; + return code === "ENOENT" || code === "ENOTDIR" ? ok("absent") : err(cause); + } +}; + +/** + * A desktop ID replaces path separators with dashes, while names can also + * contain dashes. Probe only spellings that encode this ID, rather than + * enumerating unrelated directories. Each descent consumes part of the ID. + */ +const findNestedDesktopEntry = async ( + applicationsDirectory: string, + id: string, + signal: AbortSignal | undefined, +): Promise<{ path: string } | { truncated: true } | undefined> => { + const pending = [{ path: applicationsDirectory, remaining: id, depth: 1 }]; + let examined = 0; + let incomplete = false; + while (pending.length > 0) { + signal?.throwIfAborted(); + const directory = pending.pop()!; + if (directory.depth > 1) { + if (examined >= NESTED_SEARCH_MAX_ENTRIES) return { truncated: true }; + examined++; + const path = join(directory.path, directory.remaining); + const kind = await desktopPathKind(path); + signal?.throwIfAborted(); + if (!kind.ok) incomplete = true; + else if (kind.value === "file") return { path }; + } + for ( + let split = directory.remaining.indexOf("-"); + split !== -1; + split = directory.remaining.indexOf("-", split + 1) + ) { + const prefix = directory.remaining.slice(0, split); + if (prefix === "" || prefix === "." || prefix === "..") continue; + if (examined >= NESTED_SEARCH_MAX_ENTRIES) return { truncated: true }; + examined++; + const path = join(directory.path, prefix); + const kind = await desktopPathKind(path); + signal?.throwIfAborted(); + if (!kind.ok) { + incomplete = true; + continue; + } + if (kind.value !== "directory") continue; + if (directory.depth >= NESTED_SEARCH_MAX_DEPTH) { + incomplete = true; + continue; + } + pending.push({ + path, + remaining: directory.remaining.slice(split + 1), + depth: directory.depth + 1, + }); + } + } + return incomplete ? { truncated: true } : undefined; +}; + const locateDesktopEntry = async ( request: LinuxApplicationRequest, selector: string, + signal?: AbortSignal, ): Promise< Result<{ path: string; id: string | null }, LinuxApplicationRefusal> > => { @@ -105,10 +188,23 @@ const locateDesktopEntry = async ( const id = selector.endsWith(".desktop") ? selector : `${selector}.desktop`; const searched = desktopEntryDirectories(request.environment, request.home); for (const directory of searched) { + signal?.throwIfAborted(); const path = join(directory, id); - // Flatpak exports and Nix profiles install entries as links to the file. - if ((await stat(path).catch(() => undefined))?.isFile() === true) + const kind = await desktopPathKind(path); + signal?.throwIfAborted(); + if (!kind.ok) + return err({ reason: "desktop_entry_search_incomplete", searched }); + if (kind.value === "file") return ok({ path, id: id.slice(0, -".desktop".length) }); + // A nested entry in this directory outranks a flat one in a later root. + const nested = await findNestedDesktopEntry(directory, id, signal); + signal?.throwIfAborted(); + if (nested !== undefined && "path" in nested) + return ok({ path: nested.path, id: id.slice(0, -".desktop".length) }); + // The bounded search did not establish absence, so a later root must not + // supply a different application under the same id. + if (nested !== undefined) + return err({ reason: "desktop_entry_search_incomplete", searched }); } return err({ reason: "desktop_entry_not_found", searched }); }; @@ -265,7 +361,11 @@ const launchCommand = async ( entry: null, }); } - const located = await locateDesktopEntry(request, request.desktopEntry); + const located = await locateDesktopEntry( + request, + request.desktopEntry, + signal, + ); if (!located.ok) return located; const entry = await readDesktopEntry(located.value.path, signal); if (!entry.ok) return entry;