morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
file_store.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2#pragma once
3
4#include <algorithm>
5#include <chrono>
6#include <cstddef>
7#include <cstdint>
8#include <expected>
9#include <functional>
10#include <map>
11#include <mutex>
12#include <random>
13#include <string>
14#include <string_view>
15#include <utility>
16
17/// @file
18/// @brief The file side channel's store (`files/1`, spec 6 ยง18): uploads in chunks, and files a principal may read by
19/// a capability reference.
20///
21/// Specified in `docs/spec/ui/service.md`, "Files".
22
23namespace morph::ui {
24
25/// @brief Why a store refused a call.
26enum class FileError : std::uint8_t {
27 NotFound, ///< No such upload or file for this principal; a caller cannot tell the two apart.
28 TooLarge, ///< The file is larger than the store accepts.
29 OverQuota, ///< The principal's files would exceed what the store keeps for one principal.
30 BadOffset, ///< A chunk does not start where the bytes received so far end.
31 Incomplete, ///< An upload ended before every byte it announced arrived.
32};
33
34/// @brief The text of a `FileError`, as a `ui-file` refusal carries it.
35/// @param error The error.
36/// @return Its text.
37[[nodiscard]] constexpr std::string_view fileErrorText(FileError error) noexcept {
38 switch (error) {
40 return "not found";
42 return "too large";
44 return "over quota";
46 return "bad offset";
48 default:
49 return "incomplete";
50 }
51}
52
53/// @brief What a file is, as its uploader described it.
54struct FileInfo {
55 /// @brief The file's name.
56 std::string name;
57 /// @brief Its media type.
58 std::string type;
59 /// @brief Its size in bytes.
60 std::size_t size = 0;
61};
62
63/// @brief A slice of a stored file.
64struct FileSlice {
65 /// @brief The bytes of the slice.
66 std::string bytes;
67 /// @brief The file's description; `size` is the whole file's.
69};
70
71// NOLINTBEGIN(bugprone-easily-swappable-parameters): every call takes the principal, then the id it owns.
72/// @brief Where uploaded and issued files live. Every call names the principal it is made for, and an upload id or a
73/// file reference works only for the principal it was made for.
74///
75/// A reference is a capability: whoever presents it, as the principal it belongs to, may read the file. It is
76/// therefore unguessable (`newFileRef`), and a store answers a reference of another principal exactly as one that
77/// does not exist.
78class IFileStore {
79public:
80 IFileStore() = default;
81 IFileStore(IFileStore const&) = delete;
82 IFileStore& operator=(IFileStore const&) = delete;
83 IFileStore(IFileStore&&) = delete;
84 IFileStore& operator=(IFileStore&&) = delete;
85 virtual ~IFileStore() = default;
86
87 /// @brief Starts an upload.
88 /// @param principal The uploader.
89 /// @param info The file's name, type and size.
90 /// @return The upload id, or why it was refused.
91 [[nodiscard]] virtual std::expected<std::string, FileError> begin(std::string const& principal, FileInfo info) = 0;
92
93 /// @brief Appends a chunk to an upload.
94 /// @param principal The uploader.
95 /// @param upload The upload id.
96 /// @param offset Where the chunk starts: the bytes received so far.
97 /// @param bytes The chunk.
98 /// @return The bytes received so far, or why the chunk was refused.
99 [[nodiscard]] virtual std::expected<std::size_t, FileError> append(std::string const& principal,
100 std::string const& upload, std::size_t offset,
101 std::string_view bytes) = 0;
102
103 /// @brief Ends an upload and files it.
104 /// @param principal The uploader.
105 /// @param upload The upload id.
106 /// @return The file's reference, or why it was refused.
107 [[nodiscard]] virtual std::expected<std::string, FileError> finish(std::string const& principal,
108 std::string const& upload) = 0;
109
110 /// @brief Files bytes the server made for a principal, such as a report a reply returns.
111 /// @param principal The principal the reference is for.
112 /// @param info The file's name and type; `size` is taken from @p bytes.
113 /// @param bytes The file.
114 /// @return The file's reference, or why it was refused.
115 [[nodiscard]] virtual std::expected<std::string, FileError> issue(std::string const& principal, FileInfo info,
116 std::string bytes) = 0;
117
118 /// @brief Reads a slice of a file.
119 /// @param principal The reader.
120 /// @param ref The file's reference.
121 /// @param offset The first byte.
122 /// @param length At most this many bytes.
123 /// @return The slice, or why it was refused.
124 [[nodiscard]] virtual std::expected<FileSlice, FileError> read(std::string const& principal,
125 std::string const& ref, std::size_t offset,
126 std::size_t length) = 0;
127};
128
129/// @brief A new unguessable id: @p prefix, then 32 lower-case hex digits read straight from `std::random_device`.
130///
131/// `std::random_device` is read directly, never used as a seed: libstdc++, libc++ and MSVC back it with the
132/// operating system's entropy source, which is what makes the id a capability.
133/// @param prefix `file:` for a reference, `upload:` for an upload id.
134/// @return The id.
135[[nodiscard]] inline std::string newFileRef(std::string_view prefix = "file:") {
136 static constexpr std::string_view kHex = "0123456789abcdef";
137 std::random_device entropy;
138 std::string text{prefix};
139 for (int word = 0; word < 4; ++word) {
140 std::uint32_t value = entropy(); // random_device yields 32 bits of entropy per call
141 for (int nibble = 0; nibble < 8; ++nibble) {
142 text += kHex.at(value & 0xFU);
143 value >>= 4U;
144 }
145 }
146 return text;
147}
148
149/// @brief How much a `MemoryFileStore` keeps.
151 /// @brief The largest file it accepts.
152 std::size_t maxFileBytes = std::size_t{64} * 1024 * 1024;
153 /// @brief The most bytes it keeps for one principal, uploads in progress included.
154 std::size_t maxBytesPerPrincipal = std::size_t{256} * 1024 * 1024;
155 /// @brief How long an upload may go without a chunk before it is dropped.
156 std::chrono::seconds uploadTtl{3600};
157 /// @brief The clock expiry reads; the steady clock when empty.
158 std::function<std::chrono::steady_clock::time_point()> clock;
159};
160
161/// @brief An `IFileStore` in memory: bounded per file and per principal; an upload that stalls expires.
162///
163/// Thread-safe: the UI service calls it on the server strand while an application issues references from its models'.
164class MemoryFileStore final : public IFileStore {
165public:
166 /// @brief A store with @p options.
167 /// @param options The bounds and the clock.
168 explicit MemoryFileStore(MemoryFileStoreOptions options = {}) : _options{std::move(options)} {}
169
170 /// @brief Starts an upload.
171 /// @param principal The uploader.
172 /// @param info The file's name, type and size.
173 /// @return The upload id, or `TooLarge` or `OverQuota`.
174 [[nodiscard]] std::expected<std::string, FileError> begin(std::string const& principal, FileInfo info) override {
175 std::scoped_lock const lock{_mutex};
176 expire();
177 if (info.size > _options.maxFileBytes) {
178 return std::unexpected(FileError::TooLarge);
179 }
180 if (usage(principal) + info.size > _options.maxBytesPerPrincipal) {
181 return std::unexpected(FileError::OverQuota);
182 }
183 std::string uploadId = newFileRef("upload:");
184 _uploads.emplace(uploadId,
185 Upload{.principal = principal, .info = std::move(info), .bytes = {}, .touched = now()});
186 return uploadId;
187 }
188
189 /// @brief Appends a chunk.
190 /// @param principal The uploader.
191 /// @param upload The upload id.
192 /// @param offset The bytes received so far.
193 /// @param bytes The chunk.
194 /// @return The bytes received so far, or `NotFound`, `BadOffset` or `TooLarge` (past the announced size).
195 [[nodiscard]] std::expected<std::size_t, FileError> append(std::string const& principal, std::string const& upload,
196 std::size_t offset, std::string_view bytes) override {
197 std::scoped_lock const lock{_mutex};
198 expire();
199 auto const found = _uploads.find(upload);
200 if (found == _uploads.end() || found->second.principal != principal) {
201 return std::unexpected(FileError::NotFound);
202 }
203 Upload& open = found->second;
204 if (offset != open.bytes.size()) {
205 return std::unexpected(FileError::BadOffset);
206 }
207 if (open.bytes.size() + bytes.size() > open.info.size) {
208 return std::unexpected(FileError::TooLarge);
209 }
210 open.bytes.append(bytes);
211 open.touched = now();
212 return open.bytes.size();
213 }
214
215 /// @brief Ends an upload and files it.
216 /// @param principal The uploader.
217 /// @param upload The upload id.
218 /// @return The reference, or `NotFound` or `Incomplete`.
219 [[nodiscard]] std::expected<std::string, FileError> finish(std::string const& principal,
220 std::string const& upload) override {
221 std::scoped_lock const lock{_mutex};
222 expire();
223 auto const found = _uploads.find(upload);
224 if (found == _uploads.end() || found->second.principal != principal) {
225 return std::unexpected(FileError::NotFound);
226 }
227 if (found->second.bytes.size() != found->second.info.size) {
228 return std::unexpected(FileError::Incomplete);
229 }
230 Upload done = std::move(found->second);
231 _uploads.erase(found);
232 std::string ref = newFileRef();
233 _files.emplace(ref, Stored{.principal = std::move(done.principal),
234 .info = std::move(done.info),
235 .bytes = std::move(done.bytes)});
236 return ref;
237 }
238
239 /// @brief Files bytes for a principal.
240 /// @param principal The principal.
241 /// @param info The name and type.
242 /// @param bytes The file.
243 /// @return The reference, or `TooLarge` or `OverQuota`.
244 [[nodiscard]] std::expected<std::string, FileError> issue(std::string const& principal, FileInfo info,
245 std::string bytes) override {
246 std::scoped_lock const lock{_mutex};
247 expire();
248 if (bytes.size() > _options.maxFileBytes) {
249 return std::unexpected(FileError::TooLarge);
250 }
251 if (usage(principal) + bytes.size() > _options.maxBytesPerPrincipal) {
252 return std::unexpected(FileError::OverQuota);
253 }
254 info.size = bytes.size();
255 std::string ref = newFileRef();
256 _files.emplace(ref, Stored{.principal = principal, .info = std::move(info), .bytes = std::move(bytes)});
257 return ref;
258 }
259
260 /// @brief Reads a slice.
261 /// @param principal The reader.
262 /// @param ref The reference.
263 /// @param offset The first byte.
264 /// @param length At most this many bytes.
265 /// @return The slice (empty past the end), or `NotFound`.
266 [[nodiscard]] std::expected<FileSlice, FileError> read(std::string const& principal, std::string const& ref,
267 std::size_t offset, std::size_t length) override {
268 std::scoped_lock const lock{_mutex};
269 auto const found = _files.find(ref);
270 if (found == _files.end() || found->second.principal != principal) {
271 return std::unexpected(FileError::NotFound);
272 }
273 Stored const& file = found->second;
274 std::size_t const start = std::min(offset, file.bytes.size());
275 return FileSlice{.bytes = file.bytes.substr(start, length), .info = file.info};
276 }
277
278private:
279 struct Upload {
280 std::string principal;
281 FileInfo info;
282 std::string bytes;
283 std::chrono::steady_clock::time_point touched;
284 };
285
286 struct Stored {
287 std::string principal;
288 FileInfo info;
289 std::string bytes;
290 };
291
292 [[nodiscard]] std::chrono::steady_clock::time_point now() const {
293 return _options.clock ? _options.clock() : std::chrono::steady_clock::now();
294 }
295
296 /// Drops the uploads that went without a chunk for longer than the TTL. Under the lock.
297 void expire() {
298 auto const cutoff = now() - _options.uploadTtl;
299 std::erase_if(_uploads, [cutoff](auto const& entry) { return entry.second.touched < cutoff; });
300 }
301
302 /// The bytes a principal holds: its files, and the announced sizes of its uploads. Under the lock.
303 [[nodiscard]] std::size_t usage(std::string const& principal) const {
304 std::size_t total = 0;
305 for (auto const& entry : _files) {
306 if (entry.second.principal == principal) {
307 total += entry.second.bytes.size();
308 }
309 }
310 for (auto const& entry : _uploads) {
311 if (entry.second.principal == principal) {
312 total += entry.second.info.size;
313 }
314 }
315 return total;
316 }
317
318 MemoryFileStoreOptions _options;
319 mutable std::mutex _mutex;
320 std::map<std::string, Upload, std::less<>> _uploads;
321 std::map<std::string, Stored, std::less<>> _files;
322};
323
324// NOLINTEND(bugprone-easily-swappable-parameters)
325
326} // namespace morph::ui
virtual std::expected< FileSlice, FileError > read(std::string const &principal, std::string const &ref, std::size_t offset, std::size_t length)=0
Reads a slice of a file.
virtual std::expected< std::string, FileError > issue(std::string const &principal, FileInfo info, std::string bytes)=0
Files bytes the server made for a principal, such as a report a reply returns.
virtual std::expected< std::size_t, FileError > append(std::string const &principal, std::string const &upload, std::size_t offset, std::string_view bytes)=0
Appends a chunk to an upload.
virtual std::expected< std::string, FileError > begin(std::string const &principal, FileInfo info)=0
Starts an upload.
virtual std::expected< std::string, FileError > finish(std::string const &principal, std::string const &upload)=0
Ends an upload and files it.
std::expected< std::string, FileError > issue(std::string const &principal, FileInfo info, std::string bytes) override
Files bytes for a principal.
std::expected< std::string, FileError > finish(std::string const &principal, std::string const &upload) override
Ends an upload and files it.
std::expected< std::size_t, FileError > append(std::string const &principal, std::string const &upload, std::size_t offset, std::string_view bytes) override
Appends a chunk.
std::expected< FileSlice, FileError > read(std::string const &principal, std::string const &ref, std::size_t offset, std::size_t length) override
Reads a slice.
std::expected< std::string, FileError > begin(std::string const &principal, FileInfo info) override
Starts an upload.
MemoryFileStore(MemoryFileStoreOptions options={})
A store with options.
constexpr std::string_view fileErrorText(FileError error) noexcept
The text of a FileError, as a ui-file refusal carries it.
std::string newFileRef(std::string_view prefix="file:")
A new unguessable id: prefix, then 32 lower-case hex digits read straight from std::random_device.
FileError
Why a store refused a call.
@ NotFound
No such upload or file for this principal; a caller cannot tell the two apart.
@ OverQuota
The principal's files would exceed what the store keeps for one principal.
@ Incomplete
An upload ended before every byte it announced arrived.
@ TooLarge
The file is larger than the store accepts.
@ BadOffset
A chunk does not start where the bytes received so far end.
What a file is, as its uploader described it.
std::string type
Its media type.
std::string name
The file's name.
std::size_t size
Its size in bytes.
A slice of a stored file.
FileInfo info
The file's description; size is the whole file's.
std::string bytes
The bytes of the slice.
How much a MemoryFileStore keeps.
std::chrono::seconds uploadTtl
How long an upload may go without a chunk before it is dropped.
std::size_t maxFileBytes
The largest file it accepts.
std::function< std::chrono::steady_clock::time_point()> clock
The clock expiry reads; the steady clock when empty.
std::size_t maxBytesPerPrincipal
The most bytes it keeps for one principal, uploads in progress included.
Node text(Text spec)
Static or bound text.
Definition view.hpp:1194