diff --git a/docs/desktop-application-capture.md b/docs/desktop-application-capture.md index 1d165124ef..638f098c4d 100644 --- a/docs/desktop-application-capture.md +++ b/docs/desktop-application-capture.md @@ -35,7 +35,11 @@ rea capture-desktop-application --executable gnucash --argument /home/ada/books. Desktop entries are looked up in `$XDG_DATA_HOME/applications` and then each `$XDG_DATA_DIRS` directory; an entry installed as a symbolic link, as flatpak -and Nix do, is followed to its file. The entry's `Exec` line is expanded with no files; +and Nix do, is followed to its file. An XDG base folder variable counts only +when its value is an absolute path, as the XDG Base Directory Specification +requires, so a relative value leaves the home default in use instead of +naming a folder under the working directory. The entry's `Exec` line is +expanded with no files; `--argument` values are appended in its place, and the application runs in the entry's `Path` or the home directory. When the line starts with `env NAME=value`, the program is found on the `PATH` those assignments set, diff --git a/docs/specs/desktop-app-environments/capture/spec.md b/docs/specs/desktop-app-environments/capture/spec.md index b8dc38fd42..06ba4ca40c 100644 --- a/docs/specs/desktop-app-environments/capture/spec.md +++ b/docs/specs/desktop-app-environments/capture/spec.md @@ -50,11 +50,14 @@ folder to build the environment; this step builds nothing. `flatpak run` launch), otherwise the dpkg owner of the launched desktop entry, or else of the executable, with its version, architecture, the apt sources listing the - installed version and the `o=` origin apt reports for each source. The - entry's package is the one that installs the application: LibreOffice - Writer's entry belongs to `libreoffice-writer`, while the `libreoffice` it - runs belongs to `libreoffice-common`, which does not depend on Writer. When - the two differ, `executable_package` names the executable's, with + installed version and the `o=` origin apt reports for each source. An XDG + base folder variable names a folder only when its value is an absolute + path, as the XDG Base Directory Specification requires, so a relative value + leaves the home default in use instead of naming a folder under the working + directory. The entry's package is the one that installs the application: + LibreOffice Writer's entry belongs to `libreoffice-writer`, while the + `libreoffice` it runs belongs to `libreoffice-common`, which does not + depend on Writer. When the two differ, `executable_package` names the executable's, with `executable_package_reason` when neither the Ubuntu 24.04 archive nor a declared vendor repository offers it or apt could not say; each vendor repository records in `supplies` the library packages and executable's diff --git a/src/desktop/ApplicationStateDirectories.ts b/src/desktop/ApplicationStateDirectories.ts index 5860848f25..4b628fc054 100644 --- a/src/desktop/ApplicationStateDirectories.ts +++ b/src/desktop/ApplicationStateDirectories.ts @@ -2,6 +2,7 @@ import { lstat, readdir } from "node:fs/promises"; import { basename, join } from "node:path"; import type { DesktopApplicationInventory } from "../domain/desktop/desktopCapture.js"; +import { xdgBase } from "./XdgBase.js"; /** The environment whose XDG folders, and the home whose dot-folders, may hold the application's state. */ interface StateLocations { @@ -64,10 +65,10 @@ export const stateDirectories = async ( extra: readonly string[], ): Promise => { const bases = [ - environment.XDG_CONFIG_HOME || join(home, ".config"), - environment.XDG_DATA_HOME || join(home, ".local/share"), - environment.XDG_STATE_HOME || join(home, ".local/state"), - environment.XDG_CACHE_HOME || join(home, ".cache"), + xdgBase(environment.XDG_CONFIG_HOME, join(home, ".config")), + xdgBase(environment.XDG_DATA_HOME, join(home, ".local/share")), + xdgBase(environment.XDG_STATE_HOME, join(home, ".local/state")), + xdgBase(environment.XDG_CACHE_HOME, join(home, ".cache")), ]; const variants = new Set( names diff --git a/src/desktop/BuildSecretFiles.test.ts b/src/desktop/BuildSecretFiles.test.ts index c11fee3f80..c9b20e5dee 100644 --- a/src/desktop/BuildSecretFiles.test.ts +++ b/src/desktop/BuildSecretFiles.test.ts @@ -43,6 +43,11 @@ describe("build secret files", () => { expect(buildSecretsDirectory({ HOME: "/h" })).toBe( "/h/.config/rea/build-secrets", ); + // An absolute XDG_CONFIG_HOME decides the folder without consulting the + // host account, which a Unix UID without a passwd entry cannot answer. + expect(buildSecretsDirectory({ XDG_CONFIG_HOME: "/cfg" }, "linux")).toBe( + "/cfg/rea/build-secrets", + ); }); it("reads a file only its owner can read, without its final line break", async () => { diff --git a/src/desktop/BuildSecretFiles.ts b/src/desktop/BuildSecretFiles.ts index 2276d9c28c..7f4762d937 100644 --- a/src/desktop/BuildSecretFiles.ts +++ b/src/desktop/BuildSecretFiles.ts @@ -10,7 +10,7 @@ import { unlink, type FileHandle, } from "node:fs/promises"; -import { isAbsolute, join } from "node:path"; +import { join } from "node:path"; import { ArtifactFileCleanupFailure, @@ -35,6 +35,7 @@ import { readStableFileBytes, } from "../filesystem/readStableFileBytes.js"; import { RegularFileChangedError } from "../filesystem/RegularFile.js"; +import { xdgBase } from "./XdgBase.js"; /** The most bytes a secret file of at most `characters` may hold: those in UTF-8 and a line break. */ const maxFileBytes = (characters: number) => characters * 4 + 2; @@ -44,11 +45,12 @@ export const buildSecretsDirectory = ( environment: Readonly, platform: NodeJS.Platform = process.platform, ): string => { - const xdg = environment.XDG_CONFIG_HOME; - const root = - xdg !== undefined && isAbsolute(xdg) - ? xdg - : join(homeDirectoryFromEnvironment(environment, platform), ".config"); + const root = xdgBase(environment.XDG_CONFIG_HOME, () => + // Resolve the home fallback only when it is the one that applies: on a host + // without a passwd entry for the caller it can throw, and an absolute + // XDG_CONFIG_HOME leaves it unused. + join(homeDirectoryFromEnvironment(environment, platform), ".config"), + ); return join(root, "rea", "build-secrets"); }; diff --git a/src/desktop/LinuxApplicationInventory.test.ts b/src/desktop/LinuxApplicationInventory.test.ts index 2bb060538b..9cb927edac 100644 --- a/src/desktop/LinuxApplicationInventory.test.ts +++ b/src/desktop/LinuxApplicationInventory.test.ts @@ -6,7 +6,7 @@ import { symlink, writeFile, } from "node:fs/promises"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -647,7 +647,10 @@ const inventoryOf = async ( { home, environment, - }: Pick>, "home" | "environment">, + }: { + home: string; + environment: Readonly>; + }, ) => { const inspected = await inspectLinuxApplication({ desktopEntry, @@ -824,6 +827,76 @@ describe.skipIf(process.platform === "win32")( }, ); +describe.skipIf(process.platform === "win32")( + "inspectLinuxApplication relative XDG bases", + () => { + it("ignores a relative XDG base and keeps the home default", async () => { + const installed = await fixture(); + const { home, environment } = installed; + const inventory = await inventoryOf("org.example.notes", { + ...installed, + environment: { + ...environment, + XDG_CONFIG_HOME: "relative-config", + XDG_DATA_HOME: "relative-data", + XDG_STATE_HOME: "relative-state", + XDG_CACHE_HOME: "relative-cache", + }, + }); + + const paths = inventory.state_directories.map((entry) => entry.path); + expect(paths).toContain(join(home, ".config/notes")); + expect(paths).toContain(join(home, ".local/share/notes")); + expect(paths).toContain(join(home, ".local/state/notes")); + expect(paths).toContain(join(home, ".cache/notes")); + expect(paths.filter((path) => !isAbsolute(path))).toEqual([]); + }); + + it("reads a relative XDG base an Exec env prefix sets as unset", async () => { + const installed = await fixture(); + const { home, bin } = installed; + await mkdir(join(home, ".config"), { recursive: true }); + await mkdir(join(home, ".local/share/applications"), { + recursive: true, + }); + await writeFile( + join( + home, + ".local/share/applications/org.example.env-relative.desktop", + ), + `[Desktop Entry]\nType=Application\nName=Env\nExec=env XDG_CONFIG_HOME=relative-config notes-app\n`, + ); + + const inventory = await inventoryOf( + "org.example.env-relative", + installed, + ); + + expect(inventory.executable.path).toBe(join(bin, "notes-app")); + expect(inventory.state_directories.map((entry) => entry.path)).toContain( + join(home, ".config/notes-app"), + ); + }); + + it("finds a desktop entry by id when XDG_DATA_HOME is relative", async () => { + const installed = await fixture(); + const { home, environment } = installed; + await mkdir(join(home, ".local/share/applications"), { recursive: true }); + await writeFile( + join(home, ".local/share/applications/org.example.home-notes.desktop"), + `[Desktop Entry]\nType=Application\nName=HomeNotes\nExec=notes-app\n`, + ); + + const inventory = await inventoryOf("org.example.home-notes", { + ...installed, + environment: { ...environment, XDG_DATA_HOME: "relative-data" }, + }); + + expect(inventory.desktop_entry).toMatchObject({ name: "HomeNotes" }); + }); + }, +); + it.skipIf(process.platform === "win32").each([ { stage: "desktop entry", closeAt: 1, oversized: false, cancel: false }, { diff --git a/src/desktop/LinuxApplicationInventory.ts b/src/desktop/LinuxApplicationInventory.ts index 8d3a8d73eb..54aa604ebd 100644 --- a/src/desktop/LinuxApplicationInventory.ts +++ b/src/desktop/LinuxApplicationInventory.ts @@ -30,6 +30,7 @@ import { } from "../filesystem/readStableFileBytes.js"; import { stateDirectories, stateNames } from "./ApplicationStateDirectories.js"; import { APT_LOCATIONS } from "./AptRepositoryInventory.js"; +import { xdgBase } from "./XdgBase.js"; import { inspectPackageFile } from "./DebianPackageFile.js"; import { debianPackage, @@ -96,7 +97,10 @@ const desktopEntryDirectories = ( environment: LinuxApplicationRequest["environment"], home: string, ): readonly string[] => { - const dataHome = environment.XDG_DATA_HOME || join(home, ".local/share"); + const dataHome = xdgBase( + environment.XDG_DATA_HOME, + join(home, ".local/share"), + ); const dataDirectories = ( environment.XDG_DATA_DIRS || "/usr/local/share:/usr/share" ) diff --git a/src/desktop/XdgBase.ts b/src/desktop/XdgBase.ts new file mode 100644 index 0000000000..649cf546c5 --- /dev/null +++ b/src/desktop/XdgBase.ts @@ -0,0 +1,20 @@ +import { isAbsolute } from "node:path"; + +/** + * An XDG base folder: the variable's value when it is an absolute path, and + * the home default otherwise. The XDG Base Directory Specification defines + * these variables as absolute paths and says to ignore a relative value, so a + * relative one must not redirect a lookup under the working directory. + * + * The fallback may be a thunk so a caller whose default needs the host account + * never pays for that lookup when the variable already decides the folder. + */ +export const xdgBase = ( + value: string | undefined, + fallback: string | (() => string), +): string => + value !== undefined && isAbsolute(value) + ? value + : typeof fallback === "function" + ? fallback() + : fallback;