// Copyright (C) 2016 The Qt Company Ltd. // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 #include "scanner.h" #include "logging.h" #include #include #include #include #include #include #include #include using namespace Qt::Literals::StringLiterals; namespace Scanner { static void missingPropertyWarning(const QString &filePath, const QString &property) { std::cerr << qPrintable(tr("File %1: Missing mandatory property '%2'.").arg( QDir::toNativeSeparators(filePath), property)) << std::endl; } static bool validatePackage(Package &p, const QString &filePath, Checks checks, LogLevel logLevel) { bool validPackage = true; if (p.qtParts.isEmpty()) p.qtParts << u"libs"_s; if (p.name.isEmpty()) { if (p.id.startsWith("chromium-"_L1)) // Ignore invalid README.chromium files return false; if (logLevel != SilentLog) missingPropertyWarning(filePath, u"Name"_s); validPackage = false; } if (p.id.isEmpty()) { if (logLevel != SilentLog) missingPropertyWarning(filePath, u"Id"_s); validPackage = false; } else if (!p.id.isLower() || p.id.contains(' '_L1)) { if (logLevel != SilentLog) std::cerr << qPrintable(tr("File %1: Value of 'Id' must be in lowercase and without spaces.") .arg(QDir::toNativeSeparators(filePath))) << std::endl; validPackage = false; } if (p.license.isEmpty()) { if (logLevel != SilentLog) missingPropertyWarning(filePath, u"License"_s); validPackage = false; } if (!p.copyright.isEmpty() && !p.copyrightFile.isEmpty()) { if (logLevel != SilentLog) { std::cerr << qPrintable(tr("File %1: Properties 'Copyright' and 'CopyrightFile' are " "mutually exclusive.") .arg(QDir::toNativeSeparators(filePath))) << std::endl; } validPackage = false; } if (p.securityCritical && p.downloadLocation.isEmpty()) { if (logLevel != SilentLog) missingPropertyWarning(filePath, u"DownloadLocation"_s); validPackage = false; } for (const QString &part : std::as_const(p.qtParts)) { if (part != "examples"_L1 && part != "tests"_L1 && part != "tools"_L1 && part != "libs"_L1) { if (logLevel != SilentLog) { std::cerr << qPrintable(tr("File %1: Property 'QtPart' contains unknown element " "'%2'. Valid entries are 'examples', 'tests', 'tools' " "and 'libs'.").arg( QDir::toNativeSeparators(filePath), part)) << std::endl; } validPackage = false; } } if (!(checks & Check::Paths)) return validPackage; const QDir dir = p.path; if (!dir.exists()) { std::cerr << qPrintable( tr("File %1: Directory '%2' does not exist.") .arg(QDir::toNativeSeparators(filePath), QDir::toNativeSeparators(p.path))) << std::endl; validPackage = false; } else { for (const QString &file : std::as_const(p.files)) { if (!dir.exists(file)) { if (logLevel != SilentLog) { std::cerr << qPrintable( tr("File %1: Path '%2' does not exist in directory '%3'.") .arg(QDir::toNativeSeparators(filePath), QDir::toNativeSeparators(file), QDir::toNativeSeparators(p.path))) << std::endl; } validPackage = false; } } } return validPackage; } static std::optional toStringList(const QJsonValue &value) { if (!value.isArray()) return std::nullopt; QStringList result; for (const auto &iter : value.toArray()) { if (iter.type() != QJsonValue::String) return std::nullopt; result.push_back(iter.toString()); } return result; } static std::optional arrayToMultiLineString(const QJsonValue &value) { if (!value.isArray()) return std::nullopt; QString result; for (const auto &iter : value.toArray()) { if (iter.type() != QJsonValue::String) return std::nullopt; result.append(iter.toString()); result.append(QLatin1StringView("\n")); } return result; } // Extracts SPDX license ids from a SPDX license expression. // For "(BSD-3-Clause AND BeerWare)" this function returns { "BSD-3-Clause", "BeerWare" }. static QStringList extractLicenseIdsFromSPDXExpression(QString expression) { const QStringList spdxOperators = { u"AND"_s, u"OR"_s, u"WITH"_s }; // Replace parentheses with spaces. We're not interested in grouping. const QRegularExpression parensRegex(u"[()]"_s); expression.replace(parensRegex, u" "_s); // Split the string at space boundaries to extract tokens. QStringList result; for (const QString &token : expression.split(QLatin1Char(' '), Qt::SkipEmptyParts)) { if (spdxOperators.contains(token)) continue; // Remove the unary + operator, if present. if (token.endsWith(QLatin1Char('+'))) result.append(token.mid(0, token.size() - 1)); else result.append(token); } return result; } // Starting at packageDir, look for a LICENSES subdirectory in the directory hierarchy upwards. // Return a default-constructed QString if the directory was not found. static QString locateLicensesDir(const QString &packageDir) { static const QString licensesSubDir = u"LICENSES"_s; QDir dir(packageDir); while (true) { if (!dir.exists()) break; if (dir.cd(licensesSubDir)) return dir.path(); if (dir.isRoot() || !dir.cdUp()) break; } return {}; } // Locates the license files that belong to the licenses mentioned in LicenseId and stores them in // the specified package object. static bool autoDetectLicenseFiles(Package &p) { const QString licensesDirPath = locateLicensesDir(p.path); const QStringList licenseIds = extractLicenseIdsFromSPDXExpression(p.licenseId); bool success = true; QDir licensesDir(licensesDirPath); QDir licensesDirLocal = p.path; for (const QString &id : licenseIds) { QString fileName = id + u".txt"; QString fileNameLocal = u"LICENSE." + id + u".txt"; if (licensesDirLocal.exists(fileNameLocal)) { p.licenseFiles.append(licensesDirLocal.filePath(fileNameLocal)); } else if (licensesDir.exists(fileName)) { p.licenseFiles.append(licensesDir.filePath(fileName)); } else { std::cerr << qPrintable(tr("Missing expected license file:")) << std::endl; std::cerr << qPrintable(QDir::toNativeSeparators(licensesDirLocal.filePath(fileNameLocal))) << std::endl; if (!licensesDirPath.isEmpty()) { std::cerr << qPrintable(tr("or\n %1").arg( QDir::toNativeSeparators(licensesDir.filePath(fileName)))) << std::endl; } success = false; } } return success; } // Tries to interpret a json value either as a string or an array of strings, and assigns the // result to outList. Returns true on success, false on failure. On failure, it also conditionally // prints an error. static bool handleStringOrStringArrayJsonKey(QStringList &outList, const QString &key, QJsonValueConstRef jsonValue, const QString &filePath, LogLevel logLevel) { if (jsonValue.isArray()) { auto maybeStringList = toStringList(jsonValue); if (maybeStringList) outList = maybeStringList.value(); } else if (jsonValue.isString()) { outList.append(jsonValue.toString()); } else { if (logLevel != SilentLog) { std::cerr << qPrintable(tr("File %1: Expected JSON array of strings or " "string as value of %2.").arg( QDir::toNativeSeparators(filePath), key)) << std::endl; } return false; } return true; } // Transforms a JSON object into a Package object static std::optional readPackage(const QJsonObject &object, const QString &filePath, Checks checks, LogLevel logLevel) { Package p; bool validPackage = true; const QString directory = QFileInfo(filePath).absolutePath(); p.path = directory; for (auto iter = object.constBegin(); iter != object.constEnd(); ++iter) { const QString key = iter.key(); if (!iter.value().isString() && key != "QtParts"_L1 && key != "SecurityCritical"_L1 && key != "Files"_L1 && key != "LicenseFiles"_L1 && key != "Comment"_L1 && key != "Copyright"_L1 && key != "CPE"_L1 && key != "PURL"_L1) { if (logLevel != SilentLog) std::cerr << qPrintable(tr("File %1: Expected JSON string as value of %2.").arg( QDir::toNativeSeparators(filePath), key)) << std::endl; validPackage = false; continue; } const QString value = iter.value().toString(); if (key == "Name"_L1) { p.name = value; } else if (key == "Path"_L1) { p.path = QDir(directory).absoluteFilePath(value); } else if (key == "Files"_L1) { QJsonValueConstRef jsonValue = iter.value(); if (jsonValue.isArray()) { auto maybeStringList = toStringList(jsonValue); if (maybeStringList) p.files = maybeStringList.value(); } else if (jsonValue.isString()) { // Legacy format: multiple values separated by space in one string. p.files = value.simplified().split(QLatin1Char(' '), Qt::SkipEmptyParts); } else { if (logLevel != SilentLog) { std::cerr << qPrintable(tr("File %1: Expected JSON array of strings as value " "of Files.").arg(QDir::toNativeSeparators(filePath))) << std::endl; validPackage = false; continue; } } } else if (key == "Comment"_L1) { // Accepted purely to record details of potential interest doing // updates in future. Value is an arbitrary object. Any number of // Comment entries may be present: JSON doesn't require names to be // unique, albeit some linters may kvetch. } else if (key == "Id"_L1) { p.id = value; } else if (key == "Homepage"_L1) { p.homepage = value; } else if (key == "Version"_L1) { p.version = value; } else if (key == "DownloadLocation"_L1) { p.downloadLocation = value; } else if (key == "License"_L1) { p.license = value; } else if (key == "LicenseId"_L1) { p.licenseId = value; } else if (key == "LicenseFile"_L1) { p.licenseFiles = QStringList(QDir(directory).absoluteFilePath(value)); } else if (key == "LicenseFiles"_L1) { auto strings = toStringList(iter.value()); if (!strings) { if (logLevel != SilentLog) std::cerr << qPrintable(tr("File %1: Expected JSON array of strings in %2.") .arg(QDir::toNativeSeparators(filePath), key)) << std::endl; validPackage = false; continue; } const QDir dir(directory); for (const auto &iter : std::as_const(strings.value())) p.licenseFiles.push_back(dir.absoluteFilePath(iter)); } else if (key == "Copyright"_L1) { QJsonValueConstRef jsonValue = iter.value(); if (jsonValue.isArray()) { // Array joined with new lines auto maybeString = arrayToMultiLineString(jsonValue); if (maybeString) p.copyright = maybeString.value(); } else if (jsonValue.isString()) { // Legacy format: multiple values separated by space in one string. p.copyright = value; } else { if (logLevel != SilentLog) { std::cerr << qPrintable(tr("File %1: Expected JSON array of strings or " "string as value of %2.").arg( QDir::toNativeSeparators(filePath), key)) << std::endl; validPackage = false; continue; } } } else if (key == "CPE"_L1) { const QJsonValueConstRef jsonValue = iter.value(); if (!handleStringOrStringArrayJsonKey(p.cpeList, key, jsonValue, filePath, logLevel)) { validPackage = false; continue; } } else if (key == "PURL"_L1) { const QJsonValueConstRef jsonValue = iter.value(); if (!handleStringOrStringArrayJsonKey(p.purlList, key, jsonValue, filePath, logLevel)) { validPackage = false; continue; } } else if (key == "CopyrightFile"_L1) { p.copyrightFile = QDir(directory).absoluteFilePath(value); } else if (key == "PackageComment"_L1) { p.packageComment = value; } else if (key == "QDocModule"_L1) { p.qdocModule = value; } else if (key == "Description"_L1) { p.description = value; } else if (key == "QtUsage"_L1) { p.qtUsage = value; } else if (key == "SecurityCritical"_L1) { if (!iter.value().isBool()) { std::cerr << qPrintable(tr("File %1: Expected JSON boolean in %2.") .arg(QDir::toNativeSeparators(filePath), key)) << std::endl; validPackage = false; continue; } p.securityCritical = iter.value().toBool(); } else if (key == "QtParts"_L1) { auto parts = toStringList(iter.value()); if (!parts) { if (logLevel != SilentLog) { std::cerr << qPrintable(tr("File %1: Expected JSON array of strings in %2.") .arg(QDir::toNativeSeparators(filePath), key)) << std::endl; } validPackage = false; continue; } p.qtParts = parts.value(); } else { if (logLevel != SilentLog) { std::cerr << qPrintable(tr("File %1: Unknown key %2.").arg( QDir::toNativeSeparators(filePath), key)) << std::endl; } validPackage = false; } } if (!p.copyrightFile.isEmpty()) { QFile file(p.copyrightFile); if (!file.open(QIODevice::ReadOnly)) { std::cerr << qPrintable(tr("File %1: Cannot open 'CopyrightFile' %2.\n") .arg(QDir::toNativeSeparators(filePath), QDir::toNativeSeparators(p.copyrightFile))); validPackage = false; } p.copyrightFileContents = QString::fromUtf8(file.readAll()); } if (p.licenseFiles.isEmpty() && !autoDetectLicenseFiles(p)) return std::nullopt; for (const QString &licenseFile : std::as_const(p.licenseFiles)) { QFile file(licenseFile); if (!file.open(QIODevice::ReadOnly)) { if (logLevel != SilentLog) { std::cerr << qPrintable(tr("File %1: Cannot open 'LicenseFile' %2.\n") .arg(QDir::toNativeSeparators(filePath), QDir::toNativeSeparators(licenseFile))); } validPackage = false; } p.licenseFilesContents << QString::fromUtf8(file.readAll()).trimmed(); } if (!validatePackage(p, filePath, checks, logLevel) || !validPackage) return std::nullopt; return p; } // Parses a package's details from a README.chromium file static Package parseChromiumFile(QFile &file, const QString &filePath, LogLevel logLevel) { const QString directory = QFileInfo(filePath).absolutePath(); // Parse the fields in the file QHash fields; QTextStream in(&file); while (!in.atEnd()) { QString line = in.readLine().trimmed(); QStringList parts = line.split(u":"_s); if (parts.size() < 2) continue; QString key = parts.at(0); parts.removeFirst(); QString value = parts.join(QString()).trimmed(); fields[key] = value; if (line == "Description:"_L1) { // special field : should handle multi-lines values while (!in.atEnd()) { QString line = in.readLine().trimmed(); if (line.startsWith("Local Modifications:"_L1)) // Don't include this part break; fields[key] += line + u"\n"_s; } break; } } // Construct the Package object Package p; QString shortName = fields.contains("Short Name"_L1) ? fields["Short Name"_L1] : fields["Name"_L1]; QString version = fields[u"Version"_s]; p.id = u"chromium-"_s + shortName.toLower().replace(QChar::Space, u"-"_s); p.name = fields[u"Name"_s]; if (version != QLatin1Char('0')) // "0" : not applicable p.version = version; p.license = fields[u"License"_s]; p.homepage = fields[u"URL"_s]; p.qdocModule = u"qtwebengine"_s; p.qtUsage = u"Used in Qt WebEngine"_s; p.description = fields[u"Description"_s].trimmed(); p.path = directory; QString licenseFile = fields[u"License File"_s]; if (licenseFile != QString() && licenseFile != "NOT_SHIPPED"_L1) { p.licenseFiles = QStringList(QDir(directory).absoluteFilePath(licenseFile)); } else { // Look for a LICENSE or COPYING file as a fallback QDir dir = directory; dir.setNameFilters({ u"LICENSE"_s, u"COPYING"_s }); dir.setFilter(QDir::Files | QDir::NoDotAndDotDot); const QFileInfoList entries = dir.entryInfoList(); if (!entries.empty()) p.licenseFiles = QStringList(entries.at(0).absoluteFilePath()); } // let's ignore warnings regarding Chromium files for now Q_UNUSED(validatePackage(p, filePath, {}, logLevel)); return p; } struct CursorPosition { int line = -1; int column = -1; }; static CursorPosition mapFromOffset(const QByteArray &content, int offset) { CursorPosition pos{ 1, 1 }; for (int i = 0; i < content.size(); ++i) { if (i == offset) return pos; if (content[i] == '\n') { pos.line++; pos.column = 1; } else { pos.column++; } } return CursorPosition(); } std::optional> readFile(const QString &filePath, Checks checks, LogLevel logLevel) { QList packages; bool errorsFound = false; if (logLevel == VerboseLog) { std::cerr << qPrintable(tr("Reading file %1...").arg( QDir::toNativeSeparators(filePath))) << std::endl; } QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { if (logLevel != SilentLog) std::cerr << qPrintable(tr("Could not open file %1.").arg( QDir::toNativeSeparators(file.fileName()))) << std::endl; return std::nullopt; } if (filePath.endsWith(".json"_L1)) { QJsonParseError jsonParseError; const QByteArray content = file.readAll(); const QJsonDocument document = QJsonDocument::fromJson(content, &jsonParseError); if (document.isNull()) { if (logLevel != SilentLog) { const CursorPosition pos = mapFromOffset(content, jsonParseError.offset); std::cerr << qPrintable(tr("Could not parse file %1: %2 at line %3, column %4") .arg(QDir::toNativeSeparators(file.fileName()), jsonParseError.errorString(), QString::number(pos.line), QString::number(pos.column))) << std::endl; } return std::nullopt; } if (document.isObject()) { std::optional p = readPackage(document.object(), file.fileName(), checks, logLevel); if (p) { packages << *p; } else { errorsFound = true; } } else if (document.isArray()) { QJsonArray array = document.array(); for (int i = 0, size = array.size(); i < size; ++i) { QJsonValue value = array.at(i); if (value.isObject()) { std::optional p = readPackage(value.toObject(), file.fileName(), checks, logLevel); if (p) { packages << *p; } else { errorsFound = true; } } else { if (logLevel != SilentLog) { std::cerr << qPrintable(tr("File %1: Expecting JSON object in array.") .arg(QDir::toNativeSeparators(file.fileName()))) << std::endl; } errorsFound = true; } } } else { if (logLevel != SilentLog) { std::cerr << qPrintable(tr("File %1: Expecting JSON object in array.").arg( QDir::toNativeSeparators(file.fileName()))) << std::endl; } errorsFound = true; } } else if (filePath.endsWith(".chromium"_L1)) { Package chromiumPackage = parseChromiumFile(file, filePath, logLevel); if (!chromiumPackage.name.isEmpty()) // Skip invalid README.chromium files packages << chromiumPackage; } else { if (logLevel != SilentLog) { std::cerr << qPrintable(tr("File %1: Unsupported file type.") .arg(QDir::toNativeSeparators(file.fileName()))) << std::endl; } errorsFound = true; } if (errorsFound) return std::nullopt; return packages; } std::optional> scanDirectory(const QString &directory, InputFormats inputFormats, Checks checks, LogLevel logLevel) { QDir dir(directory); QList packages; bool errorsFound = false; QStringList nameFilters = QStringList(); if (inputFormats & InputFormat::QtAttributions) nameFilters << u"qt_attribution.json"_s; if (inputFormats & InputFormat::ChromiumAttributions) nameFilters << u"README.chromium"_s; if (qEnvironmentVariableIsSet("QT_ATTRIBUTIONSSCANNER_TEST")) nameFilters << u"qt_attribution_test.json"_s << u"README_test.chromium"_s; dir.setNameFilters(nameFilters); dir.setFilter(QDir::AllDirs | QDir::NoDotAndDotDot | QDir::Files); const QFileInfoList entries = dir.entryInfoList(); for (const QFileInfo &info : entries) { if (info.isDir()) { std::optional> ps = scanDirectory(info.filePath(), inputFormats, checks, logLevel); if (!ps) errorsFound = true; else packages += *ps; } else { std::optional p = readFile(info.filePath(), checks, logLevel); if (!p) errorsFound = true; else packages += *p; } } if (errorsFound) return std::nullopt; return packages; } } // namespace Scanner