Phosphor
Qt6 / Wayland library suite for window-management tools
 
Loading...
Searching...
No Matches
JsonEnvelopeValidator.h
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 fuddlesworth
2// SPDX-License-Identifier: LGPL-2.1-or-later
3
4#pragma once
5
7
8#include <QtCore/QFile>
9#include <QtCore/QFileInfo>
10#include <QtCore/QJsonDocument>
11#include <QtCore/QJsonObject>
12#include <QtCore/QLatin1String>
13#include <QtCore/QLoggingCategory>
14#include <QtCore/QString>
15
16#include <optional>
17#include <utility>
18
19namespace PhosphorFsLoader {
20
31{
32 QString name;
33 QJsonObject root;
34};
35
76inline std::optional<JsonEnvelope> validateJsonEnvelope(const QString& filePath, const QLoggingCategory& category)
77{
78 // Single source of truth for the JSON-envelope size cap is
79 // `kMaxJsonFileBytes`. The loader applies it first via
80 // the default sink dispatch path; this helper enforces it again
81 // because `validateJsonEnvelope` is a public free function and may
82 // be called directly (without a loader stat in front of it). One
83 // extra `QFileInfo::size()` per direct call — microscopic compared
84 // with the alternative of letting a 2 GiB blob fall through to a
85 // caller that didn't stat itself.
86 QFileInfo info(filePath);
87 // `isFile()`, not just size: `open(ReadOnly)` on a FIFO blocks until a
88 // writer appears — indefinitely, on the caller's thread — and neither size
89 // check helps (a FIFO reports size 0). The loader-driven callers are immune
90 // only incidentally (they enumerate with `QDir::Files`), but this is a
91 // public free function documented as callable without a stat in front of
92 // it, so it screens out the FIFO/device/directory cases it can see at stat
93 // time.
94 //
95 // This is a stat-time screen, not a guarantee: the guard is gated on
96 // `exists()`, so a path that is ABSENT at stat time passes through (there
97 // is nothing to reject yet), and a regular file can still be swapped for a
98 // FIFO in the TOCTOU window between this stat and the `open()` below. Fully
99 // closing that would need an O_NONBLOCK open + fstat on the descriptor;
100 // callers handling genuinely hostile paths must do that themselves. The
101 // gate here removes the common accidental-FIFO/dir footgun, not a motivated
102 // attacker with write access to the exact path mid-call.
103 if (info.exists() && !info.isFile()) {
104 qCWarning(category) << "Skipping non-regular file" << filePath;
105 return std::nullopt;
106 }
107 if (info.exists() && info.size() > kMaxJsonFileBytes) {
108 qCWarning(category).nospace() << "Skipping " << filePath << ": file size " << info.size() << " exceeds limit "
110 return std::nullopt;
111 }
112
113 QFile file(filePath);
114 if (!file.open(QIODevice::ReadOnly)) {
115 qCWarning(category) << "Skipping unreadable file" << filePath << ":" << file.errorString();
116 return std::nullopt;
117 }
118 // Re-checked on the OPEN handle. The pre-open `QFileInfo` above is gated on
119 // `exists()`, so a path that was absent at stat time and created before the
120 // open bypassed the cap entirely and `readAll()` below was unbounded — which
121 // defeats the whole point of the guard. This check is on the descriptor, so
122 // there is no window between the size and the read.
123 if (file.size() > kMaxJsonFileBytes) {
124 qCWarning(category).nospace() << "Skipping " << filePath << ": file size " << file.size() << " exceeds limit "
126 return std::nullopt;
127 }
128
129 QJsonParseError parseError;
130 const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
131 if (parseError.error != QJsonParseError::NoError) {
132 qCWarning(category) << "Skipping malformed JSON" << filePath << ":" << parseError.errorString();
133 return std::nullopt;
134 }
135 if (!doc.isObject()) {
136 qCWarning(category) << "Skipping non-object root JSON in" << filePath;
137 return std::nullopt;
138 }
139
140 QJsonObject root = doc.object();
141
142 const QString name = root.value(QLatin1String("name")).toString();
143 if (name.isEmpty()) {
144 qCWarning(category) << "Skipping" << filePath << ": missing required 'name' field";
145 return std::nullopt;
146 }
147
148 // The filename (without extension) is the user's ergonomic handle on
149 // the entity; the inner `name` field is what actually gets
150 // registered. If the two diverge — typically because the user copied
151 // `widget.fade.json → custom.json` and forgot to rename the inner
152 // field — the result registers under the inner-name key while the
153 // file on disk suggests a different identity. Reject up front with a
154 // clear diagnostic naming both sides.
155 // Reuses the QFileInfo built for the size cap above rather than stat-ing
156 // the same path a second time on this per-file hot path.
157 const QString basename = info.completeBaseName();
158 if (name != basename) {
159 qCWarning(category).nospace() << "Skipping " << filePath << ": name '" << name << "' does not match filename '"
160 << basename << "' — rejecting to avoid silent shadowing";
161 return std::nullopt;
162 }
163
164 // Strip the bookkeeping field before handing the root on. It is this
165 // layer's routing key, not schema data, and a sink's `fromJson` should
166 // never have to know it was ever there — nor re-serialize it as an
167 // unrecognised field it decided to preserve.
168 root.remove(QLatin1String("name"));
169
170 return JsonEnvelope{name, std::move(root)};
171}
172
173} // namespace PhosphorFsLoader
Definition DirectoryLoader.h:19
constexpr qint64 kMaxJsonFileBytes
Per-file size cap for JSON metadata reads.
Definition FileLimits.h:18
std::optional< JsonEnvelope > validateJsonEnvelope(const QString &filePath, const QLoggingCategory &category)
Validate the default envelope used by DirectoryLoader sinks.
Definition JsonEnvelopeValidator.h:76
Result of a successful envelope validation.
Definition JsonEnvelopeValidator.h:31
QString name
Definition JsonEnvelopeValidator.h:32
QJsonObject root
Definition JsonEnvelopeValidator.h:33