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;