fix(desktop): ignore relative XDG bases in capture inventory

Desktop capture used XDG_* values without checking that they are
absolute, so a relative XDG_CONFIG_HOME (or data, state, cache) moved
state-directory, desktop-entry and build-secret lookups under the
working directory. The XDG Base Directory Specification says to ignore
a relative value; one xdgBase helper now applies that rule and falls
back to the home default.

Fixes #9702

Squashed from #9705 and rebased onto main. Setup's client registration
paths are left unchanged: each client's own handling of a relative
XDG_CONFIG_HOME has to be verified before REA writes elsewhere.

Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
2SinceJulyandgensokyo-dev authored and Ysopwnable committed 2026-11-14 14:59:49 +01:00
1 parent 0ffde0cd09
commit 4b5d00bebd
8 files changed
+131 -19

No files matched your search

+5 -1
View File
@@ -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,
@@ -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
+5 -4
View File
@@ -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<DesktopApplicationInventory["state_directories"]> => {
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
+5
View File
@@ -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 () => {
+8 -6
View File
@@ -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<NodeJS.ProcessEnv>,
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");
};
+75 -2
View File
@@ -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<Awaited<ReturnType<typeof fixture>>, "home" | "environment">,
}: {
home: string;
environment: Readonly<Record<string, string | undefined>>;
},
) => {
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 },
{
+5 -1
View File
@@ -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"
)
+20
View File
@@ -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;