1/*
2 This file is part of the KDE libraries
3 SPDX-FileCopyrightText: 2000 Torben Weis <weis@kde.org>
4 SPDX-FileCopyrightText: 2006-2013 David Faure <faure@kde.org>
5 SPDX-FileCopyrightText: 2009 Michael Pyne <michael.pyne@kdemail.net>
6
7 SPDX-License-Identifier: LGPL-2.0-or-later
8*/
9
10#include "desktopexecparser.h"
11#ifndef Q_OS_ANDROID
12#include "kiofuse_interface.h"
13#endif
14
15#include <KApplicationTrader>
16#include <KConfigGroup>
17#include <KDesktopFile>
18#include <KLocalizedString>
19#include <KMacroExpander>
20#include <KService>
21#include <KSharedConfig>
22#include <KShell>
23#include <kprotocolinfo.h> // KF6 TODO remove after moving hasSchemeHandler to OpenUrlJob
24
25#ifndef Q_OS_ANDROID
26#include <QDBusConnection>
27#include <QDBusReply>
28#endif
29#include <QDir>
30#include <QFile>
31#include <QProcessEnvironment>
32#include <QStandardPaths>
33#include <QUrl>
34
35#include <config-kiocore.h> // KDE_INSTALL_FULL_LIBEXECDIR_KF
36
37#include "kiocoredebug.h"
38
39class KRunMX1 : public KMacroExpanderBase
40{
41public:
42 explicit KRunMX1(const KService &_service)
43 : KMacroExpanderBase(QLatin1Char('%'))
44 , hasUrls(false)
45 , hasSpec(false)
46 , service(_service)
47 {
48 }
49
50 bool hasUrls;
51 bool hasSpec;
52
53protected:
54 int expandEscapedMacro(const QString &str, int pos, QStringList &ret) override;
55
56private:
57 const KService &service;
58};
59
60int KRunMX1::expandEscapedMacro(const QString &str, int pos, QStringList &ret)
61{
62 uint option = str[pos + 1].unicode();
63 switch (option) {
64 case 'c':
65 ret << service.name().replace(c: QLatin1Char('%'), after: QLatin1String("%%"));
66 break;
67 case 'k':
68 ret << service.entryPath().replace(c: QLatin1Char('%'), after: QLatin1String("%%"));
69 break;
70 case 'i':
71 ret << QStringLiteral("--icon") << service.icon().replace(c: QLatin1Char('%'), after: QLatin1String("%%"));
72 break;
73 case 'm':
74 // ret << "-miniicon" << service.icon().replace( '%', "%%" );
75 qCWarning(KIO_CORE) << "-miniicon isn't supported anymore (service" << service.name() << ')';
76 break;
77 case 'u':
78 case 'U':
79 hasUrls = true;
80 Q_FALLTHROUGH();
81 /* fallthrough */
82 case 'f':
83 case 'F':
84 case 'n':
85 case 'N':
86 case 'd':
87 case 'D':
88 case 'v':
89 hasSpec = true;
90 Q_FALLTHROUGH();
91 /* fallthrough */
92 default:
93 return -2; // subst with same and skip
94 }
95 return 2;
96}
97
98class KRunMX2 : public KMacroExpanderBase
99{
100public:
101 explicit KRunMX2(const QList<QUrl> &_urls)
102 : KMacroExpanderBase(QLatin1Char('%'))
103 , ignFile(false)
104 , urls(_urls)
105 {
106 }
107
108 bool ignFile;
109
110protected:
111 int expandEscapedMacro(const QString &str, int pos, QStringList &ret) override;
112
113private:
114 void subst(int option, const QUrl &url, QStringList &ret);
115
116 const QList<QUrl> &urls;
117};
118
119void KRunMX2::subst(int option, const QUrl &url, QStringList &ret)
120{
121 switch (option) {
122 case 'u':
123 ret << ((url.isLocalFile() && url.fragment().isNull() && url.query().isNull()) ? QDir::toNativeSeparators(pathName: url.toLocalFile()) : url.toString());
124 break;
125 case 'd':
126 ret << url.adjusted(options: QUrl::RemoveFilename).path();
127 break;
128 case 'f':
129 ret << QDir::toNativeSeparators(pathName: url.toLocalFile());
130 break;
131 case 'n':
132 ret << url.fileName();
133 break;
134 case 'v':
135 if (url.isLocalFile() && QFile::exists(fileName: url.toLocalFile())) {
136 ret << KDesktopFile(url.toLocalFile()).desktopGroup().readEntry(key: "Dev");
137 }
138 break;
139 }
140 return;
141}
142
143int KRunMX2::expandEscapedMacro(const QString &str, int pos, QStringList &ret)
144{
145 uint option = str[pos + 1].unicode();
146 switch (option) {
147 case 'f':
148 case 'u':
149 case 'n':
150 case 'd':
151 case 'v':
152 if (urls.isEmpty()) {
153 if (!ignFile) {
154 // qCDebug(KIO_CORE) << "No URLs supplied to single-URL service" << str;
155 }
156 } else if (urls.count() > 1) {
157 qCWarning(KIO_CORE) << urls.count() << "URLs supplied to single-URL service" << str;
158 } else {
159 subst(option, url: urls.first(), ret);
160 }
161 break;
162 case 'F':
163 case 'U':
164 case 'N':
165 case 'D':
166 option += 'a' - 'A';
167 for (const QUrl &url : urls) {
168 subst(option, url, ret);
169 }
170 break;
171 case '%':
172 ret = QStringList(QStringLiteral("%"));
173 break;
174 default:
175 return -2; // subst with same and skip
176 }
177 return 2;
178}
179
180QStringList KIO::DesktopExecParser::supportedProtocols(const KService &service)
181{
182 QStringList supportedProtocols = service.supportedProtocols();
183
184 KRunMX1 mx1(service);
185 QString exec = service.exec();
186 if (mx1.expandMacrosShellQuote(str&: exec) && !mx1.hasUrls) {
187 if (!supportedProtocols.isEmpty()) {
188 qCWarning(KIO_CORE) << service.entryPath() << "contains supported protocols but doesn't use %u or %U in its Exec line! This is inconsistent.";
189 }
190 return QStringList();
191 } else {
192 if (supportedProtocols.isEmpty()) {
193 // compat mode: assume KIO if not set and it's a KDE app (or a KDE service)
194 const QStringList categories = service.property<QStringList>(QStringLiteral("Categories"));
195 if (categories.contains(str: QLatin1String("KDE")) || !service.isApplication() || service.entryPath().isEmpty() /*temp service*/) {
196 supportedProtocols.append(QStringLiteral("KIO"));
197 } else { // if no KDE app, be a bit over-generic
198 supportedProtocols.append(QStringLiteral("http"));
199 supportedProtocols.append(QStringLiteral("https")); // #253294
200 supportedProtocols.append(QStringLiteral("ftp"));
201 }
202 }
203 }
204
205 // qCDebug(KIO_CORE) << "supportedProtocols:" << supportedProtocols;
206 return supportedProtocols;
207}
208
209bool KIO::DesktopExecParser::isProtocolInSupportedList(const QUrl &url, const QStringList &supportedProtocols)
210{
211 return url.isLocalFile() //
212 || supportedProtocols.contains(str: QLatin1String("KIO")) //
213 || supportedProtocols.contains(str: url.scheme(), cs: Qt::CaseInsensitive);
214}
215
216// We have up to two sources of data, for protocols not handled by KIO workers (so called "helper") :
217// 1) the exec line of the .protocol file, if there's one
218// 2) the application associated with x-scheme-handler/<protocol> if there's one
219bool KIO::DesktopExecParser::hasSchemeHandler(const QUrl &url) // KF6 TODO move to OpenUrlJob
220{
221 if (KProtocolInfo::isHelperProtocol(url)) {
222 return true;
223 }
224 const KService::Ptr service = KApplicationTrader::preferredService(mimeType: QLatin1String("x-scheme-handler/") + url.scheme());
225 if (service) {
226 qCDebug(KIO_CORE) << QLatin1String("preferred service for x-scheme-handler/") + url.scheme() << service->desktopEntryName();
227 }
228 return service;
229}
230
231class KIO::DesktopExecParserPrivate
232{
233public:
234 DesktopExecParserPrivate(const KService &_service, const QList<QUrl> &_urls)
235 : service(_service)
236 , urls(_urls)
237 , tempFiles(false)
238 {
239 }
240
241 bool isUrlSupported(const QUrl &url, const QStringList &supportedProtocols);
242
243 const KService &service;
244 QList<QUrl> urls;
245 bool tempFiles;
246 QString suggestedFileName;
247 QString m_errorString;
248};
249
250KIO::DesktopExecParser::DesktopExecParser(const KService &service, const QList<QUrl> &urls)
251 : d(new DesktopExecParserPrivate(service, urls))
252{
253}
254
255KIO::DesktopExecParser::~DesktopExecParser()
256{
257}
258
259void KIO::DesktopExecParser::setUrlsAreTempFiles(bool tempFiles)
260{
261 d->tempFiles = tempFiles;
262}
263
264void KIO::DesktopExecParser::setSuggestedFileName(const QString &suggestedFileName)
265{
266 d->suggestedFileName = suggestedFileName;
267}
268
269static const QString kioexecPath()
270{
271 QString kioexec = QCoreApplication::applicationDirPath() + QLatin1String("/kioexec");
272 if (!QFileInfo::exists(file: kioexec)) {
273 kioexec = QStringLiteral(KDE_INSTALL_FULL_LIBEXECDIR_KF "/kioexec");
274 }
275 Q_ASSERT(QFileInfo::exists(kioexec));
276 return kioexec;
277}
278
279static QString findNonExecutableProgram(const QString &executable)
280{
281 // Relative to current dir, or absolute path
282 const QFileInfo fi(executable);
283 if (fi.exists() && !fi.isExecutable()) {
284 return executable;
285 }
286
287#ifdef Q_OS_UNIX
288 // This is a *very* simplified version of QStandardPaths::findExecutable
289 const QStringList searchPaths = QString::fromLocal8Bit(ba: qgetenv(varName: "PATH")).split(sep: QDir::listSeparator(), behavior: Qt::SkipEmptyParts);
290 for (const QString &searchPath : searchPaths) {
291 const QString candidate = searchPath + QLatin1Char('/') + executable;
292 const QFileInfo fileInfo(candidate);
293 if (fileInfo.exists()) {
294 if (fileInfo.isExecutable()) {
295 qWarning() << "Internal program error. QStandardPaths::findExecutable couldn't find" << executable << "but our own logic found it at"
296 << candidate << ". Please report a bug at https://bugs.kde.org";
297 } else {
298 return candidate;
299 }
300 }
301 }
302#endif
303 return QString();
304}
305
306bool KIO::DesktopExecParserPrivate::isUrlSupported(const QUrl &url, const QStringList &protocols)
307{
308 if (KIO::DesktopExecParser::isProtocolInSupportedList(url, supportedProtocols: protocols)) {
309 return true;
310 }
311
312 // supportedProtocols() only checks whether the .desktop file has MimeType=x-scheme-handler/xxx
313 // We also want to check whether the app has been set as default/associated in mimeapps.list
314 const auto handlers = KApplicationTrader::queryByMimeType(mimeType: QLatin1String("x-scheme-handler/") + url.scheme());
315 for (const KService::Ptr &handler : handlers) {
316 if (handler->desktopEntryName() == service.desktopEntryName()) {
317 return true;
318 }
319 }
320
321 return false;
322}
323
324QStringList KIO::DesktopExecParser::resultingArguments() const
325{
326 QString exec = d->service.exec();
327 if (exec.isEmpty()) {
328 d->m_errorString = i18n("No Exec field in %1", d->service.entryPath());
329 qCWarning(KIO_CORE) << "No Exec field in" << d->service.entryPath();
330 return QStringList();
331 }
332
333 // Extract the name of the binary to execute from the full Exec line, to see if it exists
334 const QString binary = executablePath(execLine: exec);
335 QString executableFullPath;
336 if (!binary.isEmpty()) { // skip all this if the Exec line is a complex shell command
337 if (QDir::isRelativePath(path: binary)) {
338 // Resolve the executable to ensure that helpers in libexec are found.
339 // Too bad for commands that need a shell - they must reside in $PATH.
340 executableFullPath = QStandardPaths::findExecutable(executableName: binary);
341 if (executableFullPath.isEmpty()) {
342 executableFullPath = QFile::decodeName(KDE_INSTALL_FULL_LIBEXECDIR_KF "/") + binary;
343 }
344 } else {
345 executableFullPath = binary;
346 }
347
348 // Now check that the binary exists and has the executable flag
349 if (!QFileInfo(executableFullPath).isExecutable()) {
350 // Does it really not exist, or is it non-executable (on Unix)? (bug #415567)
351 const QString nonExecutable = findNonExecutableProgram(executable: binary);
352 if (nonExecutable.isEmpty()) {
353 d->m_errorString = i18n("Could not find the program '%1'", binary);
354 } else {
355 if (QDir::isRelativePath(path: binary)) {
356 d->m_errorString = i18n("The program '%1' was found at '%2' but it is missing executable permissions.", binary, nonExecutable);
357 } else {
358 d->m_errorString = i18n("The program '%1' is missing executable permissions.", nonExecutable);
359 }
360 }
361 return QStringList();
362 }
363 }
364
365 QStringList result;
366 bool appHasTempFileOption;
367
368 KRunMX1 mx1(d->service);
369 KRunMX2 mx2(d->urls);
370
371 if (!mx1.expandMacrosShellQuote(str&: exec)) { // Error in shell syntax
372 d->m_errorString = i18n("Syntax error in command %1 coming from %2", exec, d->service.entryPath());
373 qCWarning(KIO_CORE) << "Syntax error in command" << d->service.exec() << ", service" << d->service.name();
374 return QStringList();
375 }
376
377 // FIXME: the current way of invoking kioexec disables term and su use
378
379 // Check if we need "tempexec" (kioexec in fact)
380 appHasTempFileOption = d->tempFiles && d->service.property<bool>(QStringLiteral("X-KDE-HasTempFileOption"));
381 if (d->tempFiles && !appHasTempFileOption && d->urls.size()) {
382 result << kioexecPath() << QStringLiteral("--tempfiles") << exec;
383 if (!d->suggestedFileName.isEmpty()) {
384 result << QStringLiteral("--suggestedfilename");
385 result << d->suggestedFileName;
386 }
387 result += QUrl::toStringList(uris: d->urls);
388 return result;
389 }
390
391 // Return true for non-KIO desktop files with explicit X-KDE-Protocols list, like vlc, for the special case below
392 auto isNonKIO = [this]() {
393 const QStringList protocols = d->service.property<QStringList>(QStringLiteral("X-KDE-Protocols"));
394 return !protocols.isEmpty() && !protocols.contains(str: QLatin1String("KIO"));
395 };
396
397 // Check if we need kioexec, or KIOFuse
398 bool useKioexec = false;
399#ifndef Q_OS_ANDROID
400 org::kde::KIOFuse::VFS kiofuse_iface(QStringLiteral("org.kde.KIOFuse"), QStringLiteral("/org/kde/KIOFuse"), QDBusConnection::sessionBus());
401 struct MountRequest {
402 QDBusPendingReply<QString> reply;
403 int urlIndex;
404 };
405 QList<MountRequest> requests;
406 requests.reserve(asize: d->urls.count());
407
408 const QStringList appSupportedProtocols = supportedProtocols(service: d->service);
409 for (int i = 0; i < d->urls.count(); ++i) {
410 const QUrl url = d->urls.at(i);
411 const bool supported = mx1.hasUrls ? d->isUrlSupported(url, protocols: appSupportedProtocols) : url.isLocalFile();
412 if (!supported) {
413 // If FUSE fails, and there is no scheme handler, we'll have to fallback to kioexec
414 useKioexec = true;
415 }
416
417 // NOTE: Some non-KIO apps may support the URLs (e.g. VLC supports smb://)
418 // but will not have the password if they are not in the URL itself.
419 // Hence convert URL to KIOFuse equivalent in case there is a password.
420 // @see https://pointieststick.com/2018/01/17/videos-on-samba-shares/
421 // @see https://bugs.kde.org/show_bug.cgi?id=330192
422 if (!supported || (!url.userName().isEmpty() && url.password().isEmpty() && isNonKIO())) {
423 requests.push_back(t: {.reply: kiofuse_iface.mountUrl(remoteUrl: url.toString()), .urlIndex: i});
424 }
425 }
426
427 for (auto &request : requests) {
428 request.reply.waitForFinished();
429 }
430 const bool fuseError = std::any_of(first: requests.cbegin(), last: requests.cend(), pred: [](const MountRequest &request) {
431 return request.reply.isError();
432 });
433
434 if (fuseError && useKioexec) {
435 // We need to run the app through kioexec
436 result << kioexecPath();
437 if (d->tempFiles) {
438 result << QStringLiteral("--tempfiles");
439 }
440 if (!d->suggestedFileName.isEmpty()) {
441 result << QStringLiteral("--suggestedfilename");
442 result << d->suggestedFileName;
443 }
444 result << exec;
445 result += QUrl::toStringList(uris: d->urls);
446 return result;
447 }
448
449 // At this point we know we're not using kioexec, so feel free to replace
450 // KIO URLs with their KIOFuse local path.
451 for (const auto &request : std::as_const(t&: requests)) {
452 if (!request.reply.isError()) {
453 d->urls[request.urlIndex] = QUrl::fromLocalFile(localfile: request.reply.value());
454 }
455 }
456#endif
457
458 if (appHasTempFileOption) {
459 exec += QLatin1String(" --tempfile");
460 }
461
462 // Did the user forget to append something like '%f'?
463 // If so, then assume that '%f' is the right choice => the application
464 // accepts only local files.
465 if (!mx1.hasSpec) {
466 exec += QLatin1String(" %f");
467 mx2.ignFile = true;
468 }
469
470 mx2.expandMacrosShellQuote(str&: exec); // syntax was already checked, so don't check return value
471
472 /*
473 1 = need_shell, 2 = terminal, 4 = su
474
475 0 << split(cmd)
476 1 << "sh" << "-c" << cmd
477 2 << split(term) << "-e" << split(cmd)
478 3 << split(term) << "-e" << "sh" << "-c" << cmd
479
480 4 << "kdesu" << "-u" << user << "-c" << cmd
481 5 << "kdesu" << "-u" << user << "-c" << ("sh -c " + quote(cmd))
482 6 << split(term) << "-e" << "su" << user << "-c" << cmd
483 7 << split(term) << "-e" << "su" << user << "-c" << ("sh -c " + quote(cmd))
484
485 "sh -c" is needed in the "su" case, too, as su uses the user's login shell, not sh.
486 this could be optimized with the -s switch of some su versions (e.g., debian linux).
487 */
488
489 if (d->service.terminal()) {
490 KConfigGroup cg(KSharedConfig::openConfig(), QStringLiteral("General"));
491 QString terminal = cg.readPathEntry(key: "TerminalApplication", QStringLiteral("konsole"));
492
493 const bool isKonsole = (terminal == QLatin1String("konsole"));
494 QStringList terminalParts = KShell::splitArgs(cmd: terminal);
495 QString terminalPath;
496 if (!terminalParts.isEmpty()) {
497 terminalPath = QStandardPaths::findExecutable(executableName: terminalParts.at(i: 0));
498 }
499
500 if (terminalPath.isEmpty()) {
501 d->m_errorString = i18n("Terminal %1 not found while trying to run %2", terminal, d->service.entryPath());
502 qCWarning(KIO_CORE) << "Terminal" << terminal << "not found, service" << d->service.name();
503 return QStringList();
504 }
505 terminalParts[0] = terminalPath;
506 terminal = KShell::joinArgs(args: terminalParts);
507 if (isKonsole) {
508 if (!d->service.workingDirectory().isEmpty()) {
509 terminal += QLatin1String(" --workdir ") + KShell::quoteArg(arg: d->service.workingDirectory());
510 }
511 terminal += QLatin1String(" -qwindowtitle '%c'");
512 if (!d->service.icon().isEmpty()) {
513 terminal += QLatin1String(" -qwindowicon ") + KShell::quoteArg(arg: d->service.icon().replace(c: QLatin1Char('%'), after: QLatin1String("%%")));
514 }
515 }
516 terminal += QLatin1Char(' ') + d->service.terminalOptions();
517 if (!mx1.expandMacrosShellQuote(str&: terminal)) {
518 d->m_errorString = i18n("Syntax error in command %1 while trying to run %2", terminal, d->service.entryPath());
519 qCWarning(KIO_CORE) << "Syntax error in command" << terminal << ", service" << d->service.name();
520 return QStringList();
521 }
522 mx2.expandMacrosShellQuote(str&: terminal);
523 result = KShell::splitArgs(cmd: terminal); // assuming that the term spec never needs a shell!
524 result << QStringLiteral("-e");
525 }
526
527 KShell::Errors err;
528 QStringList execlist = KShell::splitArgs(cmd: exec, flags: KShell::AbortOnMeta | KShell::TildeExpand, err: &err);
529 if (!executableFullPath.isEmpty()) {
530 execlist[0] = executableFullPath;
531 }
532
533 if (d->service.substituteUid()) {
534 if (d->service.terminal()) {
535 result << QStringLiteral("su");
536 } else {
537 QString kdesu = QFile::decodeName(KDE_INSTALL_FULL_LIBEXECDIR_KF "/kdesu");
538 if (!QFile::exists(fileName: kdesu)) {
539 kdesu = QStandardPaths::findExecutable(QStringLiteral("kdesu"));
540 }
541 if (!QFile::exists(fileName: kdesu)) {
542 // Insert kdesu as string so we show a nice warning: 'Could not launch kdesu'
543 result << QStringLiteral("kdesu");
544 return result;
545 } else {
546 result << kdesu << QStringLiteral("-u");
547 }
548 }
549
550 result << d->service.username() << QStringLiteral("-c");
551 if (err == KShell::FoundMeta) {
552 exec = QLatin1String("/bin/sh -c ") + KShell::quoteArg(arg: exec);
553 } else {
554 exec = KShell::joinArgs(args: execlist);
555 }
556 result << exec;
557 } else {
558 if (err == KShell::FoundMeta) {
559 result << QStringLiteral("/bin/sh") << QStringLiteral("-c") << exec;
560 } else {
561 result += execlist;
562 }
563 }
564
565 return result;
566}
567
568QString KIO::DesktopExecParser::errorMessage() const
569{
570 return d->m_errorString;
571}
572
573// static
574QString KIO::DesktopExecParser::executableName(const QString &execLine)
575{
576 const QString bin = executablePath(execLine);
577 return bin.mid(position: bin.lastIndexOf(c: QLatin1Char('/')) + 1);
578}
579
580// static
581QString KIO::DesktopExecParser::executablePath(const QString &execLine)
582{
583 // Remove parameters and/or trailing spaces.
584 const QStringList args = KShell::splitArgs(cmd: execLine, flags: KShell::AbortOnMeta | KShell::TildeExpand);
585 auto it = std::find_if(first: args.cbegin(), last: args.cend(), pred: [](const QString &arg) {
586 return !arg.contains(c: QLatin1Char('='));
587 });
588 return it != args.cend() ? *it : QString{};
589}
590

source code of kio/src/core/desktopexecparser.cpp