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
466 lines
13 KiB
JavaScript
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");
|
|
});
|