Files
Duncan McIntosh 239c86055c Bug 2032098 - Don't look for argv[0] commands in the current directory if they don't seem to be paths. r=emilio
Worst part is, this is the obvious case! Wasn't caught because the tests
generally use an absolute path or empty string as argv[0], instead of
the more likely "firefox" etc.

Differential Revision: https://phabricator.services.mozilla.com/D294450
2026-04-15 21:31:41 +00:00

466 lines
13 KiB
JavaScript

/* Any copyright is dedicated to the Public Domain.
* https://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
ChromeUtils.defineESModuleGetters(this, {
AppConstants: "resource://gre/modules/AppConstants.sys.mjs",
FileTestUtils: "resource://testing-common/FileTestUtils.sys.mjs",
ShellService: "moz-src:///browser/components/shell/ShellService.sys.mjs",
sinon: "resource://testing-common/Sinon.sys.mjs",
});
const { Subprocess, getSubprocessImplForTest } = ChromeUtils.importESModule(
"resource://gre/modules/Subprocess.sys.mjs"
);
const kOriginalEnvironment = Subprocess.getEnvironment();
let gEnvironment = [["PATH", ""]];
const gBrowserExe = Services.dirsvc.get("XREExeF", Ci.nsIFile).path;
// This is a symbol so it's a different type than a string.
const kAbsoluteArgv0 = Symbol("argv0");
const gTmpDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
gTmpDir.append("createLinuxDesktopEntry-" + Date.now());
const gDirectoryServiceProvider = {
getFile(prop, persistent) {
persistent.value = false;
// We only expect a narrow range of calls.
let folder;
let type = Ci.nsIFile.DIRECTORY_TYPE;
switch (prop) {
case "Home":
folder = gTmpDir.clone();
folder.append("home");
break;
default:
console.error(`Access to unexpected directory '${prop}'`);
return Cr.NS_ERROR_FAILURE;
}
try {
folder.create(type, 0o755);
} catch (e) {
if (e.result !== Cr.NS_ERROR_FILE_ALREADY_EXISTS) {
throw e;
}
}
return folder;
},
};
/**
* Parses the INI file at the given path.
*
* @param {string} path - The path to the INI file.
* @returns {nsIINIParser} The parsed INI file.
*/
async function parseINI(path) {
let parser = Cc["@mozilla.org/xpcom/ini-parser-factory;1"]
.getService(Ci.nsIINIParserFactory)
.createINIParser();
// The file should be UTF-8, so use IOUtils to make sure that's the case.
parser.initFromString(await IOUtils.readUTF8(path));
return parser;
}
add_setup(async function setup() {
Services.dirsvc
.QueryInterface(Ci.nsIDirectoryService)
.registerProvider(gDirectoryServiceProvider);
sinon
.stub(getSubprocessImplForTest(), "getEnvironment")
.callsFake(() => gEnvironment);
});
registerCleanupFunction(async function cleanupTmp() {
gTmpDir.remove(true);
});
add_setup(function setupEnv() {
Services.env.set("XDG_DATA_HOME", "");
});
add_task(async function test_validateAppId() {
let message = /Desktop entry ID '[^']*' is invalid/;
await Assert.rejects(
ShellService.createLinuxDesktopEntry("", "ignored", [], ""),
message,
"The empty string is not a valid application ID"
);
await Assert.rejects(
ShellService.createLinuxDesktopEntry("a.1b.c", "ignored", [], ""),
message,
"Segment cannot begin with a digit"
);
await Assert.rejects(
ShellService.createLinuxDesktopEntry("a..c", "ignored", [], ""),
message,
"Segment cannot be empty"
);
// Doesn't really care about return value, just that it doesn't throw.
Assert.equal(
await ShellService.createLinuxDesktopEntry("a.b.c", "ignored", [], ""),
undefined,
"Typical segment is allowed"
);
Assert.equal(
await ShellService.createLinuxDesktopEntry(
"a-._b4.c__3",
"ignored",
[],
""
),
undefined,
"Underscores and numbers are allowed"
);
});
add_task(async function test_xdgdir() {
// By default, it should go into ~/.local/share/applications.
let path = PathUtils.join(
gTmpDir.path,
"home",
".local",
"share",
"applications",
"xdgdir.a.b.desktop"
);
await ShellService.createLinuxDesktopEntry("xdgdir.a.b", "ignored", [], "");
Assert.ok(
await IOUtils.exists(path),
"Desktop file was created in the default location if XDG_DATA_HOME is unset"
);
// But $XDG_DATA_HOME/applications should be used instead if available.
Services.env.set("XDG_DATA_HOME", PathUtils.join(gTmpDir.path, "datahome"));
path = PathUtils.join(
gTmpDir.path,
"datahome",
"applications",
"xdgdir.c.d.desktop"
);
await ShellService.createLinuxDesktopEntry("xdgdir.c.d", "ignored", [], "");
Assert.ok(
await IOUtils.exists(path),
"Desktop file was created in XDG_DATA_HOME/applications"
);
await IOUtils.remove(path); // datahome isn't removed in 'cleanup'
// ...unless it's invalid. (Or empty, but XPCOM doesn't differentiate 'empty'
// and 'nonexistent', which is fine.)
Services.env.set("XDG_DATA_HOME", "pineapple!");
path = PathUtils.join(
gTmpDir.path,
"home",
".local",
"share",
"applications",
"xdgdir.e.f.desktop"
);
await ShellService.createLinuxDesktopEntry("xdgdir.e.f", "ignored", [], "");
Assert.ok(
await IOUtils.exists(path),
"Desktop file was created in the default location if XDG_DATA_HOME is invalid"
);
Services.env.set("XDG_DATA_HOME", "");
});
add_task(async function test_standardContent() {
let sandbox = sinon.createSandbox();
sandbox.stub(ShellService, "shellService").value({
getArgv0: () => "/executable",
});
let path = PathUtils.join(
gTmpDir.path,
"home",
".local",
"share",
"applications",
"content.a.desktop"
);
await ShellService.createLinuxDesktopEntry(
"content.a",
"Cool Progr\xe0m!",
[],
"open-menu-symbolic"
);
let ini = await parseINI(path);
Assert.equal(
ini.getSections().getNext(),
"Desktop Entry",
"'Desktop Entry' must be the first section in the file"
);
Assert.equal(
ini.getString("Desktop Entry", "Version"),
"1.5",
"Compliance with version 1.5 of the spec is declared"
);
Assert.equal(
ini.getString("Desktop Entry", "Name"),
"Cool Progr\xe0m!",
"The name is stored, including any non-ASCII characters"
);
Assert.equal(
ini.getString("Desktop Entry", "Exec"),
`"/executable"`,
"argv[0] will be run without any arguments"
);
Assert.equal(
ini.getString("Desktop Entry", "Icon"),
"open-menu-symbolic",
"The icon is exactly the provided text"
);
sandbox.restore();
});
add_task(async function test_exec() {
let sandbox = sinon.createSandbox();
sandbox.stub(ShellService, "shellService").value({
getArgv0: () => "/executable",
});
let path = PathUtils.join(
gTmpDir.path,
"home",
".local",
"share",
"applications",
"content.b.desktop"
);
await ShellService.createLinuxDesktopEntry(
"content.b",
"Exec Test",
["abc", "$d$e$f", "gh\\i", "jk lm", '"nopq"'],
""
);
let ini = await parseINI(path);
Assert.equal(
ini.getString("Desktop Entry", "Exec"),
`"/executable" "abc" "\\$d\\$e\\$f" "gh\\\\i" "jk lm" "\\"nopq\\""`,
"Arguments are escaped as expected"
);
sandbox.restore();
});
add_task(async function test_relativeArgv0() {
let sandbox = sinon.createSandbox();
sandbox.stub(ShellService, "shellService").value({
getArgv0: () => "../relative/./to",
});
let path = PathUtils.join(
gTmpDir.path,
"home",
".local",
"share",
"applications",
"relative.desktop"
);
await ShellService.createLinuxDesktopEntry("relative", "Exec Test", [], "");
let ini = await parseINI(path);
let cwdParent = Services.dirsvc.get("CurWorkD", Ci.nsIFile).parent.path;
Assert.equal(
ini.getString("Desktop Entry", "Exec"),
`"${cwdParent}/relative/./to"`,
"The relative argv[0] value is replaced with an absolute path"
);
sandbox.restore();
});
add_task(async function test_pathLookup_nonPathArgv0() {
let name = "argv0";
await checkPathLookup([name], "anything", name, { argv0: name });
name = AppConstants.MOZ_APP_NAME;
await checkPathLookup([name], "anything", name, { argv0: name });
name = AppConstants.MOZ_APP_NAME + "-" + AppConstants.MOZ_UPDATE_CHANNEL;
await checkPathLookup([name], "anything", name, { argv0: name });
});
add_task(async function test_pathLookup_nonPathArgv0_notInPath() {
await checkPathLookup([], "anything", gBrowserExe, { argv0: "argv0" });
});
add_task(async function test_pathLookup_notInPath() {
await checkPathLookup([], "argv0", kAbsoluteArgv0);
});
add_task(async function test_pathLookup_emptyArgv0() {
await checkPathLookup([], "argv0", gBrowserExe, { argv0: "" });
let name = AppConstants.MOZ_APP_NAME;
await checkPathLookup([name], gBrowserExe, name, { argv0: "" });
name = AppConstants.MOZ_APP_NAME + "-" + AppConstants.MOZ_UPDATE_CHANNEL;
await checkPathLookup([name], gBrowserExe, name, { argv0: "" });
});
add_task(async function test_pathLookup_argv0() {
await checkPathLookup(["argv0"], "argv0", "argv0");
await checkPathLookup(["argv0"], "elsewhere", kAbsoluteArgv0);
});
add_task(async function test_pathLookup_appNameOnly() {
let name = AppConstants.MOZ_APP_NAME;
await checkPathLookup([name], "argv0", name);
await checkPathLookup([name], "elsewhere", kAbsoluteArgv0);
});
add_task(async function test_pathLookup_updateBranch() {
let name = AppConstants.MOZ_APP_NAME + "-" + AppConstants.MOZ_UPDATE_CHANNEL;
await checkPathLookup([name], "argv0", name);
await checkPathLookup([name], "elsewhere", kAbsoluteArgv0);
});
add_task(async function test_pathLookup_priority() {
let options = [
"argv0",
AppConstants.MOZ_APP_NAME + "-" + AppConstants.MOZ_UPDATE_CHANNEL,
AppConstants.MOZ_APP_NAME,
];
for (let i = 0; options.length; i++) {
await checkPathLookup(options, "argv0", options[0]);
options.shift();
}
});
/**
* Checks, indirectly, that ShellService._findStartupCommand works as expected.
*
* The names in linkNames are created as symlinks to a transient file
* targetName and made available in $PATH. A desktop entry is then created, and
* the Exec line's command name is compared to expected.
*
* @param {string[]} linkNames - A list of symlink names that will be present
* in the (fake) $PATH during the check.
* @param {string} targetName - The file that those symlinks point to, within a
* transient 'bin' folder.
* @param {string} expected - The command name that is expected in the desktop
* file. If this is kAbsoluteArgv0, the absolute path of the transient argv0
* will be expected.
* @param {{argv0:string}} [options] - Additional options, currently the value
* to use as argv[0]. If no value is provided, a file 'argv0' in the transient
* bin folder will be used.
*/
async function checkPathLookup(
linkNames,
targetName,
expected,
{ argv0 } = {}
) {
let desktopEntryPath = PathUtils.join(
gTmpDir.path,
"home",
".local",
"share",
"applications",
"checkpathlookup.desktop"
);
Assert.equal(
await IOUtils.exists(desktopEntryPath),
false,
"Desktop entry path shouldn't exist yet"
);
// Don't just put it in gTmpDir to avoid the file being found in other
// tests if this one exits halfway through (e.g. in the debugger).
let container = await IOUtils.createUniqueDirectory(
gTmpDir.path,
"test_linuxDesktopEntry_checkPathLookup"
);
let sandbox = sinon.createSandbox();
let absoluteArgv0 = argv0 ?? PathUtils.join(container, "bin", "argv0");
sandbox.stub(ShellService, "shellService").value({
getArgv0: () => absoluteArgv0,
});
let targetPath;
if (PathUtils.isAbsolute(targetName)) {
targetPath = targetName;
// assume it exists with reasonable permissions
} else {
targetPath = PathUtils.join(container, "bin", targetName);
await IOUtils.writeUTF8(targetPath, "");
await IOUtils.setPermissions(targetPath, 0o755);
}
await IOUtils.makeDirectory(PathUtils.join(container, "links"));
try {
await Promise.all(
linkNames.map(async linkName => {
let linkPath = PathUtils.join(container, "links", linkName);
// There doesn't seem to be a nice way to create symlinks from JavaScript,
// so call out to the command-line.
await Subprocess.call({
command: await Subprocess.pathSearch("ln", kOriginalEnvironment),
arguments: ["-s", targetPath, linkPath],
environment: kOriginalEnvironment,
}).then(process => process.wait());
})
);
gEnvironment = [["PATH", PathUtils.join(container, "links")]];
await ShellService.createLinuxDesktopEntry(
"checkpathlookup",
"Exec Test",
["abc"],
""
);
let ini = await parseINI(desktopEntryPath);
Assert.equal(
ini.getString("Desktop Entry", "Exec"),
`"${expected === kAbsoluteArgv0 ? absoluteArgv0 : expected}" "abc"`,
"The expected command name was used."
);
} finally {
sandbox.restore();
gEnvironment = [["PATH", ""]];
await IOUtils.remove(container, { recursive: true });
await IOUtils.remove(desktopEntryPath, { ignoreAbsent: true });
}
}
add_task(async function test_deletion() {
let path = PathUtils.join(
gTmpDir.path,
"home",
".local",
"share",
"applications",
"deletion.desktop"
);
await ShellService.createLinuxDesktopEntry(
"deletion",
"Deletion Test",
[],
""
);
ok(await IOUtils.exists(path), "The desktop file was created");
await ShellService.deleteLinuxDesktopEntry("deletion");
ok(!(await IOUtils.exists(path)), "The desktop file was deleted");
});