Revision control
++ // Copyright (C) 2016 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
++ // Qt-Security score:critical reason:data-parser
++
++ #include "qmediametadata.h"
++ #include "qmediaplayer.h"
++ #include "qplaylistfileparser.h"
++
++ #include <QDebug>
++ #include <QFileInfo>
++ #include <QIODevice>
++ #include <QNetworkReply>
++ #include <QNetworkRequest>
++ #include <QPointer>
++
++ #include <memory>
++
++ QT_BEGIN_NAMESPACE
++
++ namespace {
++
++ class ParserBase
++ {
++ public:
++ explicit ParserBase(QPlaylistFileParser *parent) : m_parent(parent), m_aborted(false)
++ {
++ Q_ASSERT(m_parent);
++ }
++
++ bool parseLine(int lineIndex, const QString &line, const QUrl &root)
++ {
++ if (m_aborted)
++ return false;
++
++ const bool ok = parseLineImpl(lineIndex, line, root);
++ return ok && !m_aborted;
++ }
++
++ virtual void abort() { m_aborted = true; }
++ virtual ~ParserBase() = default;
++
++ protected:
++ virtual bool parseLineImpl(int lineIndex, const QString &line, const QUrl &root) = 0;
++
++ static QUrl expandToFullPath(const QUrl &root, const QString &line)
++ {
++ // On Linux, backslashes are not converted to forward slashes :/
++ if (line.startsWith(QLatin1String("//")) || line.startsWith(QLatin1String("\\\\"))) {
++ // Network share paths are not resolved
++ return QUrl::fromLocalFile(line);
++ }
++
++ QUrl url(line);
++ if (url.scheme().isEmpty()) {
++ // Resolve it relative to root
++ if (root.isLocalFile())
++ return QUrl::fromUserInput(line, root.adjusted(QUrl::RemoveFilename).toLocalFile(),
++ QUrl::AssumeLocalFile);
++ return root.resolved(url);
++ }
++ if (url.scheme().length() == 1)
++ // Assume it's a drive letter for a Windows path
++ url = QUrl::fromLocalFile(line);
++
++ return url;
++ }
++
++ void newItemFound(const QVariant &content) { Q_EMIT m_parent->newItem(content); }
++
++ QPlaylistFileParser *m_parent;
++ bool m_aborted;
++ };
++
++ class M3UParser : public ParserBase
++ {
++ public:
++ explicit M3UParser(QPlaylistFileParser *q) : ParserBase(q), m_extendedFormat(false) { }
++
++ /*
++ *
++ Extended M3U directives
++
++ #EXTM3U - header - must be first line of file
++ #EXTINF - extra info - length (seconds), title
++ #EXTINF - extra info - length (seconds), artist '-' title
++
++ Example
++
++ #EXTM3U
++ #EXTINF:123, Sample artist - Sample title
++ C:\Documents and Settings\I\My Music\Sample.mp3
++ #EXTINF:321,Example Artist - Example title
++ C:\Documents and Settings\I\My Music\Greatest Hits\Example.ogg
++
++ */
++ bool parseLineImpl(int lineIndex, const QString &line, const QUrl &root) override
++ {
++ if (line[0] == u'#') {
++ if (m_extendedFormat) {
++ if (line.startsWith(QLatin1String("#EXTINF:"))) {
++ m_extraInfo.clear();
++ int artistStart = line.indexOf(QLatin1String(","), 8);
++ bool ok = false;
++ QStringView lineView{ line };
++ int length = lineView.mid(8, artistStart < 8 ? -1 : artistStart - 8)
++ .trimmed()
++ .toInt(&ok);
++ if (ok && length > 0) {
++ // convert from second to milisecond
++ m_extraInfo[QMediaMetaData::Duration] = QVariant(length * 1000);
++ }
++ if (artistStart > 0) {
++ int titleStart = getSplitIndex(line, artistStart);
++ if (titleStart > artistStart) {
++ m_extraInfo[QMediaMetaData::Author] =
++ lineView.mid(artistStart + 1, titleStart - artistStart - 1)
++ .trimmed()
++ .toString()
++ .replace(QLatin1String("--"), QLatin1String("-"));
++ m_extraInfo[QMediaMetaData::Title] =
++ lineView.mid(titleStart + 1)
++ .trimmed()
++ .toString()
++ .replace(QLatin1String("--"), QLatin1String("-"));
++ } else {
++ m_extraInfo[QMediaMetaData::Title] =
++ lineView.mid(artistStart + 1)
++ .trimmed()
++ .toString()
++ .replace(QLatin1String("--"), QLatin1String("-"));
++ }
++ }
++ }
++ } else if (lineIndex == 0 && line.startsWith(QLatin1String("#EXTM3U"))) {
++ m_extendedFormat = true;
++ }
++ } else {
++ QUrl url = expandToFullPath(root, line);
++ m_extraInfo[QMediaMetaData::Url] = url;
++ m_parent->playlist.append(url);
++ newItemFound(QVariant::fromValue(m_extraInfo));
++ m_extraInfo.clear();
++ }
++
++ return true;
++ }
++
++ int getSplitIndex(const QString &line, int startPos)
++ {
++ if (startPos < 0)
++ startPos = 0;
++ const QChar *buf = line.data();
++ for (int i = startPos; i < line.length(); ++i) {
++ if (buf[i] == u'-') {
++ if (i == line.length() - 1)
++ return i;
++ ++i;
++ if (buf[i] != u'-')
++ return i - 1;
++ }
++ }
++ return -1;
++ }
++
++ private:
++ QMediaMetaData m_extraInfo;
++ bool m_extendedFormat;
++ };
++
++ class PLSParser : public ParserBase
++ {
++ public:
++ explicit PLSParser(QPlaylistFileParser *q) : ParserBase(q) { }
++
++ /*
++ *
++ The format is essentially that of an INI file structured as follows:
++
++ Header
++
++ * [playlist] : This tag indicates that it is a Playlist File
++
++ Track Entry
++ Assuming track entry #X
++
++ * FileX : Variable defining location of stream.
++ * TitleX : Defines track title.
++ * LengthX : Length in seconds of track. Value of -1 indicates indefinite.
++
++ Footer
++
++ * NumberOfEntries : This variable indicates the number of tracks.
++ * Version : Playlist version. Currently only a value of 2 is valid.
++
++ [playlist]
++
++ File1=Alternative\everclear - SMFTA.mp3
++
++ Title1=Everclear - So Much For The Afterglow
++
++ Length1=233
++
++ File2=http://www.site.com:8000/listen.pls
++
++ Title2=My Cool Stream
++
++ Length5=-1
++
++ NumberOfEntries=2
++
++ Version=2
++ */
++ bool parseLineImpl(int, const QString &line, const QUrl &root) override
++ {
++ // We ignore everything but 'File' entries, since that's the only thing we care about.
++ if (!line.startsWith(QLatin1String("File")))
++ return true;
++
++ QString value = getValue(line);
++ if (value.isEmpty())
++ return true;
++
++ QUrl path = expandToFullPath(root, value);
++ m_parent->playlist.append(path);
++ newItemFound(path);
++
++ return true;
++ }
++
++ QString getValue(QStringView line)
++ {
++ int start = line.indexOf(u'=');
++ if (start < 0)
++ return QString();
++ return line.mid(start + 1).trimmed().toString();
++ }
++ };
++ }
++
++ /////////////////////////////////////////////////////////////////////////////////////////////////
++
++ class QPlaylistFileParserPrivate
++ {
++ Q_DECLARE_PUBLIC(QPlaylistFileParser)
++ public:
++ QPlaylistFileParserPrivate(QPlaylistFileParser *q)
++ : q_ptr(q),
++ m_stream(nullptr),
++ m_type(QPlaylistFileParser::UNKNOWN),
++ m_scanIndex(0),
++ m_lineIndex(-1),
++ m_utf8(false),
++ m_aborted(false)
++ {
++ }
++
++ void handleData();
++ void handleParserFinished();
++ void abort();
++ void reset();
++
++ std::unique_ptr<QNetworkReply, QScopedPointerDeleteLater> m_source;
++ std::unique_ptr<ParserBase> m_currentParser;
++ QByteArray m_buffer;
++ QUrl m_root;
++ QNetworkAccessManager m_mgr;
++ QString m_mimeType;
++ QPlaylistFileParser *q_ptr;
++ QPointer<QIODevice> m_stream;
++ QPlaylistFileParser::FileType m_type;
++ struct ParserJob
++ {
++ QIODevice *m_stream = nullptr;
++ QUrl m_media;
++ QString m_mimeType;
++ [[nodiscard]] bool isValid() const { return m_stream || !m_media.isEmpty(); }
++ void reset()
++ {
++ m_stream = nullptr;
++ m_media = QUrl();
++ m_mimeType = QString();
++ }
++ } m_pendingJob;
++ int m_scanIndex;
++ int m_lineIndex;
++ bool m_utf8;
++ bool m_aborted;
++
++ private:
++ bool processLine(int startIndex, int length);
++ };
++
++ #define LINE_LIMIT 4096
++ #define READ_LIMIT 64
++
++ bool QPlaylistFileParserPrivate::processLine(int startIndex, int length)
++ {
++ Q_Q(QPlaylistFileParser);
++ m_lineIndex++;
++
++ if (!m_currentParser) {
++ const QString urlString = m_root.toString();
++ const QString &suffix = !urlString.isEmpty() ? QFileInfo(urlString).suffix() : urlString;
++ QString mimeType;
++ if (m_source)
++ mimeType = m_source->header(QNetworkRequest::ContentTypeHeader).toString();
++ m_type = QPlaylistFileParser::findPlaylistType(
++ suffix, !mimeType.isEmpty() ? mimeType : m_mimeType, m_buffer.constData(),
++ quint32(m_buffer.size()));
++
++ switch (m_type) {
++ case QPlaylistFileParser::UNKNOWN:
++ emit q->error(QMediaPlaylist::FormatError,
++ QMediaPlaylist::tr("%1 playlist type is unknown").arg(m_root.toString()));
++ q->abort();
++ return false;
++ case QPlaylistFileParser::M3U:
++ m_currentParser.reset(new M3UParser(q));
++ break;
++ case QPlaylistFileParser::M3U8:
++ m_currentParser.reset(new M3UParser(q));
++ m_utf8 = true;
++ break;
++ case QPlaylistFileParser::PLS:
++ m_currentParser.reset(new PLSParser(q));
++ break;
++ }
++
++ Q_ASSERT(m_currentParser);
++ }
++
++ QString line;
++
++ if (m_utf8) {
++ line = QString::fromUtf8(m_buffer.constData() + startIndex, length).trimmed();
++ } else {
++ line = QString::fromLatin1(m_buffer.constData() + startIndex, length).trimmed();
++ }
++ if (line.isEmpty())
++ return true;
++
++ Q_ASSERT(m_currentParser);
++ return m_currentParser->parseLine(m_lineIndex, line, m_root);
++ }
++
++ void QPlaylistFileParserPrivate::handleData()
++ {
++ Q_Q(QPlaylistFileParser);
++
++ if (!m_stream)
++ return;
++
++ while (m_stream->bytesAvailable() && !m_aborted) {
++ int expectedBytes =
++ qMin(READ_LIMIT,
++ int(qMin(m_stream->bytesAvailable(), qint64(LINE_LIMIT - m_buffer.size()))));
++ m_buffer.push_back(m_stream->read(expectedBytes));
++ int processedBytes = 0;
++ while (m_scanIndex < m_buffer.length() && !m_aborted) {
++ char s = m_buffer[m_scanIndex];
++ if (s == '\r' || s == '\n') {
++ int l = m_scanIndex - processedBytes;
++ if (l > 0) {
++ if (!processLine(processedBytes, l))
++ break;
++ }
++ processedBytes = m_scanIndex + 1;
++ }
++ m_scanIndex++;
++ }
++
++ if (m_aborted)
++ break;
++
++ if (m_buffer.length() - processedBytes >= LINE_LIMIT) {
++ emit q->error(QMediaPlaylist::FormatError,
++ QMediaPlaylist::tr("invalid line in playlist file"));
++ q->abort();
++ break;
++ }
++
++ if (!m_stream->bytesAvailable() && (!m_source || !m_source->isFinished())) {
++ // last line
++ processLine(processedBytes, -1);
++ break;
++ }
++
++ Q_ASSERT(m_buffer.length() == m_scanIndex);
++ if (processedBytes == 0)
++ continue;
++
++ int copyLength = m_buffer.length() - processedBytes;
++ if (copyLength > 0) {
++ Q_ASSERT(copyLength <= READ_LIMIT);
++ m_buffer = m_buffer.right(copyLength);
++ } else {
++ m_buffer.clear();
++ }
++ m_scanIndex = 0;
++ }
++
++ handleParserFinished();
++ }
++
++ QPlaylistFileParser::QPlaylistFileParser(QObject *parent)
++ : QObject(parent), d_ptr(new QPlaylistFileParserPrivate(this))
++ {
++ }
++
++ QPlaylistFileParser::~QPlaylistFileParser() = default;
++
++ QPlaylistFileParser::FileType QPlaylistFileParser::findByMimeType(const QString &mime)
++ {
++ if (mime == QLatin1String("text/uri-list") || mime == QLatin1String("audio/x-mpegurl")
++ || mime == QLatin1String("audio/mpegurl"))
++ return QPlaylistFileParser::M3U;
++
++ if (mime == QLatin1String("application/x-mpegURL")
++ || mime == QLatin1String("application/vnd.apple.mpegurl"))
++ return QPlaylistFileParser::M3U8;
++
++ if (mime == QLatin1String("audio/x-scpls"))
++ return QPlaylistFileParser::PLS;
++
++ return QPlaylistFileParser::UNKNOWN;
++ }
++
++ QPlaylistFileParser::FileType QPlaylistFileParser::findBySuffixType(const QString &suffix)
++ {
++ const QString &s = suffix.toLower();
++
++ if (s == QLatin1String("m3u"))
++ return QPlaylistFileParser::M3U;
++
++ if (s == QLatin1String("m3u8"))
++ return QPlaylistFileParser::M3U8;
++
++ if (s == QLatin1String("pls"))
++ return QPlaylistFileParser::PLS;
++
++ return QPlaylistFileParser::UNKNOWN;
++ }
++
++ QPlaylistFileParser::FileType QPlaylistFileParser::findByDataHeader(const char *data, quint32 size)
++ {
++ if (!data || size == 0)
++ return QPlaylistFileParser::UNKNOWN;
++
++ if (size >= 7 && strncmp(data, "#EXTM3U", 7) == 0)
++ return QPlaylistFileParser::M3U;
++
++ if (size >= 10 && strncmp(data, "[playlist]", 10) == 0)
++ return QPlaylistFileParser::PLS;
++
++ return QPlaylistFileParser::UNKNOWN;
++ }
++
++ QPlaylistFileParser::FileType QPlaylistFileParser::findPlaylistType(const QString &suffix,
++ const QString &mime,
++ const char *data, quint32 size)
++ {
++
++ FileType dataHeaderType = findByDataHeader(data, size);
++ if (dataHeaderType != UNKNOWN)
++ return dataHeaderType;
++
++ FileType mimeType = findByMimeType(mime);
++ if (mimeType != UNKNOWN)
++ return mimeType;
++
++ mimeType = findBySuffixType(mime);
++ if (mimeType != UNKNOWN)
++ return mimeType;
++
++ FileType suffixType = findBySuffixType(suffix);
++ if (suffixType != UNKNOWN)
++ return suffixType;
++
++ return UNKNOWN;
++ }
++
++ /*
++ * Delegating
++ */
++ void QPlaylistFileParser::start(const QUrl &media, QIODevice *stream, const QString &mimeType)
++ {
++ if (stream)
++ start(stream, mimeType);
++ else
++ start(media, mimeType);
++ }
++
++ void QPlaylistFileParser::start(QIODevice *stream, const QString &mimeType)
++ {
++ Q_D(QPlaylistFileParser);
++ const bool validStream = stream ? (stream->isOpen() && stream->isReadable()) : false;
++
++ if (!validStream) {
++ Q_EMIT error(QMediaPlaylist::AccessDeniedError, QMediaPlaylist::tr("Invalid stream"));
++ return;
++ }
++
++ if (d->m_currentParser) {
++ abort();
++ d->m_pendingJob = { stream, QUrl(), mimeType };
++ return;
++ }
++
++ playlist.clear();
++ d->reset();
++ d->m_mimeType = mimeType;
++ d->m_stream = stream;
++ connect(d->m_stream, SIGNAL(readyRead()), this, SLOT(handleData()));
++ d->handleData();
++ }
++
++ void QPlaylistFileParser::start(const QUrl &url, const QString &mimeType)
++ {
++ Q_D(QPlaylistFileParser);
++
++ if (url.isLocalFile() && !QFile::exists(url.toLocalFile())) {
++ emit error(QMediaPlaylist::AccessDeniedError,
++ QString(QMediaPlaylist::tr("%1 does not exist")).arg(url.toString()));
++ return;
++ }
++
++ if (d->m_currentParser) {
++ abort();
++ d->m_pendingJob = { nullptr, url, mimeType };
++ return;
++ }
++
++ d->reset();
++ d->m_root = url;
++ d->m_mimeType = mimeType;
++ d->m_source.reset(d->m_mgr.get(QNetworkRequest(url)));
++ d->m_stream = d->m_source.get();
++ connect(d->m_source.get(), SIGNAL(readyRead()), this, SLOT(handleData()));
++ connect(d->m_source.get(), SIGNAL(finished()), this, SLOT(handleData()));
++ connect(d->m_source.get(), SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this,
++ SLOT(handleError()));
++
++ if (url.isLocalFile())
++ d->handleData();
++ }
++
++ void QPlaylistFileParser::abort()
++ {
++ Q_D(QPlaylistFileParser);
++ d->abort();
++
++ if (d->m_source)
++ d->m_source->disconnect();
++
++ if (d->m_stream)
++ disconnect(d->m_stream, SIGNAL(readyRead()), this, SLOT(handleData()));
++
++ playlist.clear();
++ }
++
++ void QPlaylistFileParser::handleData()
++ {
++ Q_D(QPlaylistFileParser);
++ d->handleData();
++ }
++
++ void QPlaylistFileParserPrivate::handleParserFinished()
++ {
++ Q_Q(QPlaylistFileParser);
++ const bool isParserValid = bool(m_currentParser);
++ if (!isParserValid && !m_aborted)
++ emit q->error(QMediaPlaylist::FormatNotSupportedError,
++ QMediaPlaylist::tr("Empty file provided"));
++
++ if (isParserValid && !m_aborted) {
++ m_currentParser.reset();
++ emit q->finished();
++ }
++
++ if (!m_aborted)
++ q->abort();
++
++ if (m_source)
++ m_source.reset();
++
++ if (m_pendingJob.isValid())
++ q->start(m_pendingJob.m_media, m_pendingJob.m_stream, m_pendingJob.m_mimeType);
++ }
++
++ void QPlaylistFileParserPrivate::abort()
++ {
++ m_aborted = true;
++ if (m_currentParser)
++ m_currentParser->abort();
++ }
++
++ void QPlaylistFileParserPrivate::reset()
++ {
++ Q_ASSERT(m_currentParser == nullptr);
++ Q_ASSERT(m_source == nullptr);
++ m_buffer.clear();
++ m_root.clear();
++ m_mimeType.clear();
++ m_stream = nullptr;
++ m_type = QPlaylistFileParser::UNKNOWN;
++ m_scanIndex = 0;
++ m_lineIndex = -1;
++ m_utf8 = false;
++ m_aborted = false;
++ m_pendingJob.reset();
++ }
++
++ void QPlaylistFileParser::handleError()
++ {
++ Q_D(QPlaylistFileParser);
++ const QString &errorString = d->m_source->errorString();
++ Q_EMIT error(QMediaPlaylist::NetworkError, errorString);
++ abort();
++ }
++
++ QT_END_NAMESPACE
++
++ #include "moc_qplaylistfileparser.cpp"