NAH 3.0.0
Native Application Host - Library API Reference
Loading...
Searching...
No Matches
nah_store.h
Go to the documentation of this file.
1/* Durable local package-store mutations. SPDX-License-Identifier: MIT */
2
3#ifndef NAH_STORE_H
4#define NAH_STORE_H
5
6#include <nlohmann/json.hpp>
7
8#include <filesystem>
9#include <fstream>
10#include <cstdint>
11#include <cstdio>
12#include <random>
13#include <string>
14#include <vector>
15
16#ifdef _WIN32
17#include <windows.h>
18#else
19#include <fcntl.h>
20#include <sys/file.h>
21#include <unistd.h>
22#endif
23
24namespace nah::store {
25namespace fs = std::filesystem;
26
27enum class Error {
28 none,
29 busy,
34};
35
36struct Result {
37 bool ok = false;
39 std::string message;
40};
41
42namespace detail {
43
44inline std::string unique_id() {
45 static std::random_device random;
46 static std::mt19937_64 generator(random());
47 std::uniform_int_distribution<std::uint64_t> distribution;
48 char value[33];
49 std::snprintf(value, sizeof(value), "%016llx%016llx",
50 static_cast<unsigned long long>(distribution(generator)),
51 static_cast<unsigned long long>(distribution(generator)));
52 return value;
53}
54
55inline bool safe_relative(const fs::path& path) {
56 if (path.empty() || path.is_absolute() || path.has_root_name()) return false;
57 for (const auto& part : path.lexically_normal()) {
58 if (part == ".." || part == ".") return false;
59 }
60 return true;
61}
62
63inline bool allowed_pair(const fs::path& payload, const fs::path& record) {
64 if (!safe_relative(payload) || !safe_relative(record)) return false;
65 std::vector<fs::path> payload_parts(payload.begin(), payload.end());
66 std::vector<fs::path> record_parts(record.begin(), record.end());
67 if (record_parts.size() != 3 || record_parts[0] != "registry" ||
68 record_parts[2].extension() != ".json") return false;
69 return (payload_parts.size() == 2 && payload_parts[0] == "apps" && record_parts[1] == "apps") ||
70 (payload_parts.size() == 3 && payload_parts[0] == "naks" && record_parts[1] == "naks");
71}
72
73inline bool sync_file(const fs::path& path, std::string& error) {
74#ifdef _WIN32
75 // FlushFileBuffers requires a handle opened for writing.
76 HANDLE handle = CreateFileW(path.wstring().c_str(), GENERIC_READ | GENERIC_WRITE,
77 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
78 nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
79 if (handle == INVALID_HANDLE_VALUE || !FlushFileBuffers(handle)) {
80 if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle);
81 error = "cannot persist " + path.string();
82 return false;
83 }
84 CloseHandle(handle);
85#else
86 const int descriptor = ::open(path.c_str(), O_RDONLY);
87 if (descriptor < 0 || ::fsync(descriptor) != 0) {
88 if (descriptor >= 0) ::close(descriptor);
89 error = "cannot persist " + path.string();
90 return false;
91 }
92 ::close(descriptor);
93#endif
94 return true;
95}
96
97inline bool write_file(const fs::path& path, const std::string& value, std::string& error) {
98 {
99 std::ofstream output(path, std::ios::binary | std::ios::trunc);
100 if (!output) { error = "cannot write " + path.string(); return false; }
101 output << value;
102 output.close();
103 if (!output) { error = "cannot finish " + path.string(); return false; }
104 }
105 return sync_file(path, error);
106}
107
108inline bool sync_tree(const fs::path& root, std::string& error) {
109 std::error_code ec;
110 std::vector<fs::path> directories{root};
111 for (fs::recursive_directory_iterator it(root, fs::directory_options::none, ec), end;
112 !ec && it != end; it.increment(ec)) {
113 const auto status = it->symlink_status(ec);
114 if (ec) break;
115 if (fs::is_regular_file(status) && !sync_file(it->path(), error)) return false;
116 if (fs::is_directory(status)) directories.push_back(it->path());
117 }
118 if (ec) { error = "cannot inspect staged package: " + ec.message(); return false; }
119#ifndef _WIN32
120 for (auto it = directories.rbegin(); it != directories.rend(); ++it) {
121 if (!sync_file(*it, error)) return false;
122 }
123 return true;
124#else
125 return true;
126#endif
127}
128
129inline bool sync_directory(const fs::path& path, std::string& error) {
130#ifdef _WIN32
131 (void)path;
132 (void)error;
133 return true;
134#else
135 return sync_file(path, error);
136#endif
137}
138
139inline bool rename_durable(const fs::path& from, const fs::path& to, std::string& error) {
140#ifdef _WIN32
141 if (!MoveFileExW(from.wstring().c_str(), to.wstring().c_str(), MOVEFILE_WRITE_THROUGH)) {
142 error = "cannot rename " + from.string() + " to " + to.string();
143 return false;
144 }
145#else
146 std::error_code ec;
147 fs::rename(from, to, ec);
148 if (ec) { error = "cannot rename " + from.string() + ": " + ec.message(); return false; }
149 if (!sync_directory(from.parent_path(), error)) return false;
150 if (to.parent_path() != from.parent_path() && !sync_directory(to.parent_path(), error)) return false;
151#endif
152 return true;
153}
154
155class Lock {
156public:
157 explicit Lock(const fs::path& path) {
158#ifdef _WIN32
159 handle_ = CreateFileW(path.wstring().c_str(), GENERIC_READ | GENERIC_WRITE, 0,
160 nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
161 acquired_ = handle_ != INVALID_HANDLE_VALUE;
162#else
163 descriptor_ = ::open(path.c_str(), O_CREAT | O_RDWR, 0600);
164 acquired_ = descriptor_ >= 0 && ::flock(descriptor_, LOCK_EX | LOCK_NB) == 0;
165#endif
166 }
167
168 Lock(const Lock&) = delete;
169 Lock& operator=(const Lock&) = delete;
170
172#ifdef _WIN32
173 if (handle_ != INVALID_HANDLE_VALUE) CloseHandle(handle_);
174#else
175 if (descriptor_ >= 0) {
176 if (acquired_) ::flock(descriptor_, LOCK_UN);
177 ::close(descriptor_);
178 }
179#endif
180 }
181
182 bool acquired() const { return acquired_; }
183
184private:
185 bool acquired_ = false;
186#ifdef _WIN32
187 HANDLE handle_ = INVALID_HANDLE_VALUE;
188#else
189 int descriptor_ = -1;
190#endif
191};
192
193} // namespace detail
194
199class Store {
200public:
201 explicit Store(fs::path root) : root_(fs::absolute(std::move(root)).lexically_normal()) {}
202
203 const fs::path& root() const { return root_; }
204
206 std::error_code ec;
207 for (const auto* path : {"apps", "naks", "host", "registry", "registry/apps", "registry/naks", "staging"}) {
208 fs::create_directories(root_ / path, ec);
209 if (ec) return failure(Error::io_error, "cannot create NAH root: " + ec.message());
210 const auto status = fs::symlink_status(root_ / path, ec);
211 if (ec || fs::is_symlink(status) || !fs::is_directory(status)) {
212 return failure(Error::invalid_path, "managed store directory is not a real directory: " +
213 (root_ / path).string());
214 }
215 }
216 return success();
217 }
218
219 Result recover() const {
220 const auto initialized = initialize();
221 if (!initialized.ok) return initialized;
222 detail::Lock lock(root_ / "staging" / "store.lock");
223 if (!lock.acquired()) return failure(Error::busy, "another package operation owns this NAH root");
224 return recover_locked();
225 }
226
227 Result install(const fs::path& source,
228 const fs::path& payload_relative,
229 const fs::path& record_relative,
230 const std::string& record_json,
231 bool force = false) const {
232 if (!detail::allowed_pair(payload_relative, record_relative)) {
233 return failure(Error::invalid_path, "installation paths are outside the managed store");
234 }
235 const auto initialized = initialize();
236 if (!initialized.ok) return initialized;
237 detail::Lock lock(root_ / "staging" / "store.lock");
238 if (!lock.acquired()) return failure(Error::busy, "another package operation owns this NAH root");
239 const auto recovered = recover_locked();
240 if (!recovered.ok) return recovered;
241
242 std::error_code ec;
243 if (!fs::is_directory(source, ec)) return failure(Error::io_error, "installation source is not a directory");
244 for (fs::recursive_directory_iterator it(source, fs::directory_options::none, ec), end;
245 !ec && it != end; it.increment(ec)) {
246 const auto status = it->symlink_status(ec);
247 if (ec) break;
248 if (fs::is_symlink(status) || (!fs::is_directory(status) && !fs::is_regular_file(status))) {
249 return failure(Error::invalid_path, "installation source contains an unsupported file: " +
250 it->path().string());
251 }
252 }
253 if (ec) return failure(Error::io_error, "cannot inspect installation source: " + ec.message());
254 const auto final_path = root_ / payload_relative;
255 const auto record_path = root_ / record_relative;
256 if (!force && (fs::exists(final_path, ec) || fs::exists(record_path, ec))) {
257 return failure(Error::already_exists, "package is already installed; use --force to replace it");
258 }
259
260 const auto operation_id = detail::unique_id();
261 const auto operation = root_ / "staging" / operation_id;
262 const auto staged_payload = operation / "payload";
263 const auto staged_record = operation / "record.json";
264 fs::create_directories(operation, ec);
265 fs::copy(source, staged_payload, fs::copy_options::recursive, ec);
266 if (ec) return cleanup_failure(operation, "cannot stage package: " + ec.message());
267 std::string error;
268 if (!detail::sync_tree(staged_payload, error)) return cleanup_failure(operation, error);
269 if (!detail::write_file(staged_record, record_json, error)) return cleanup_failure(operation, error);
270
271 nlohmann::json journal{{"version", 1}, {"kind", "install"}, {"phase", "prepared"},
272 {"operation", operation_id},
273 {"payload", payload_relative.generic_string()},
274 {"record", record_relative.generic_string()}};
275 if (!write_journal(journal, error)) return cleanup_failure(operation, error);
276
277 fs::create_directories(final_path.parent_path(), ec);
278 if (ec) return rollback_failure(journal, "cannot create payload directory: " + ec.message());
279 fs::create_directories(record_path.parent_path(), ec);
280 if (ec) return rollback_failure(journal, "cannot create registry directory: " + ec.message());
281 if (fs::exists(final_path, ec) && !detail::rename_durable(final_path, operation / "old-payload", error))
282 return rollback_failure(journal, error);
283 if (ec) return rollback_failure(journal, "cannot inspect existing payload: " + ec.message());
284 if (fs::exists(record_path, ec) && !detail::rename_durable(record_path, operation / "old-record.json", error))
285 return rollback_failure(journal, error);
286 if (ec) return rollback_failure(journal, "cannot inspect existing record: " + ec.message());
287 journal["phase"] = "backed_up";
288 if (!write_journal(journal, error)) return rollback_failure(journal, error);
289
290 if (!detail::rename_durable(staged_payload, final_path, error)) return rollback_failure(journal, error);
291 journal["phase"] = "payload_active";
292 if (!write_journal(journal, error)) return rollback_failure(journal, error);
293
294 if (!detail::rename_durable(staged_record, record_path, error)) return rollback_failure(journal, error);
295 journal["phase"] = "committed";
296 if (!write_journal(journal, error)) return rollback_failure(journal, error);
297 return finish_committed(operation);
298 }
299
300 Result remove(const fs::path& payload_relative, const fs::path& record_relative) const {
301 if (!detail::allowed_pair(payload_relative, record_relative)) {
302 return failure(Error::invalid_path, "removal paths are outside the managed store");
303 }
304 const auto initialized = initialize();
305 if (!initialized.ok) return initialized;
306 detail::Lock lock(root_ / "staging" / "store.lock");
307 if (!lock.acquired()) return failure(Error::busy, "another package operation owns this NAH root");
308 const auto recovered = recover_locked();
309 if (!recovered.ok) return recovered;
310
311 const auto operation_id = detail::unique_id();
312 const auto operation = root_ / "staging" / operation_id;
313 const auto final_path = root_ / payload_relative;
314 const auto record_path = root_ / record_relative;
315 std::error_code ec;
316 fs::create_directories(operation, ec);
317 if (ec) return failure(Error::io_error, "cannot stage removal: " + ec.message());
318 nlohmann::json journal{{"version", 1}, {"kind", "remove"}, {"phase", "prepared"},
319 {"operation", operation_id},
320 {"payload", payload_relative.generic_string()},
321 {"record", record_relative.generic_string()}};
322 std::string error;
323 if (!write_journal(journal, error)) return cleanup_failure(operation, error);
324 if (fs::exists(final_path, ec) && !detail::rename_durable(final_path, operation / "old-payload", error))
325 return rollback_failure(journal, error);
326 if (ec) return rollback_failure(journal, "cannot inspect installed payload: " + ec.message());
327 if (fs::exists(record_path, ec) && !detail::rename_durable(record_path, operation / "old-record.json", error))
328 return rollback_failure(journal, error);
329 if (ec) return rollback_failure(journal, "cannot inspect registry record: " + ec.message());
330 journal["phase"] = "committed";
331 if (!write_journal(journal, error)) return rollback_failure(journal, error);
332 return finish_committed(operation);
333 }
334
335private:
336 static Result success() { return {true, Error::none, {}}; }
337 static Result failure(Error code, std::string message) { return {false, code, std::move(message)}; }
338
339 fs::path journal_path() const { return root_ / "staging" / "transaction.json"; }
340
341 bool write_journal(const nlohmann::json& journal, std::string& error) const {
342 const auto temporary = root_ / "staging" / "transaction.new";
343 if (!detail::write_file(temporary, journal.dump() + "\n", error)) return false;
344#ifdef _WIN32
345 if (!MoveFileExW(temporary.wstring().c_str(), journal_path().wstring().c_str(),
346 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
347 error = "cannot activate transaction journal";
348 return false;
349 }
350#else
351 std::error_code ec;
352 fs::rename(temporary, journal_path(), ec);
353 if (ec) { error = "cannot activate transaction journal: " + ec.message(); return false; }
354 if (!detail::sync_file(journal_path().parent_path(), error)) return false;
355#endif
356 return true;
357 }
358
359 Result recover_locked() const {
360 std::error_code ec;
361 if (!fs::exists(journal_path(), ec)) return success();
362 nlohmann::json journal;
363 try {
364 std::ifstream input(journal_path(), std::ios::binary);
365 input >> journal;
366 } catch (...) {
367 return failure(Error::invalid_journal, "transaction journal is invalid; manual recovery is required");
368 }
369 if (!journal.is_object() || !journal.contains("version") || !journal["version"].is_number_integer() ||
370 journal["version"].get<int>() != 1 ||
371 !journal.contains("kind") || !journal["kind"].is_string() ||
372 !journal.contains("phase") || !journal["phase"].is_string() ||
373 !journal.contains("operation") || !journal["operation"].is_string() ||
374 !journal.contains("payload") || !journal["payload"].is_string() ||
375 !journal.contains("record") || !journal["record"].is_string()) {
376 return failure(Error::invalid_journal, "transaction journal is incomplete; manual recovery is required");
377 }
378 const std::string kind = journal["kind"].get<std::string>();
379 const std::string phase = journal["phase"].get<std::string>();
380 const bool valid_phase =
381 (kind == "install" && (phase == "prepared" || phase == "backed_up" ||
382 phase == "payload_active" || phase == "committed")) ||
383 (kind == "remove" && (phase == "prepared" || phase == "committed"));
384 if (!valid_phase) {
385 return failure(Error::invalid_journal, "transaction journal has an unsupported operation or phase");
386 }
387 const fs::path payload = journal["payload"].get<std::string>();
388 const fs::path record = journal["record"].get<std::string>();
389 const std::string operation_id = journal["operation"].get<std::string>();
390 if (!detail::allowed_pair(payload, record) || operation_id.size() != 32 ||
391 operation_id.find_first_not_of("0123456789abcdef") != std::string::npos) {
392 return failure(Error::invalid_journal, "transaction journal contains unsafe paths");
393 }
394 const auto operation = root_ / "staging" / operation_id;
395 if (phase == "committed") return finish_committed(operation);
396 return rollback(journal);
397 }
398
399 Result rollback(const nlohmann::json& journal) const {
400 const auto operation = root_ / "staging" / journal["operation"].get<std::string>();
401 const auto final_path = root_ / fs::path(journal["payload"].get<std::string>());
402 const auto record_path = root_ / fs::path(journal["record"].get<std::string>());
403 std::error_code ec;
404 if (journal.value("kind", "") == "install") {
405 if (!fs::exists(operation / "payload", ec)) fs::remove_all(final_path, ec);
406 ec.clear();
407 if (!fs::exists(operation / "record.json", ec)) fs::remove(record_path, ec);
408 }
409 ec.clear();
410 std::string error;
411 if (fs::exists(operation / "old-payload", ec) &&
412 !detail::rename_durable(operation / "old-payload", final_path, error))
413 return failure(Error::io_error, error);
414 if (ec) return failure(Error::io_error, "cannot inspect previous payload: " + ec.message());
415 if (fs::exists(operation / "old-record.json", ec) &&
416 !detail::rename_durable(operation / "old-record.json", record_path, error))
417 return failure(Error::io_error, error);
418 if (ec) return failure(Error::io_error, "cannot inspect previous record: " + ec.message());
419 fs::remove_all(operation, ec);
420 fs::remove(journal_path(), ec);
421 return success();
422 }
423
424 Result rollback_failure(const nlohmann::json& journal, const std::string& message) const {
425 const auto recovered = rollback(journal);
426 return failure(Error::io_error, recovered.ok ? message : message + "; " + recovered.message);
427 }
428
429 Result cleanup_failure(const fs::path& operation, const std::string& message) const {
430 std::error_code ec;
431 fs::remove_all(operation, ec);
432 fs::remove(journal_path(), ec);
433 return failure(Error::io_error, message);
434 }
435
436 Result finish_committed(const fs::path& operation) const {
437 std::error_code ec;
438 fs::remove_all(operation, ec);
439 if (ec) return failure(Error::io_error, "cannot clean committed transaction: " + ec.message());
440 fs::remove(journal_path(), ec);
441 if (ec) return failure(Error::io_error, "cannot remove transaction journal: " + ec.message());
442 return success();
443 }
444
445 fs::path root_;
446};
447
448} // namespace nah::store
449
450#endif // NAH_STORE_H
Result remove(const fs::path &payload_relative, const fs::path &record_relative) const
Definition nah_store.h:300
Result install(const fs::path &source, const fs::path &payload_relative, const fs::path &record_relative, const std::string &record_json, bool force=false) const
Definition nah_store.h:227
const fs::path & root() const
Definition nah_store.h:203
Result recover() const
Definition nah_store.h:219
Result initialize() const
Definition nah_store.h:205
Store(fs::path root)
Definition nah_store.h:201
Lock & operator=(const Lock &)=delete
Lock(const fs::path &path)
Definition nah_store.h:157
Lock(const Lock &)=delete
bool sync_tree(const fs::path &root, std::string &error)
Definition nah_store.h:108
bool safe_relative(const fs::path &path)
Definition nah_store.h:55
bool write_file(const fs::path &path, const std::string &value, std::string &error)
Definition nah_store.h:97
bool allowed_pair(const fs::path &payload, const fs::path &record)
Definition nah_store.h:63
bool sync_file(const fs::path &path, std::string &error)
Definition nah_store.h:73
std::string unique_id()
Definition nah_store.h:44
bool sync_directory(const fs::path &path, std::string &error)
Definition nah_store.h:129
bool rename_durable(const fs::path &from, const fs::path &to, std::string &error)
Definition nah_store.h:139
std::string message
Definition nah_store.h:39