Phosphor
Qt6 / Wayland library suite for window-management tools
 
Loading...
Searching...
No Matches
PackPathGuard.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
6#include <QtCore/QDir>
7#include <QtCore/QFileInfo>
8#include <QtCore/QString>
9#include <QtCore/QStringList>
10
11#include <optional>
12
13namespace PhosphorFsLoader {
14
22 Reject,
26 Trust,
27};
28
61[[nodiscard]] inline std::optional<QString> resolveWithinDirectory(const QString& declaredPath,
62 const QString& directory, AbsolutePathPolicy policy)
63{
64 if (declaredPath.isEmpty() || directory.isEmpty()) {
65 return std::nullopt;
66 }
67
68 const QDir dir(directory);
69 const bool wasAbsolute = QFileInfo(declaredPath).isAbsolute();
70 if (wasAbsolute && policy == AbsolutePathPolicy::Trust) {
71 // Cleaned like every other accepted return, so a trusted override cannot
72 // reach watch keys and path-keyed caches in two spellings of one file.
73 return QDir::cleanPath(declaredPath);
74 }
75 // `absoluteFilePath`, not `filePath`: a RELATIVE @p directory would otherwise
76 // yield a relative result, breaking both the documented return contract and
77 // the cache-dedupe rationale.
78 const QString resolved = wasAbsolute ? declaredPath : dir.absoluteFilePath(declaredPath);
79
80 const QString lexicalRoot = QDir::cleanPath(dir.absolutePath());
81 const QString canonicalRoot = QFileInfo(dir.absolutePath()).canonicalFilePath();
82
83 // Canonicalise as much of the target as EXISTS, then re-append the rest.
84 //
85 // A plain `QFileInfo(resolved).canonicalFilePath()` returns empty for a path
86 // whose leaf does not exist yet, and falling back to a purely lexical
87 // compare there fails OPEN: `pack/link/future.frag`, where `link` is a
88 // symlink out of the pack, is lexically inside and canonically outside, and
89 // the leaf not existing is exactly the live-reload case (the watcher arms on
90 // the path and the file materialises later). So walk up to the deepest
91 // existing ancestor, canonicalise THAT, and rebuild — which resolves every
92 // symlinked intermediate component even when the leaf is absent.
93 //
94 // The climb is PURE STRING WORK via `QFileInfo::absolutePath()`. It must not
95 // use `QDir::cdUp()`: that is `cd("..")`, which FAILS on a parent that does
96 // not exist, so it stops at the first missing component rather than at the
97 // deepest existing ancestor. With two or more missing components below a
98 // symlink (`pack/link/sub/future.frag`) the old loop bailed out to a lexical
99 // path and handed it to a CANONICAL root comparison — the exact domain mixing
100 // this function claims never to do, failing open on a real escape.
101 //
102 // Returns nullopt when NOTHING on the chain exists up to the filesystem root.
103 // That case fails CLOSED rather than degrading to lexical, because a lexical
104 // target compared against a canonical root is unsound in both directions.
105 const auto canonicalise = [](const QString& path) -> std::optional<QString> {
106 QString current = QDir::cleanPath(path);
107 QStringList tail;
108 // Bounded by the component count: each iteration strips exactly one, and
109 // `absolutePath()` of the filesystem root is the root itself, which ends it.
110 for (;;) {
111 const QString canonical = QFileInfo(current).canonicalFilePath();
112 if (!canonical.isEmpty()) {
113 if (tail.isEmpty()) {
114 return canonical;
115 }
116 // Cleaned, so a deepest-existing-ancestor of "/" does not produce
117 // a doubled separator.
118 return QDir::cleanPath(canonical + QLatin1Char('/') + tail.join(QLatin1Char('/')));
119 }
120 const QFileInfo info(current);
121 // PRESENT but unresolvable is NOT the same as absent, and conflating
122 // them is an escape of exactly the kind this climb was rewritten to
123 // close. `canonicalFilePath()` returns empty for a DANGLING symlink
124 // just as it does for a missing component (a self-referential CYCLE
125 // is different: Qt canonicalises it to itself, so a contained cycle
126 // never reaches this branch and is accepted by the containment
127 // check — see acceptsASymlinkCycleBecauseItStaysContained in the
128 // tests). Without this the climb would treat a dangling link as
129 // "not there yet", prepend its name, and
130 // re-append it LEXICALLY onto the canonical root — mixing the two
131 // domains this function promises never to mix. A pack shipping
132 // `link -> /outside` while `/outside` does not exist yet would then be
133 // accepted, and the stored path resolves out of the pack the moment the
134 // target materialises. Nothing re-validates at that point either:
135 // QFileSystemWatcher cannot watch a non-existent path, so the
136 // materialisation fires no rescan.
137 if (info.isSymLink()) {
138 return std::nullopt;
139 }
140 const QString parent = info.absolutePath();
141 const QString name = info.fileName();
142 if (name.isEmpty() || parent.isEmpty() || parent == current) {
143 return std::nullopt;
144 }
145 tail.prepend(name);
146 current = parent;
147 }
148 };
149
150 // Never mix domains: compare canonical against canonical, and only fall back
151 // to lexical-vs-lexical when the ROOT itself cannot be canonicalised.
152 const bool useCanonical = !canonicalRoot.isEmpty();
153 QString target;
154 if (useCanonical) {
155 const auto canonicalTarget = canonicalise(resolved);
156 if (!canonicalTarget) {
157 return std::nullopt;
158 }
159 target = *canonicalTarget;
160 } else {
161 target = QDir::cleanPath(resolved);
162 }
163 const QString root = useCanonical ? canonicalRoot : lexicalRoot;
164
165 // `root + '/'` unless root already ends in one, so a directory of "/" does
166 // not become "//" and refuse everything.
167 const QString prefix = root.endsWith(QLatin1Char('/')) ? root : root + QLatin1Char('/');
168 if (target == root || !target.startsWith(prefix)) {
169 return std::nullopt;
170 }
171 // The CLEANED form, so `./x.frag` and `sub//x.frag` do not reach watch keys,
172 // content signatures and path-keyed caches as distinct strings for one file.
173 return QDir::cleanPath(resolved);
174}
175
176} // namespace PhosphorFsLoader
Definition DirectoryLoader.h:19
AbsolutePathPolicy
What to do with a declared path that is already absolute.
Definition PackPathGuard.h:16
@ Reject
Do not trust it as-is: the path is still subject to the containment check, so only an absolute path t...
std::optional< QString > resolveWithinDirectory(const QString &declaredPath, const QString &directory, AbsolutePathPolicy policy)
Resolve declaredPath against directory, refusing anything that lands outside it.
Definition PackPathGuard.h:61