yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
message_source_sync.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <array>
5#include <atomic>
6#include <cctype>
7#include <cerrno>
8#include <chrono>
9#include <cstdint>
10#include <cstring>
11#include <exception>
12#include <filesystem>
13#include <fstream>
14#include <iterator>
15#include <limits>
16#include <map>
17#include <memory>
18#include <mutex>
19#include <optional>
20#include <string>
21#include <string_view>
22#include <system_error>
23#include <utility>
24#include <vector>
25
26#include "absl/status/status.h"
27#include "absl/strings/ascii.h"
28#include "absl/strings/match.h"
29#include "absl/strings/str_cat.h"
30#include "absl/strings/str_format.h"
32#include "nlohmann/json.hpp"
33#include "rom/snes.h"
34#include "util/macro.h"
35
36#if defined(_WIN32)
37#ifndef NOMINMAX
38#define NOMINMAX
39#endif
40#include <windows.h>
41#elif !defined(__EMSCRIPTEN__)
42#include <fcntl.h>
43#include <sys/file.h>
44#include <sys/stat.h>
45#include <unistd.h>
46#endif
47
48namespace yaze::editor {
49namespace {
50
51namespace fs = std::filesystem;
52using Json = nlohmann::json;
53
55 std::string text;
56 std::vector<uint8_t> bytes;
57};
58
59using ExpandedSourceBank = std::map<int, ExpandedSourceMessage>;
60
61constexpr char kSourceSyncLockBasename[] = ".yaze-message-source-sync.lock";
62
64 fs::path target;
65 std::string content;
66 std::optional<std::string> original_content;
67 fs::path temp;
68 std::optional<fs::path> backup;
69 bool published = false;
70};
71
73 static std::mutex mutex;
74 return mutex;
75}
76
78 public:
79 static absl::StatusOr<std::unique_ptr<MessageSourceWriteLocks>> Acquire(
80 const std::vector<fs::path>& lock_paths) {
81 if (lock_paths.empty()) {
82 return absl::InvalidArgumentError(
83 "Message source publication requires at least one lock");
84 }
85 auto lock =
86 std::unique_ptr<MessageSourceWriteLocks>(new MessageSourceWriteLocks());
87 lock->process_lock_ =
88 std::unique_lock<std::mutex>(MessageSourceWriteMutex());
89
90#if defined(_WIN32)
91 lock->handles_.reserve(lock_paths.size());
92 struct WindowsFileIdentity {
93 DWORD volume_serial;
94 DWORD file_index_high;
95 DWORD file_index_low;
96 };
97 std::vector<WindowsFileIdentity> lock_identities;
98 lock_identities.reserve(lock_paths.size());
99 for (const fs::path& path : lock_paths) {
100 HANDLE handle = CreateFileW(
101 path.wstring().c_str(), GENERIC_READ | GENERIC_WRITE,
102 FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS,
103 FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
104 if (handle == INVALID_HANDLE_VALUE) {
105 const DWORD error = GetLastError();
106 return absl::PermissionDeniedError(absl::StrFormat(
107 "Cannot open message source lock %s: %s", path.string(),
108 std::error_code(static_cast<int>(error), std::system_category())
109 .message()));
110 }
111 BY_HANDLE_FILE_INFORMATION file_info = {};
112 if (!GetFileInformationByHandle(handle, &file_info)) {
113 const DWORD error = GetLastError();
114 CloseHandle(handle);
115 return absl::FailedPreconditionError(absl::StrFormat(
116 "Cannot inspect message source lock %s: %s", path.string(),
117 std::error_code(static_cast<int>(error), std::system_category())
118 .message()));
119 }
120 if ((file_info.dwFileAttributes &
121 (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY)) != 0) {
122 CloseHandle(handle);
123 return absl::FailedPreconditionError(absl::StrFormat(
124 "Message source lock must be a regular file: %s", path.string()));
125 }
126 if (file_info.nNumberOfLinks != 1) {
127 CloseHandle(handle);
128 return absl::FailedPreconditionError(absl::StrFormat(
129 "Message source lock must have exactly one hard link: %s",
130 path.string()));
131 }
132 const WindowsFileIdentity identity = {file_info.dwVolumeSerialNumber,
133 file_info.nFileIndexHigh,
134 file_info.nFileIndexLow};
135 const bool duplicate_identity = std::any_of(
136 lock_identities.begin(), lock_identities.end(),
137 [&](const WindowsFileIdentity& existing) {
138 return existing.volume_serial == identity.volume_serial &&
139 existing.file_index_high == identity.file_index_high &&
140 existing.file_index_low == identity.file_index_low;
141 });
142 if (duplicate_identity) {
143 CloseHandle(handle);
144 return absl::InvalidArgumentError(
145 absl::StrFormat("Message source lock aliases another lock path: %s",
146 path.string()));
147 }
148 OVERLAPPED overlapped = {};
149 if (!LockFileEx(handle, LOCKFILE_EXCLUSIVE_LOCK, 0, MAXDWORD, MAXDWORD,
150 &overlapped)) {
151 const DWORD error = GetLastError();
152 CloseHandle(handle);
153 return absl::UnavailableError(absl::StrFormat(
154 "Cannot acquire message source lock %s: %s", path.string(),
155 std::error_code(static_cast<int>(error), std::system_category())
156 .message()));
157 }
158 lock_identities.push_back(identity);
159 lock->handles_.push_back(handle);
160 }
161#elif defined(__EMSCRIPTEN__)
162 return absl::FailedPreconditionError(
163 "Durable message source locking is unavailable in browser builds");
164#else
165 lock->fds_.reserve(lock_paths.size());
166 struct PosixFileIdentity {
167 dev_t device;
168 ino_t inode;
169 };
170 std::vector<PosixFileIdentity> lock_identities;
171 lock_identities.reserve(lock_paths.size());
172 for (const fs::path& path : lock_paths) {
173 int open_flags = O_RDWR | O_CREAT;
174#ifdef O_CLOEXEC
175 open_flags |= O_CLOEXEC;
176#endif
177#ifdef O_NOFOLLOW
178 open_flags |= O_NOFOLLOW;
179#endif
180 const int fd = open(path.c_str(), open_flags, S_IRUSR | S_IWUSR);
181 if (fd < 0) {
182 return absl::PermissionDeniedError(absl::StrFormat(
183 "Cannot open message source lock %s: %s", path.string(),
184 std::error_code(errno, std::generic_category()).message()));
185 }
186 struct stat lock_stat{};
187 if (fstat(fd, &lock_stat) != 0) {
188 const int error = errno;
189 close(fd);
190 return absl::FailedPreconditionError(absl::StrFormat(
191 "Cannot inspect message source lock %s: %s", path.string(),
192 std::error_code(error, std::generic_category()).message()));
193 }
194 if (!S_ISREG(lock_stat.st_mode)) {
195 close(fd);
196 return absl::FailedPreconditionError(absl::StrFormat(
197 "Message source lock must be a regular file: %s", path.string()));
198 }
199 if (lock_stat.st_nlink != 1) {
200 close(fd);
201 return absl::FailedPreconditionError(absl::StrFormat(
202 "Message source lock must have exactly one hard link: %s",
203 path.string()));
204 }
205 const PosixFileIdentity identity = {lock_stat.st_dev, lock_stat.st_ino};
206 const bool duplicate_identity =
207 std::any_of(lock_identities.begin(), lock_identities.end(),
208 [&](const PosixFileIdentity& existing) {
209 return existing.device == identity.device &&
210 existing.inode == identity.inode;
211 });
212 if (duplicate_identity) {
213 close(fd);
214 return absl::InvalidArgumentError(
215 absl::StrFormat("Message source lock aliases another lock path: %s",
216 path.string()));
217 }
218 if (fchmod(fd, S_IRUSR | S_IWUSR) != 0) {
219 const int error = errno;
220 close(fd);
221 return absl::PermissionDeniedError(absl::StrFormat(
222 "Cannot secure message source lock %s: %s", path.string(),
223 std::error_code(error, std::generic_category()).message()));
224 }
225 while (flock(fd, LOCK_EX) != 0) {
226 if (errno == EINTR) {
227 continue;
228 }
229 const int error = errno;
230 close(fd);
231 return absl::UnavailableError(absl::StrFormat(
232 "Cannot acquire message source lock %s: %s", path.string(),
233 std::error_code(error, std::generic_category()).message()));
234 }
235 lock_identities.push_back(identity);
236 lock->fds_.push_back(fd);
237 }
238#endif
239 return lock;
240 }
241
243#if defined(_WIN32)
244 for (auto it = handles_.rbegin(); it != handles_.rend(); ++it) {
245 OVERLAPPED overlapped = {};
246 UnlockFileEx(*it, 0, MAXDWORD, MAXDWORD, &overlapped);
247 CloseHandle(*it);
248 }
249#elif !defined(__EMSCRIPTEN__)
250 for (auto it = fds_.rbegin(); it != fds_.rend(); ++it) {
251 while (flock(*it, LOCK_UN) != 0 && errno == EINTR) {}
252 close(*it);
253 }
254#endif
255 }
256
259
260 private:
262
263 std::unique_lock<std::mutex> process_lock_;
264#if defined(_WIN32)
265 std::vector<HANDLE> handles_;
266#elif !defined(__EMSCRIPTEN__)
267 std::vector<int> fds_;
268#endif
269};
270
271constexpr uint32_t kSha256Constants[64] = {
272 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
273 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
274 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
275 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
276 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
277 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
278 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
279 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
280 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
281 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
282 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
283
284constexpr uint32_t RotateRight(uint32_t value, uint32_t count) {
285 return (value >> count) | (value << (32 - count));
286}
287
288void TransformSha256(uint32_t state[8], const uint8_t block[64]) {
289 uint32_t schedule[64];
290 for (int index = 0; index < 16; ++index) {
291 schedule[index] = (static_cast<uint32_t>(block[index * 4]) << 24) |
292 (static_cast<uint32_t>(block[index * 4 + 1]) << 16) |
293 (static_cast<uint32_t>(block[index * 4 + 2]) << 8) |
294 static_cast<uint32_t>(block[index * 4 + 3]);
295 }
296 for (int index = 16; index < 64; ++index) {
297 const uint32_t low = RotateRight(schedule[index - 15], 7) ^
298 RotateRight(schedule[index - 15], 18) ^
299 (schedule[index - 15] >> 3);
300 const uint32_t high = RotateRight(schedule[index - 2], 17) ^
301 RotateRight(schedule[index - 2], 19) ^
302 (schedule[index - 2] >> 10);
303 schedule[index] = schedule[index - 16] + low + schedule[index - 7] + high;
304 }
305
306 uint32_t a = state[0];
307 uint32_t b = state[1];
308 uint32_t c = state[2];
309 uint32_t d = state[3];
310 uint32_t e = state[4];
311 uint32_t f = state[5];
312 uint32_t g = state[6];
313 uint32_t h = state[7];
314 for (int index = 0; index < 64; ++index) {
315 const uint32_t sigma1 =
316 RotateRight(e, 6) ^ RotateRight(e, 11) ^ RotateRight(e, 25);
317 const uint32_t choose = (e & f) ^ ((~e) & g);
318 const uint32_t temp1 =
319 h + sigma1 + choose + kSha256Constants[index] + schedule[index];
320 const uint32_t sigma0 =
321 RotateRight(a, 2) ^ RotateRight(a, 13) ^ RotateRight(a, 22);
322 const uint32_t majority = (a & b) ^ (a & c) ^ (b & c);
323 const uint32_t temp2 = sigma0 + majority;
324 h = g;
325 g = f;
326 f = e;
327 e = d + temp1;
328 d = c;
329 c = b;
330 b = a;
331 a = temp1 + temp2;
332 }
333
334 state[0] += a;
335 state[1] += b;
336 state[2] += c;
337 state[3] += d;
338 state[4] += e;
339 state[5] += f;
340 state[6] += g;
341 state[7] += h;
342}
343
344std::string Sha256Hex(std::string_view content) {
345 uint32_t state[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
346 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
347 const auto* cursor = reinterpret_cast<const uint8_t*>(content.data());
348 size_t remaining = content.size();
349 while (remaining >= 64) {
350 TransformSha256(state, cursor);
351 cursor += 64;
352 remaining -= 64;
353 }
354
355 uint8_t block[64] = {};
356 if (remaining > 0) {
357 std::memcpy(block, cursor, remaining);
358 }
359 block[remaining] = 0x80;
360 if (remaining >= 56) {
361 TransformSha256(state, block);
362 std::memset(block, 0, sizeof(block));
363 }
364 const uint64_t bit_length = static_cast<uint64_t>(content.size()) * 8;
365 for (int index = 0; index < 8; ++index) {
366 block[63 - index] =
367 static_cast<uint8_t>(bit_length >> (static_cast<uint64_t>(index) * 8));
368 }
369 TransformSha256(state, block);
370
371 std::string digest;
372 digest.reserve(64);
373 for (uint32_t value : state) {
374 absl::StrAppend(&digest, absl::StrFormat("%08x", value));
375 }
376 return digest;
377}
378
379absl::StatusOr<std::string> ReadTextFile(const fs::path& path,
380 absl::string_view label) {
381 std::ifstream input(path, std::ios::binary);
382 if (!input.is_open()) {
383 return absl::NotFoundError(
384 absl::StrFormat("Cannot open %s: %s", label, path.string()));
385 }
386 std::string content{std::istreambuf_iterator<char>(input),
387 std::istreambuf_iterator<char>()};
388 if (!input.good() && !input.eof()) {
389 return absl::DataLossError(
390 absl::StrFormat("Failed while reading %s: %s", label, path.string()));
391 }
392 return content;
393}
394
395bool ReadBoundedNonnegativeInteger(const Json& value, uint64_t maximum,
396 uint64_t* parsed) {
397 if (value.is_number_unsigned()) {
398 const uint64_t candidate = value.get<uint64_t>();
399 if (candidate > maximum) {
400 return false;
401 }
402 *parsed = candidate;
403 return true;
404 }
405 if (!value.is_number_integer()) {
406 return false;
407 }
408 const int64_t candidate = value.get<int64_t>();
409 if (candidate < 0 || static_cast<uint64_t>(candidate) > maximum) {
410 return false;
411 }
412 *parsed = static_cast<uint64_t>(candidate);
413 return true;
414}
415
416absl::StatusOr<Json> ParseBundleDocument(std::string_view content,
417 absl::string_view label) {
418 Json document;
419 try {
420 document = Json::parse(content);
421 } catch (const std::exception& error) {
422 return absl::InvalidArgumentError(
423 absl::StrFormat("%s is not valid JSON: %s", label, error.what()));
424 }
425 if (!document.is_object()) {
426 return absl::InvalidArgumentError(
427 absl::StrFormat("%s must be a JSON object", label));
428 }
429 if (!document.contains("format") || !document["format"].is_string() ||
430 document["format"].get<std::string>() != "yaze-message-bundle") {
431 return absl::InvalidArgumentError(
432 absl::StrFormat("%s format must be 'yaze-message-bundle'", label));
433 }
434 uint64_t version = 0;
435 if (!document.contains("version") ||
437 document["version"],
438 static_cast<uint64_t>(std::numeric_limits<int>::max()), &version) ||
439 version != static_cast<uint64_t>(kMessageBundleVersion)) {
440 return absl::InvalidArgumentError(absl::StrFormat(
441 "%s version must be integer %d", label, kMessageBundleVersion));
442 }
443 if (!document.contains("messages") || !document["messages"].is_array()) {
444 return absl::InvalidArgumentError(
445 absl::StrFormat("%s must contain a messages array", label));
446 }
447 if (document.contains("counts")) {
448 uint64_t expanded_count = 0;
449 uint64_t vanilla_count = 0;
450 if (!document["counts"].is_object() ||
451 !document["counts"].contains("expanded") ||
453 document["counts"]["expanded"],
454 static_cast<uint64_t>(std::numeric_limits<int>::max()),
455 &expanded_count) ||
456 !document["counts"].contains("vanilla") ||
458 document["counts"]["vanilla"],
459 static_cast<uint64_t>(std::numeric_limits<int>::max()),
460 &vanilla_count)) {
461 return absl::InvalidArgumentError(absl::StrFormat(
462 "%s counts must use bounded non-negative integers", label));
463 }
464 }
465 return document;
466}
467
468absl::StatusOr<std::string> SelectEntryText(const Json& entry, int id,
469 absl::string_view label) {
470 // `text` is the editable canonical field. Rich exports may retain stale
471 // raw/parsed diagnostics beside a deliberately changed text value.
472 for (absl::string_view key : {"text", "raw", "parsed"}) {
473 const std::string key_string(key);
474 if (!entry.contains(key_string)) {
475 continue;
476 }
477 if (!entry[key_string].is_string()) {
478 return absl::InvalidArgumentError(
479 absl::StrFormat("%s expanded message %d field '%s' must be a string",
480 label, id, key));
481 }
482 return entry[key_string].get<std::string>();
483 }
484 return absl::InvalidArgumentError(absl::StrFormat(
485 "%s expanded message %d has no raw, text, or parsed string", label, id));
486}
487
488std::string NormalizeLegacyDictionaryTokens(std::string text) {
489 size_t search_from = 0;
490 while (true) {
491 const size_t token_start = text.find("[D:", search_from);
492 if (token_start == std::string::npos) {
493 break;
494 }
495 size_t value_start = token_start + 3;
496 if (value_start < text.size() && text[value_start] == '$') {
497 ++value_start;
498 }
499 const size_t token_end = text.find(']', value_start);
500 if (token_end == std::string::npos) {
501 break;
502 }
503 const size_t digit_count = token_end - value_start;
504 const bool valid_digits =
505 digit_count >= 1 && digit_count <= 2 &&
506 std::all_of(text.begin() + static_cast<std::ptrdiff_t>(value_start),
507 text.begin() + static_cast<std::ptrdiff_t>(token_end),
508 [](unsigned char c) { return std::isxdigit(c) != 0; });
509 if (!valid_digits) {
510 search_from = token_end + 1;
511 continue;
512 }
513 const int dictionary_id =
514 std::stoi(text.substr(value_start, digit_count), nullptr, 16);
515 const std::string normalized = absl::StrFormat("[D:%02X]", dictionary_id);
516 text.replace(token_start, token_end - token_start + 1, normalized);
517 search_from = token_start + normalized.size();
518 }
519 return text;
520}
521
522absl::StatusOr<ExpandedSourceBank> ParseExpandedBank(const Json& document,
523 int expected_count,
524 bool require_complete,
525 absl::string_view label) {
527 for (const Json& entry : document["messages"]) {
528 if (!entry.is_object()) {
529 return absl::InvalidArgumentError(
530 absl::StrFormat("%s message entry must be an object", label));
531 }
532 uint64_t id_value = 0;
533 if (!entry.contains("id") ||
535 entry["id"], static_cast<uint64_t>(expected_count - 1),
536 &id_value)) {
537 return absl::InvalidArgumentError(
538 absl::StrFormat("%s message entry ID must be an integer in [0, %d)",
539 label, expected_count));
540 }
541 const int id = static_cast<int>(id_value);
542 if (!entry.contains("bank") || !entry["bank"].is_string() ||
543 entry["bank"].get<std::string>() != "expanded") {
544 return absl::InvalidArgumentError(absl::StrFormat(
545 "%s message %d must declare bank 'expanded'", label, id));
546 }
547 if (bank.contains(id)) {
548 return absl::InvalidArgumentError(absl::StrFormat(
549 "%s contains duplicate expanded message ID %d", label, id));
550 }
551
552 std::string text;
553 ASSIGN_OR_RETURN(text, SelectEntryText(entry, id, label));
554 text = NormalizeLegacyDictionaryTokens(std::move(text));
555 if (absl::StrContains(text, "[BANK]")) {
556 return absl::InvalidArgumentError(absl::StrFormat(
557 "%s expanded message %d contains [BANK], which is only valid in "
558 "the vanilla message stream",
559 label, id));
560 }
561 MessageParseResult parsed;
562 try {
564 } catch (const std::exception& error) {
565 return absl::InvalidArgumentError(
566 absl::StrFormat("%s expanded message %d cannot be parsed safely: %s",
567 label, id, error.what()));
568 } catch (...) {
569 return absl::InvalidArgumentError(absl::StrFormat(
570 "%s expanded message %d cannot be parsed safely", label, id));
571 }
572 if (!parsed.ok()) {
573 return absl::InvalidArgumentError(
574 absl::StrFormat("%s expanded message %d is invalid: %s", label, id,
575 parsed.errors.front()));
576 }
577 if (!parsed.warnings.empty()) {
578 return absl::InvalidArgumentError(
579 absl::StrFormat("%s expanded message %d is not source-stable: %s",
580 label, id, parsed.warnings.front()));
581 }
582 bank.emplace(id, ExpandedSourceMessage{
583 .text = std::move(text),
584 .bytes = std::move(parsed.bytes),
585 });
586 }
587
588 if (bank.empty()) {
589 return absl::InvalidArgumentError(
590 absl::StrFormat("%s has no expanded messages", label));
591 }
592 if (require_complete) {
593 if (bank.size() != static_cast<size_t>(expected_count)) {
594 return absl::FailedPreconditionError(absl::StrFormat(
595 "%s must contain the complete expanded bank: expected %d entries, "
596 "found %zu",
597 label, expected_count, bank.size()));
598 }
599 for (int id = 0; id < expected_count; ++id) {
600 if (!bank.contains(id)) {
601 return absl::FailedPreconditionError(absl::StrFormat(
602 "%s is missing bank-local expanded message ID %d", label, id));
603 }
604 }
605 uint64_t expanded_count = 0;
606 uint64_t vanilla_count = 0;
607 if (!document.contains("counts") || !document["counts"].is_object() ||
608 !document["counts"].contains("expanded") ||
610 document["counts"]["expanded"],
611 static_cast<uint64_t>(std::numeric_limits<int>::max()),
612 &expanded_count) ||
613 expanded_count != static_cast<uint64_t>(expected_count) ||
614 !document["counts"].contains("vanilla") ||
616 document["counts"]["vanilla"],
617 static_cast<uint64_t>(std::numeric_limits<int>::max()),
618 &vanilla_count) ||
619 vanilla_count != 0) {
620 return absl::FailedPreconditionError(
621 absl::StrFormat("%s counts must declare vanilla=0 and expanded=%d",
622 label, expected_count));
623 }
624 }
625 return bank;
626}
627
629 Json document;
630 document["format"] = "yaze-message-bundle";
631 document["version"] = kMessageBundleVersion;
632 document["counts"] = {{"vanilla", 0}, {"expanded", bank.size()}};
633 document["messages"] = Json::array();
634 for (const auto& [id, message] : bank) {
635 document["messages"].push_back(
636 {{"id", id}, {"bank", "expanded"}, {"text", message.text}});
637 }
638 return document.dump(2) + "\n";
639}
640
641std::string RenderAsmBody(const ExpandedSourceBank& bank,
642 int first_expanded_id) {
643 std::string output;
644 for (const auto& [local_id, message] : bank) {
645 absl::StrAppend(&output, absl::StrFormat("Message_%03X:\n",
646 first_expanded_id + local_id));
647 std::vector<uint8_t> terminated = message.bytes;
648 terminated.push_back(kMessageTerminator);
649 for (size_t offset = 0; offset < terminated.size(); offset += 16) {
650 absl::StrAppend(&output, " db ");
651 const size_t line_end = std::min(terminated.size(), offset + 16);
652 for (size_t index = offset; index < line_end; ++index) {
653 if (index != offset) {
654 absl::StrAppend(&output, ", ");
655 }
656 absl::StrAppend(&output, absl::StrFormat("$%02X", terminated[index]));
657 }
658 absl::StrAppend(&output, "\n");
659 }
660 absl::StrAppend(&output, "\n");
661 }
662 absl::StrAppend(&output, "db $FF\n");
663 return output;
664}
665
666std::string RenderAsmInclude(const ExpandedSourceBank& bank,
667 int first_expanded_id,
668 std::string_view source_sha256) {
669 const std::string body = RenderAsmBody(bank, first_expanded_id);
670 return absl::StrFormat(
671 "; Source bundle SHA-256: %s\n"
672 "; Generated ASM body SHA-256: %s\n"
673 "; Generated by yaze message-source-sync. Do not edit.\n\n",
674 std::string(source_sha256), Sha256Hex(body)) +
675 body;
676}
677
678bool IsCanonicalMappedLoRomAddress(uint32_t address) {
679 const uint8_t bank = static_cast<uint8_t>((address >> 16) & 0xFFu);
680 return bank != 0x7E && bank != 0x7F && (address & 0xFFFFu) >= 0x8000u &&
681 PcToSnes(SnesToPc(address)) == address;
682}
683
684absl::StatusOr<size_t> ValidateLayoutAndGetCapacity(
685 const core::MessageLayout& layout) {
686 if (layout.expanded_count <= 0 ||
687 layout.last_expanded_id < layout.first_expanded_id) {
688 return absl::FailedPreconditionError(
689 "Hack manifest must define a non-empty expanded message range");
690 }
691 const int range_count =
692 static_cast<int>(layout.last_expanded_id - layout.first_expanded_id) + 1;
693 if (layout.expanded_count != range_count) {
694 return absl::FailedPreconditionError(absl::StrFormat(
695 "Hack manifest expanded range is not contiguous: first=0x%03X, "
696 "last=0x%03X implies %d messages, but count=%d",
697 layout.first_expanded_id, layout.last_expanded_id, range_count,
698 layout.expanded_count));
699 }
702 return absl::FailedPreconditionError(
703 "Hack manifest expanded data range must use canonical mapped LoROM "
704 "addresses");
705 }
706 const uint32_t start_pc = SnesToPc(layout.data_start);
707 const uint32_t end_pc = SnesToPc(layout.data_end);
708 if (end_pc < start_pc) {
709 return absl::FailedPreconditionError(
710 "Hack manifest expanded data range ends before it starts");
711 }
712 return static_cast<size_t>(end_pc - start_pc) + 1;
713}
714
715bool IsWithinRoot(const fs::path& root, const fs::path& candidate) {
716 const fs::path relative = candidate.lexically_relative(root);
717 if (relative.empty() || relative.is_absolute()) {
718 return false;
719 }
720 const auto first = relative.begin();
721 return first != relative.end() && *first != "..";
722}
723
724absl::StatusOr<fs::path> ResolveProjectTarget(
725 const fs::path& project_root, const std::string& configured_path,
726 bool must_exist, absl::string_view label) {
727 const fs::path relative(configured_path);
728 if (relative.empty() || relative.is_absolute() || relative.has_root_name()) {
729 return absl::InvalidArgumentError(
730 absl::StrFormat("%s must be a non-empty project-relative path", label));
731 }
732
733 const fs::path joined = (project_root / relative).lexically_normal();
734 std::error_code status_ec;
735 const auto link_status = fs::symlink_status(joined, status_ec);
736 if (status_ec != std::errc::no_such_file_or_directory && status_ec) {
737 return absl::FailedPreconditionError(
738 absl::StrFormat("Cannot inspect %s path %s: %s", label, joined.string(),
739 status_ec.message()));
740 }
741 if (!status_ec && fs::is_symlink(link_status)) {
742 return absl::FailedPreconditionError(absl::StrFormat(
743 "%s may not be a symbolic link: %s", label, joined.string()));
744 }
745
746 std::error_code canonical_ec;
747 fs::path resolved = must_exist ? fs::canonical(joined, canonical_ec)
748 : fs::weakly_canonical(joined, canonical_ec);
749 if (canonical_ec) {
750 return absl::FailedPreconditionError(
751 absl::StrFormat("Cannot resolve %s path %s: %s", label, joined.string(),
752 canonical_ec.message()));
753 }
754 resolved = resolved.lexically_normal();
755 if (!IsWithinRoot(project_root, resolved)) {
756 return absl::PermissionDeniedError(
757 absl::StrFormat("%s escapes project root %s: %s", label,
758 project_root.string(), resolved.string()));
759 }
760
761 std::error_code parent_ec;
762 const auto parent_status = fs::status(resolved.parent_path(), parent_ec);
763 if (parent_ec || !fs::is_directory(parent_status)) {
764 return absl::FailedPreconditionError(
765 absl::StrFormat("%s parent directory must already exist: %s", label,
766 resolved.parent_path().string()));
767 }
768 if (must_exist) {
769 std::error_code file_ec;
770 if (!fs::is_regular_file(resolved, file_ec) || file_ec) {
771 return absl::FailedPreconditionError(absl::StrFormat(
772 "%s must be an existing regular file: %s", label, resolved.string()));
773 }
774 } else if (!status_ec && fs::exists(link_status) &&
775 !fs::is_regular_file(link_status)) {
776 return absl::FailedPreconditionError(
777 absl::StrFormat("%s must be a regular file or a new path: %s", label,
778 resolved.string()));
779 }
780 return resolved;
781}
782
783absl::StatusOr<fs::path> CanonicalProjectRoot(
784 const project::YazeProject& project) {
785 if (project.filepath.empty()) {
786 return absl::FailedPreconditionError(
787 "Project has no descriptor path; open the project before source sync");
788 }
789 std::error_code ec;
790 fs::path descriptor = fs::absolute(project.filepath, ec);
791 if (ec) {
792 return absl::FailedPreconditionError(
793 absl::StrFormat("Cannot resolve project descriptor %s: %s",
794 project.filepath, ec.message()));
795 }
796 fs::path root = fs::canonical(descriptor.parent_path(), ec);
797 if (ec) {
798 return absl::FailedPreconditionError(
799 absl::StrFormat("Cannot resolve project root for %s: %s",
800 project.filepath, ec.message()));
801 }
802 return root.lexically_normal();
803}
804
805std::string LowercasePath(const fs::path& path) {
806 return absl::AsciiStrToLower(path.generic_string());
807}
808
809absl::Status ValidateDistinctTargets(const fs::path& bundle_path,
810 const fs::path& include_path) {
811 if (bundle_path == include_path ||
812 LowercasePath(bundle_path) == LowercasePath(include_path)) {
813 return absl::InvalidArgumentError(
814 "Canonical bundle and generated ASM include paths must be distinct");
815 }
816 std::error_code exists_ec;
817 const bool include_exists = fs::exists(include_path, exists_ec);
818 if (exists_ec) {
819 return absl::FailedPreconditionError(absl::StrFormat(
820 "Cannot inspect generated ASM include path: %s", exists_ec.message()));
821 }
822 if (include_exists) {
823 std::error_code equivalent_ec;
824 if (fs::equivalent(bundle_path, include_path, equivalent_ec)) {
825 return absl::InvalidArgumentError(
826 "Canonical bundle and generated ASM include paths alias each other");
827 }
828 if (equivalent_ec) {
829 return absl::FailedPreconditionError(
830 absl::StrFormat("Cannot compare source publication paths: %s",
831 equivalent_ec.message()));
832 }
833 }
834 return absl::OkStatus();
835}
836
837absl::StatusOr<std::vector<fs::path>> PublicationLockPaths(
838 const fs::path& bundle_path, const fs::path& include_path) {
839 std::vector<fs::path> lock_paths;
840 for (const fs::path* target : {&bundle_path, &include_path}) {
841 std::error_code canonical_ec;
842 const fs::path parent = fs::canonical(target->parent_path(), canonical_ec);
843 if (canonical_ec) {
844 return absl::FailedPreconditionError(absl::StrFormat(
845 "Cannot resolve message source target directory %s: %s",
846 target->parent_path().string(), canonical_ec.message()));
847 }
848
849 bool duplicate = false;
850 for (const fs::path& existing_lock : lock_paths) {
851 std::error_code equivalent_ec;
852 const bool equivalent =
853 fs::equivalent(parent, existing_lock.parent_path(), equivalent_ec);
854 if (equivalent_ec) {
855 return absl::FailedPreconditionError(absl::StrFormat(
856 "Cannot compare message source target directories %s and %s: %s",
857 parent.string(), existing_lock.parent_path().string(),
858 equivalent_ec.message()));
859 }
860 if (equivalent) {
861 duplicate = true;
862 break;
863 }
864 }
865 if (!duplicate) {
866 lock_paths.push_back(parent / kSourceSyncLockBasename);
867 }
868 }
869
870 std::sort(lock_paths.begin(), lock_paths.end(),
871 [](const fs::path& left, const fs::path& right) {
872 const std::string left_lower = LowercasePath(left);
873 const std::string right_lower = LowercasePath(right);
874 return left_lower == right_lower
875 ? left.generic_string() < right.generic_string()
876 : left_lower < right_lower;
877 });
878 return lock_paths;
879}
880
882 const std::vector<fs::path>& lock_paths, const fs::path& bundle_path,
883 const fs::path& include_path) {
884 for (const fs::path& lock_path : lock_paths) {
885 for (const fs::path* target : {&bundle_path, &include_path}) {
886 if (*target == lock_path ||
887 LowercasePath(*target) == LowercasePath(lock_path)) {
888 return absl::InvalidArgumentError(absl::StrFormat(
889 "Message source targets may not use a persistent lock path: %s",
890 lock_path.string()));
891 }
892 }
893
894 std::error_code lock_exists_ec;
895 const bool lock_exists = fs::exists(lock_path, lock_exists_ec);
896 if (lock_exists_ec) {
897 return absl::FailedPreconditionError(absl::StrFormat(
898 "Cannot inspect persistent message source lock %s: %s",
899 lock_path.string(), lock_exists_ec.message()));
900 }
901 if (!lock_exists) {
902 continue;
903 }
904
905 for (const fs::path* target : {&bundle_path, &include_path}) {
906 std::error_code target_exists_ec;
907 const bool target_exists = fs::exists(*target, target_exists_ec);
908 if (target_exists_ec) {
909 return absl::FailedPreconditionError(
910 absl::StrFormat("Cannot inspect message source target %s: %s",
911 target->string(), target_exists_ec.message()));
912 }
913 if (!target_exists) {
914 continue;
915 }
916 std::error_code equivalent_ec;
917 const bool equivalent = fs::equivalent(lock_path, *target, equivalent_ec);
918 if (equivalent_ec) {
919 return absl::FailedPreconditionError(absl::StrFormat(
920 "Cannot compare message source target %s with persistent lock %s: "
921 "%s",
922 target->string(), lock_path.string(), equivalent_ec.message()));
923 }
924 if (equivalent) {
925 return absl::InvalidArgumentError(absl::StrFormat(
926 "Message source target aliases a persistent lock path: %s",
927 target->string()));
928 }
929 }
930 }
931 return absl::OkStatus();
932}
933
934fs::path NextSiblingPath(const fs::path& target, absl::string_view purpose) {
935 static std::atomic<uint64_t> sequence{0};
936 const uint64_t tick = static_cast<uint64_t>(
937 std::chrono::steady_clock::now().time_since_epoch().count());
938 const uint64_t id = sequence.fetch_add(1, std::memory_order_relaxed);
939 fs::path name = target.filename();
940 name += absl::StrFormat(".yaze-%s-%016x-%016x", purpose, tick, id);
941 return target.parent_path() / name;
942}
943
944absl::StatusOr<fs::path> WriteExclusiveTemp(const fs::path& target,
945 std::string_view content) {
946 constexpr int kMaxAttempts = 100;
947#if !defined(_WIN32) && !defined(__EMSCRIPTEN__)
948 mode_t create_mode =
949 S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH;
950 bool preserve_mode = false;
951 struct stat target_stat{};
952 if (stat(target.c_str(), &target_stat) == 0) {
953 if (!S_ISREG(target_stat.st_mode)) {
954 return absl::FailedPreconditionError(absl::StrFormat(
955 "Publication target must be a regular file: %s", target.string()));
956 }
957 create_mode = target_stat.st_mode & 07777;
958 preserve_mode = true;
959 } else if (errno != ENOENT) {
960 return absl::FailedPreconditionError(absl::StrFormat(
961 "Cannot inspect publication target mode %s: %s", target.string(),
962 std::error_code(errno, std::generic_category()).message()));
963 }
964#endif
965 for (int attempt = 0; attempt < kMaxAttempts; ++attempt) {
966 const fs::path temp = NextSiblingPath(target, "tmp");
967#if defined(_WIN32)
968 HANDLE file = CreateFileW(
969 temp.wstring().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW,
970 FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH, nullptr);
971 if (file == INVALID_HANDLE_VALUE) {
972 const DWORD error = GetLastError();
973 if (error == ERROR_FILE_EXISTS || error == ERROR_ALREADY_EXISTS) {
974 continue;
975 }
976 return absl::PermissionDeniedError(absl::StrFormat(
977 "Cannot create temporary file for %s: %s", target.string(),
978 std::error_code(static_cast<int>(error), std::system_category())
979 .message()));
980 }
981 size_t written_total = 0;
982 while (written_total < content.size()) {
983 const DWORD chunk = static_cast<DWORD>(std::min<size_t>(
984 content.size() - written_total, std::numeric_limits<DWORD>::max()));
985 DWORD written = 0;
986 if (!WriteFile(file, content.data() + written_total, chunk, &written,
987 nullptr) ||
988 written == 0) {
989 const DWORD error = GetLastError();
990 CloseHandle(file);
991 std::error_code cleanup_ec;
992 fs::remove(temp, cleanup_ec);
993 return absl::InternalError(absl::StrFormat(
994 "Failed to write temporary file for %s: %s", target.string(),
995 std::error_code(static_cast<int>(error), std::system_category())
996 .message()));
997 }
998 written_total += written;
999 }
1000 if (!FlushFileBuffers(file)) {
1001 const DWORD error = GetLastError();
1002 CloseHandle(file);
1003 std::error_code cleanup_ec;
1004 fs::remove(temp, cleanup_ec);
1005 return absl::InternalError(absl::StrFormat(
1006 "Failed to flush temporary file for %s: %s", target.string(),
1007 std::error_code(static_cast<int>(error), std::system_category())
1008 .message()));
1009 }
1010 if (!CloseHandle(file)) {
1011 const DWORD error = GetLastError();
1012 std::error_code cleanup_ec;
1013 fs::remove(temp, cleanup_ec);
1014 return absl::InternalError(absl::StrFormat(
1015 "Failed to flush temporary file for %s: %s", target.string(),
1016 std::error_code(static_cast<int>(error), std::system_category())
1017 .message()));
1018 }
1019#elif defined(__EMSCRIPTEN__)
1020 std::ofstream output(temp, std::ios::binary | std::ios::trunc);
1021 if (!output.is_open()) {
1022 return absl::PermissionDeniedError(absl::StrFormat(
1023 "Cannot create temporary file for %s", target.string()));
1024 }
1025 output.write(content.data(), static_cast<std::streamsize>(content.size()));
1026 if (!output.good()) {
1027 output.close();
1028 std::error_code cleanup_ec;
1029 fs::remove(temp, cleanup_ec);
1030 return absl::InternalError(absl::StrFormat(
1031 "Failed to write temporary file for %s", target.string()));
1032 }
1033 output.close();
1034#else
1035 const int fd = open(temp.c_str(), O_WRONLY | O_CREAT | O_EXCL, create_mode);
1036 if (fd < 0) {
1037 if (errno == EEXIST) {
1038 continue;
1039 }
1040 return absl::PermissionDeniedError(absl::StrFormat(
1041 "Cannot create temporary file for %s: %s", target.string(),
1042 std::error_code(errno, std::generic_category()).message()));
1043 }
1044 if (preserve_mode && fchmod(fd, create_mode) != 0) {
1045 const int error = errno;
1046 close(fd);
1047 std::error_code cleanup_ec;
1048 fs::remove(temp, cleanup_ec);
1049 return absl::InternalError(absl::StrFormat(
1050 "Cannot preserve publication target mode for %s: %s", target.string(),
1051 std::error_code(error, std::generic_category()).message()));
1052 }
1053 size_t written_total = 0;
1054 while (written_total < content.size()) {
1055 const ssize_t written =
1056 write(fd, content.data() + written_total,
1057 std::min<size_t>(content.size() - written_total, 1024 * 1024));
1058 if (written < 0 && errno == EINTR) {
1059 continue;
1060 }
1061 if (written <= 0) {
1062 const int error = written < 0 ? errno : EIO;
1063 close(fd);
1064 std::error_code cleanup_ec;
1065 fs::remove(temp, cleanup_ec);
1066 return absl::InternalError(absl::StrFormat(
1067 "Failed to write temporary file for %s: %s", target.string(),
1068 std::error_code(error, std::generic_category()).message()));
1069 }
1070 written_total += static_cast<size_t>(written);
1071 }
1072 if (fsync(fd) != 0) {
1073 const int error = errno;
1074 close(fd);
1075 std::error_code cleanup_ec;
1076 fs::remove(temp, cleanup_ec);
1077 return absl::InternalError(absl::StrFormat(
1078 "Failed to flush temporary file for %s: %s", target.string(),
1079 std::error_code(error, std::generic_category()).message()));
1080 }
1081 if (close(fd) != 0) {
1082 const int error = errno;
1083 std::error_code cleanup_ec;
1084 fs::remove(temp, cleanup_ec);
1085 return absl::InternalError(absl::StrFormat(
1086 "Failed to flush temporary file for %s: %s", target.string(),
1087 std::error_code(error, std::generic_category()).message()));
1088 }
1089#endif
1090 return temp;
1091 }
1092 return absl::ResourceExhaustedError(absl::StrFormat(
1093 "Could not allocate a unique temporary file for %s", target.string()));
1094}
1095
1096absl::Status SyncParentDirectory(const fs::path& target) {
1097#if defined(_WIN32) || defined(__EMSCRIPTEN__)
1098 return absl::OkStatus();
1099#else
1100 int open_flags = O_RDONLY;
1101#ifdef O_CLOEXEC
1102 open_flags |= O_CLOEXEC;
1103#endif
1104#ifdef O_DIRECTORY
1105 open_flags |= O_DIRECTORY;
1106#endif
1107 const fs::path parent = target.parent_path();
1108 const int fd = open(parent.c_str(), open_flags);
1109 if (fd < 0) {
1110 return absl::InternalError(absl::StrFormat(
1111 "Cannot open parent directory for durable publication %s: %s",
1112 parent.string(),
1113 std::error_code(errno, std::generic_category()).message()));
1114 }
1115 int sync_result = 0;
1116 do {
1117 sync_result = fsync(fd);
1118 } while (sync_result != 0 && errno == EINTR);
1119 const int sync_error = sync_result == 0 ? 0 : errno;
1120 const int close_result = close(fd);
1121 const int close_error = close_result == 0 ? 0 : errno;
1122 if (sync_error != 0) {
1123 return absl::InternalError(absl::StrFormat(
1124 "Cannot sync parent directory after publishing %s: %s", target.string(),
1125 std::error_code(sync_error, std::generic_category()).message()));
1126 }
1127 if (close_result != 0) {
1128 return absl::InternalError(absl::StrFormat(
1129 "Cannot close parent directory after publishing %s: %s",
1130 target.string(),
1131 std::error_code(close_error, std::generic_category()).message()));
1132 }
1133 return absl::OkStatus();
1134#endif
1135}
1136
1137absl::Status ReplaceFromTemp(const fs::path& temp, const fs::path& target,
1138 bool* replaced = nullptr) {
1139 if (replaced != nullptr) {
1140 *replaced = false;
1141 }
1142#if defined(_WIN32)
1143 if (!MoveFileExW(temp.wstring().c_str(), target.wstring().c_str(),
1144 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
1145 const DWORD error = GetLastError();
1146 return absl::InternalError(absl::StrFormat(
1147 "Failed to publish %s: %s", target.string(),
1148 std::error_code(static_cast<int>(error), std::system_category())
1149 .message()));
1150 }
1151 if (replaced != nullptr) {
1152 *replaced = true;
1153 }
1154#else
1155 std::error_code rename_ec;
1156 fs::rename(temp, target, rename_ec);
1157 if (rename_ec) {
1158 return absl::InternalError(absl::StrFormat(
1159 "Failed to publish %s: %s", target.string(), rename_ec.message()));
1160 }
1161 if (replaced != nullptr) {
1162 *replaced = true;
1163 }
1164#endif
1165 return SyncParentDirectory(target);
1166}
1167
1168absl::StatusOr<fs::path> CreateBackup(const fs::path& target) {
1169 constexpr int kMaxAttempts = 100;
1170 for (int attempt = 0; attempt < kMaxAttempts; ++attempt) {
1171 const fs::path backup = NextSiblingPath(target, "backup");
1172 std::error_code copy_ec;
1173 fs::copy_file(target, backup, fs::copy_options::none, copy_ec);
1174 if (!copy_ec) {
1175 return backup;
1176 }
1177 if (copy_ec != std::errc::file_exists) {
1178 std::error_code cleanup_ec;
1179 fs::remove(backup, cleanup_ec);
1180 if (cleanup_ec) {
1181 return absl::DataLossError(absl::StrFormat(
1182 "Cannot back up %s (%s) or remove incomplete backup %s (%s)",
1183 target.string(), copy_ec.message(), backup.string(),
1184 cleanup_ec.message()));
1185 }
1186 return absl::FailedPreconditionError(absl::StrFormat(
1187 "Cannot back up %s: %s", target.string(), copy_ec.message()));
1188 }
1189 }
1190 return absl::ResourceExhaustedError(absl::StrFormat(
1191 "Could not allocate a backup path for %s", target.string()));
1192}
1193
1194absl::Status CleanupPublicationPaths(const std::vector<fs::path>& paths,
1195 absl::string_view artifact_kind) {
1196 std::string failures;
1197 for (const fs::path& path : paths) {
1198 if (path.empty()) {
1199 continue;
1200 }
1201 std::error_code ec;
1202 fs::remove(path, ec);
1203 if (!ec) {
1204 continue;
1205 }
1206 if (!failures.empty()) {
1207 absl::StrAppend(&failures, "; ");
1208 }
1209 absl::StrAppend(&failures, path.string(), " (", ec.message(), ")");
1210 }
1211 if (!failures.empty()) {
1212 return absl::InternalError(absl::StrFormat(
1213 "Could not remove message source %s: %s", artifact_kind, failures));
1214 }
1215 return absl::OkStatus();
1216}
1217
1218absl::Status CleanupTemps(const std::vector<PublicationArtifact>& artifacts) {
1219 std::vector<fs::path> paths;
1220 paths.reserve(artifacts.size());
1221 for (const auto& artifact : artifacts) {
1222 if (!artifact.temp.empty()) {
1223 paths.push_back(artifact.temp);
1224 }
1225 }
1226 return CleanupPublicationPaths(paths, "temporary files");
1227}
1228
1229absl::Status CleanupBackups(std::vector<PublicationArtifact>* artifacts) {
1230 std::string failures;
1231 for (auto& artifact : *artifacts) {
1232 if (!artifact.backup.has_value()) {
1233 continue;
1234 }
1235 std::error_code ec;
1236 fs::remove(*artifact.backup, ec);
1237 if (!ec) {
1238 artifact.backup.reset();
1239 continue;
1240 }
1241 if (!failures.empty()) {
1242 absl::StrAppend(&failures, "; ");
1243 }
1244 absl::StrAppend(&failures, artifact.backup->string(), " (", ec.message(),
1245 ")");
1246 }
1247 if (!failures.empty()) {
1248 return absl::InternalError(absl::StrFormat(
1249 "Could not remove message source backup files: %s", failures));
1250 }
1251 return absl::OkStatus();
1252}
1253
1255 const std::vector<PublicationArtifact>& artifacts) {
1256 std::string paths;
1257 for (const auto& artifact : artifacts) {
1258 if (!artifact.backup.has_value()) {
1259 continue;
1260 }
1261 if (!paths.empty()) {
1262 absl::StrAppend(&paths, ", ");
1263 }
1264 absl::StrAppend(&paths, artifact.backup->string());
1265 }
1266 return paths.empty() ? "<none>" : paths;
1267}
1268
1269std::string CleanupStatusSummary(const absl::Status& status) {
1270 return status.ok() ? "ok" : std::string(status.message());
1271}
1272
1274 std::vector<PublicationArtifact>* artifacts,
1275 const absl::Status& publication_failure) {
1276 const absl::Status temp_cleanup = CleanupTemps(*artifacts);
1277 const absl::Status backup_cleanup = CleanupBackups(artifacts);
1278 if (temp_cleanup.ok() && backup_cleanup.ok()) {
1279 return publication_failure;
1280 }
1281 return absl::DataLossError(absl::StrFormat(
1282 "Message source publication failed (%s), then artifact cleanup failed "
1283 "(temporary files: %s; backup files: %s)",
1284 publication_failure.message(), CleanupStatusSummary(temp_cleanup),
1285 CleanupStatusSummary(backup_cleanup)));
1286}
1287
1288absl::Status RestoreArtifact(const PublicationArtifact& artifact) {
1289 if (artifact.original_content.has_value()) {
1290 fs::path restore_temp;
1292 restore_temp,
1293 WriteExclusiveTemp(artifact.target, *artifact.original_content));
1294 const absl::Status status = ReplaceFromTemp(restore_temp, artifact.target);
1295 if (!status.ok()) {
1296 std::error_code cleanup_ec;
1297 fs::remove(restore_temp, cleanup_ec);
1298 if (cleanup_ec) {
1299 return absl::DataLossError(absl::StrFormat(
1300 "Could not restore %s (%s) or remove restore file %s (%s)",
1301 artifact.target.string(), status.message(), restore_temp.string(),
1302 cleanup_ec.message()));
1303 }
1304 }
1305 return status;
1306 }
1307 std::error_code remove_ec;
1308 const bool removed = fs::remove(artifact.target, remove_ec);
1309 if (remove_ec || !removed) {
1310 return absl::InternalError(absl::StrFormat(
1311 "Could not remove newly published file %s during rollback: %s",
1312 artifact.target.string(),
1313 remove_ec ? remove_ec.message() : "file was missing"));
1314 }
1315 return SyncParentDirectory(artifact.target);
1316}
1317
1318absl::Status RollBackPublication(std::vector<PublicationArtifact>* artifacts,
1319 const absl::Status& publication_failure) {
1320 absl::Status rollback_failure = absl::OkStatus();
1321 for (auto it = artifacts->rbegin(); it != artifacts->rend(); ++it) {
1322 if (!it->published) {
1323 continue;
1324 }
1325 const absl::Status restore_status = RestoreArtifact(*it);
1326 if (!restore_status.ok() && rollback_failure.ok()) {
1327 rollback_failure = restore_status;
1328 }
1329 it->published = false;
1330 }
1331 const absl::Status temp_cleanup = CleanupTemps(*artifacts);
1332 if (!rollback_failure.ok()) {
1333 return absl::DataLossError(absl::StrFormat(
1334 "Message source publication failed (%s) and rollback failed (%s). "
1335 "Temporary-file cleanup: %s. Preserved recovery backups: %s",
1336 publication_failure.message(), rollback_failure.message(),
1337 CleanupStatusSummary(temp_cleanup), BackupRecoveryPaths(*artifacts)));
1338 }
1339 const absl::Status backup_cleanup = CleanupBackups(artifacts);
1340 if (!temp_cleanup.ok() || !backup_cleanup.ok()) {
1341 return absl::DataLossError(absl::StrFormat(
1342 "Message source publication failed (%s); rollback completed, but "
1343 "artifact cleanup failed (temporary files: %s; backup files: %s)",
1344 publication_failure.message(), CleanupStatusSummary(temp_cleanup),
1345 CleanupStatusSummary(backup_cleanup)));
1346 }
1347 return publication_failure;
1348}
1349
1351 const std::vector<PublicationArtifact>& artifacts,
1352 std::string_view expected_source_sha256) {
1353 for (const auto& artifact : artifacts) {
1354 std::error_code exists_ec;
1355 const bool exists = fs::exists(artifact.target, exists_ec);
1356 if (exists_ec) {
1357 return absl::FailedPreconditionError(
1358 absl::StrFormat("Cannot recheck publication target %s: %s",
1359 artifact.target.string(), exists_ec.message()));
1360 }
1361 if (artifact.original_content.has_value()) {
1362 if (!exists) {
1363 return absl::AbortedError(absl::StrFormat(
1364 "Publication target disappeared after preflight: %s",
1365 artifact.target.string()));
1366 }
1367 std::string current;
1368 ASSIGN_OR_RETURN(current,
1369 ReadTextFile(artifact.target, "publication target"));
1370 if (current != *artifact.original_content) {
1371 return absl::AbortedError(
1372 absl::StrFormat("Publication target changed after preflight: %s",
1373 artifact.target.string()));
1374 }
1375 } else if (exists) {
1376 return absl::AbortedError(
1377 absl::StrFormat("Publication target appeared after preflight: %s",
1378 artifact.target.string()));
1379 }
1380 }
1381 if (Sha256Hex(*artifacts.front().original_content) !=
1382 expected_source_sha256) {
1383 return absl::AbortedError(
1384 "Canonical message source changed after SHA-256 preflight");
1385 }
1386 return absl::OkStatus();
1387}
1388
1389absl::Status PublishArtifactSet(std::vector<PublicationArtifact>* artifacts,
1390 std::string_view expected_source_sha256) {
1391 for (auto& artifact : *artifacts) {
1392 auto temp_or = WriteExclusiveTemp(artifact.target, artifact.content);
1393 if (!temp_or.ok()) {
1394 return ReturnFailureAfterCleanup(artifacts, temp_or.status());
1395 }
1396 artifact.temp = std::move(*temp_or);
1397 if (artifact.original_content.has_value()) {
1398 auto backup_or = CreateBackup(artifact.target);
1399 if (!backup_or.ok()) {
1400 return ReturnFailureAfterCleanup(artifacts, backup_or.status());
1401 }
1402 artifact.backup = std::move(*backup_or);
1403 }
1404 }
1405
1406 const absl::Status unchanged_status =
1407 VerifyUnchangedBeforePublication(*artifacts, expected_source_sha256);
1408 if (!unchanged_status.ok()) {
1409 return ReturnFailureAfterCleanup(artifacts, unchanged_status);
1410 }
1411
1412 for (auto& artifact : *artifacts) {
1413 bool replaced = false;
1414 const absl::Status replace_status =
1415 ReplaceFromTemp(artifact.temp, artifact.target, &replaced);
1416 artifact.published = replaced;
1417 if (!replace_status.ok()) {
1418 return RollBackPublication(artifacts, replace_status);
1419 }
1420 artifact.temp.clear();
1421 }
1422
1423 for (const auto& artifact : *artifacts) {
1424 std::string reopened;
1425 auto reopened_or = ReadTextFile(artifact.target, "published message file");
1426 if (!reopened_or.ok()) {
1427 return RollBackPublication(artifacts, reopened_or.status());
1428 }
1429 reopened = std::move(*reopened_or);
1430 if (reopened != artifact.content) {
1431 return RollBackPublication(
1432 artifacts, absl::DataLossError(absl::StrFormat(
1433 "Published message file failed exact readback: %s",
1434 artifact.target.string())));
1435 }
1436 }
1437 const absl::Status temp_cleanup = CleanupTemps(*artifacts);
1438 if (!temp_cleanup.ok()) {
1439 return RollBackPublication(artifacts, temp_cleanup);
1440 }
1441 return absl::OkStatus();
1442}
1443
1444absl::StatusOr<std::string> NormalizeExpectedSha256(std::string hash) {
1445 if (hash.size() != 64 ||
1446 !std::all_of(hash.begin(), hash.end(),
1447 [](unsigned char c) { return std::isxdigit(c) != 0; })) {
1448 return absl::InvalidArgumentError(
1449 "--expected-source-sha256 must be exactly 64 hexadecimal characters");
1450 }
1451 return absl::AsciiStrToLower(std::move(hash));
1452}
1453
1454} // namespace
1455
1456std::string ComputeMessageSourceSha256(std::string_view content) {
1457 return Sha256Hex(content);
1458}
1459
1460absl::StatusOr<MessageSourceSyncResult> SyncMessageSource(
1461 const project::YazeProject& project, const fs::path& incoming_bundle_path,
1462 const MessageSourceSyncOptions& options) {
1463 if (!project.hack_manifest.loaded()) {
1464 return absl::FailedPreconditionError(
1465 "Project must load a hack manifest before message source sync");
1466 }
1467#if defined(__EMSCRIPTEN__)
1468 if (options.write) {
1469 return absl::FailedPreconditionError(
1470 "message-source-sync --write is unavailable in browser builds because "
1471 "durable atomic filesystem publication cannot be guaranteed");
1472 }
1473#endif
1474 const core::MessageLayout& layout = project.hack_manifest.message_layout();
1475 if (!layout.source.has_value()) {
1476 return absl::FailedPreconditionError(
1477 "Hack manifest does not define messages.source");
1478 }
1479
1480 size_t capacity = 0;
1481 ASSIGN_OR_RETURN(capacity, ValidateLayoutAndGetCapacity(layout));
1482 fs::path project_root;
1483 ASSIGN_OR_RETURN(project_root, CanonicalProjectRoot(project));
1484
1485 fs::path canonical_bundle_path;
1487 canonical_bundle_path,
1488 ResolveProjectTarget(project_root, layout.source->canonical_bundle_path,
1489 /*must_exist=*/true, "Canonical message bundle"));
1490 fs::path asm_include_path;
1491 ASSIGN_OR_RETURN(asm_include_path,
1492 ResolveProjectTarget(
1493 project_root, layout.source->generated_asm_include_path,
1494 /*must_exist=*/false, "Generated message ASM include"));
1496 ValidateDistinctTargets(canonical_bundle_path, asm_include_path));
1497 std::vector<fs::path> publication_lock_paths;
1499 publication_lock_paths,
1500 PublicationLockPaths(canonical_bundle_path, asm_include_path));
1501 RETURN_IF_ERROR(ValidateTargetsDoNotAliasLocks(
1502 publication_lock_paths, canonical_bundle_path, asm_include_path));
1503
1504 std::error_code incoming_ec;
1505 const fs::path resolved_incoming =
1506 fs::canonical(incoming_bundle_path, incoming_ec);
1507 if (incoming_ec || !fs::is_regular_file(resolved_incoming, incoming_ec) ||
1508 incoming_ec) {
1509 return absl::NotFoundError(absl::StrFormat(
1510 "Incoming message bundle must be an existing regular file: %s",
1511 incoming_bundle_path.string()));
1512 }
1513
1514 std::string expected_sha;
1515 std::unique_ptr<MessageSourceWriteLocks> write_locks;
1516 if (options.write) {
1517 if (options.expected_source_sha256.empty()) {
1518 return absl::InvalidArgumentError(
1519 "--write requires --expected-source-sha256");
1520 }
1521 ASSIGN_OR_RETURN(expected_sha,
1522 NormalizeExpectedSha256(options.expected_source_sha256));
1523 ASSIGN_OR_RETURN(write_locks,
1524 MessageSourceWriteLocks::Acquire(publication_lock_paths));
1525 }
1526
1527 std::string canonical_before;
1528 ASSIGN_OR_RETURN(canonical_before, ReadTextFile(canonical_bundle_path,
1529 "canonical message bundle"));
1530 const std::string source_sha_before = Sha256Hex(canonical_before);
1531 Json canonical_document;
1533 canonical_document,
1534 ParseBundleDocument(canonical_before, "Canonical message bundle"));
1535 ExpandedSourceBank merged_bank;
1537 merged_bank,
1538 ParseExpandedBank(canonical_document, layout.expanded_count,
1539 /*require_complete=*/true, "Canonical message bundle"));
1540 const std::string expected_current_asm = RenderAsmInclude(
1541 merged_bank, layout.first_expanded_id, source_sha_before);
1542
1543 std::string incoming_content;
1544 ASSIGN_OR_RETURN(incoming_content,
1545 ReadTextFile(resolved_incoming, "incoming message bundle"));
1546 Json incoming_document;
1548 incoming_document,
1549 ParseBundleDocument(incoming_content, "Incoming message bundle"));
1550 ExpandedSourceBank incoming_bank;
1552 incoming_bank,
1553 ParseExpandedBank(incoming_document, layout.expanded_count,
1554 /*require_complete=*/false, "Incoming message bundle"));
1555 for (auto& [id, message] : incoming_bank) {
1556 merged_bank[id] = std::move(message);
1557 }
1558
1559 size_t encoded_size = 1; // Final $FF.
1560 for (const auto& entry : merged_bank) {
1561 encoded_size += entry.second.bytes.size() + 1; // Per-message $7F.
1562 }
1563 if (encoded_size > capacity) {
1564 return absl::ResourceExhaustedError(absl::StrFormat(
1565 "Expanded message source needs %zu bytes including terminators, but "
1566 "manifest capacity is %zu bytes [0x%06X, 0x%06X]",
1567 encoded_size, capacity, layout.data_start, layout.data_end));
1568 }
1569
1570 const std::string canonical_after = SerializeCanonicalBundle(merged_bank);
1571 const std::string proposed_source_sha = Sha256Hex(canonical_after);
1572 const std::string asm_after = RenderAsmInclude(
1573 merged_bank, layout.first_expanded_id, proposed_source_sha);
1574
1575 std::optional<std::string> asm_before;
1576 std::error_code include_exists_ec;
1577 if (fs::exists(asm_include_path, include_exists_ec)) {
1578 std::string existing_include;
1580 existing_include,
1581 ReadTextFile(asm_include_path, "generated message ASM include"));
1582 asm_before = std::move(existing_include);
1583 } else if (include_exists_ec) {
1584 return absl::FailedPreconditionError(
1585 absl::StrFormat("Cannot inspect generated message ASM include: %s",
1586 include_exists_ec.message()));
1587 }
1588 if (asm_before.has_value() && *asm_before != expected_current_asm) {
1589 return absl::FailedPreconditionError(absl::StrFormat(
1590 "Generated message ASM include drifted from the current canonical "
1591 "bundle: %s",
1592 asm_include_path.string()));
1593 }
1594
1596 .canonical_bundle_path = canonical_bundle_path,
1597 .generated_asm_include_path = asm_include_path,
1598 .first_expanded_id = layout.first_expanded_id,
1599 .last_expanded_id = layout.last_expanded_id,
1600 .expanded_count = layout.expanded_count,
1601 .incoming_updates = static_cast<int>(incoming_bank.size()),
1602 .encoded_size = encoded_size,
1603 .capacity = capacity,
1604 .source_sha256_before = source_sha_before,
1605 .proposed_source_sha256 = proposed_source_sha,
1606 .changed = canonical_before != canonical_after ||
1607 !asm_before.has_value() || *asm_before != asm_after,
1608 };
1609 if (!options.write) {
1610 return result;
1611 }
1612 if (expected_sha != source_sha_before) {
1613 return absl::AbortedError(absl::StrFormat(
1614 "Canonical message source SHA-256 CAS failed: expected %s, got %s",
1615 expected_sha, source_sha_before));
1616 }
1617 if (!result.changed) {
1618 return result;
1619 }
1620
1621 std::vector<PublicationArtifact> artifacts;
1622 artifacts.push_back(PublicationArtifact{
1623 .target = canonical_bundle_path,
1624 .content = canonical_after,
1625 .original_content = canonical_before,
1626 });
1627 artifacts.push_back(PublicationArtifact{
1628 .target = asm_include_path,
1629 .content = asm_after,
1630 .original_content = asm_before,
1631 });
1632 RETURN_IF_ERROR(PublishArtifactSet(&artifacts, expected_sha));
1633
1634 // Reopen the canonical bundle through the strict source parser, not just as
1635 // bytes, before reporting a successful two-file publication.
1636 auto reopened_canonical_or =
1637 ReadTextFile(canonical_bundle_path, "published canonical message bundle");
1638 if (!reopened_canonical_or.ok()) {
1639 return RollBackPublication(&artifacts, reopened_canonical_or.status());
1640 }
1641 std::string reopened_canonical = std::move(*reopened_canonical_or);
1642 auto reopened_document_or = ParseBundleDocument(
1643 reopened_canonical, "Published canonical message bundle");
1644 if (!reopened_document_or.ok()) {
1645 return RollBackPublication(&artifacts, reopened_document_or.status());
1646 }
1647 Json reopened_document = std::move(*reopened_document_or);
1648 auto reopened_bank_or = ParseExpandedBank(
1649 reopened_document, layout.expanded_count,
1650 /*require_complete=*/true, "Published canonical message bundle");
1651 if (!reopened_bank_or.ok()) {
1652 return RollBackPublication(&artifacts, reopened_bank_or.status());
1653 }
1654 if (Sha256Hex(reopened_canonical) != proposed_source_sha) {
1655 return RollBackPublication(
1656 &artifacts,
1657 absl::DataLossError(
1658 "Published canonical message bundle SHA-256 readback failed"));
1659 }
1660
1661 const absl::Status backup_cleanup = CleanupBackups(&artifacts);
1662 if (!backup_cleanup.ok()) {
1663 return RollBackPublication(&artifacts, backup_cleanup);
1664 }
1665 result.wrote = true;
1666 return result;
1667}
1668
1669} // namespace yaze::editor
static Json parse(const std::string &)
Definition json.h:36
static Json array()
Definition json.h:35
const MessageLayout & message_layout() const
bool loaded() const
Check if the manifest has been loaded.
MessageSourceWriteLocks & operator=(const MessageSourceWriteLocks &)=delete
static absl::StatusOr< std::unique_ptr< MessageSourceWriteLocks > > Acquire(const std::vector< fs::path > &lock_paths)
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
fs::path NextSiblingPath(const fs::path &target, absl::string_view purpose)
std::map< int, ExpandedSourceMessage > ExpandedSourceBank
absl::Status ValidateDistinctTargets(const fs::path &bundle_path, const fs::path &include_path)
absl::StatusOr< fs::path > CanonicalProjectRoot(const project::YazeProject &project)
absl::StatusOr< Json > ParseBundleDocument(std::string_view content, absl::string_view label)
absl::Status CleanupPublicationPaths(const std::vector< fs::path > &paths, absl::string_view artifact_kind)
bool IsWithinRoot(const fs::path &root, const fs::path &candidate)
absl::Status RollBackPublication(std::vector< PublicationArtifact > *artifacts, const absl::Status &publication_failure)
bool ReadBoundedNonnegativeInteger(const Json &value, uint64_t maximum, uint64_t *parsed)
absl::StatusOr< fs::path > WriteExclusiveTemp(const fs::path &target, std::string_view content)
constexpr uint32_t RotateRight(uint32_t value, uint32_t count)
std::string BackupRecoveryPaths(const std::vector< PublicationArtifact > &artifacts)
absl::StatusOr< std::string > NormalizeExpectedSha256(std::string hash)
absl::StatusOr< std::string > ReadTextFile(const fs::path &path, absl::string_view label)
absl::Status ValidateTargetsDoNotAliasLocks(const std::vector< fs::path > &lock_paths, const fs::path &bundle_path, const fs::path &include_path)
absl::StatusOr< ExpandedSourceBank > ParseExpandedBank(const Json &document, int expected_count, bool require_complete, absl::string_view label)
absl::StatusOr< std::string > SelectEntryText(const Json &entry, int id, absl::string_view label)
absl::StatusOr< size_t > ValidateLayoutAndGetCapacity(const core::MessageLayout &layout)
absl::StatusOr< fs::path > CreateBackup(const fs::path &target)
absl::StatusOr< std::vector< fs::path > > PublicationLockPaths(const fs::path &bundle_path, const fs::path &include_path)
absl::Status RestoreArtifact(const PublicationArtifact &artifact)
std::string SerializeCanonicalBundle(const ExpandedSourceBank &bank)
void TransformSha256(uint32_t state[8], const uint8_t block[64])
absl::Status CleanupBackups(std::vector< PublicationArtifact > *artifacts)
std::string RenderAsmBody(const ExpandedSourceBank &bank, int first_expanded_id)
absl::Status CleanupTemps(const std::vector< PublicationArtifact > &artifacts)
std::string CleanupStatusSummary(const absl::Status &status)
absl::Status ReplaceFromTemp(const fs::path &temp, const fs::path &target, bool *replaced=nullptr)
absl::Status VerifyUnchangedBeforePublication(const std::vector< PublicationArtifact > &artifacts, std::string_view expected_source_sha256)
std::string RenderAsmInclude(const ExpandedSourceBank &bank, int first_expanded_id, std::string_view source_sha256)
absl::Status PublishArtifactSet(std::vector< PublicationArtifact > *artifacts, std::string_view expected_source_sha256)
absl::Status ReturnFailureAfterCleanup(std::vector< PublicationArtifact > *artifacts, const absl::Status &publication_failure)
absl::StatusOr< fs::path > ResolveProjectTarget(const fs::path &project_root, const std::string &configured_path, bool must_exist, absl::string_view label)
Editors are the view controllers for the application.
constexpr int kMessageBundleVersion
std::string ComputeMessageSourceSha256(std::string_view content)
constexpr uint8_t kMessageTerminator
MessageParseResult ParseMessageToDataWithDiagnostics(std::string_view str)
absl::StatusOr< MessageSourceSyncResult > SyncMessageSource(const project::YazeProject &project, const fs::path &incoming_bundle_path, const MessageSourceSyncOptions &options)
uint32_t PcToSnes(uint32_t addr)
Definition snes.h:17
uint32_t SnesToPc(uint32_t addr) noexcept
Definition snes.h:8
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
Message range information for the expanded message system.
std::optional< Source > source
std::vector< uint8_t > bytes
std::vector< std::string > errors
std::vector< std::string > warnings
Modern project structure with comprehensive settings consolidation.
Definition project.h:172
core::HackManifest hack_manifest
Definition project.h:212