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