3#include "absl/status/status.h"
5#if defined(YAZE_WITH_JSON)
18#include "absl/strings/ascii.h"
19#include "absl/strings/str_cat.h"
21#include "nlohmann/json.hpp"
28namespace fs = std::filesystem;
29using Json = nlohmann::json;
30constexpr char kKind[] =
"yaze.native_save.v1";
31constexpr char kContextKind[] =
"yaze.native_save_context.v1";
32constexpr uint64_t kMaxRomBytes = 64 * 1024 * 1024;
33constexpr uint64_t kMaxReceiptBytes = 1024 * 1024;
34constexpr size_t kMaxArtifacts = 256;
36bool IsIdentifier(std::string_view value,
bool role =
false) {
37 if (value.empty() || value.size() > 128 ||
38 (role && (value.front() <
'a' || value.front() >
'z'))) {
41 return std::all_of(value.begin(), value.end(), [role](
char c) {
42 return (c >=
'a' && c <=
'z') || (!role && c >=
'A' && c <=
'Z') ||
43 (c >=
'0' && c <=
'9') || c ==
'.' || c ==
'_' || c ==
':' ||
48bool IsSha256(std::string_view value) {
49 return value.size() == 64 &&
50 std::all_of(value.begin(), value.end(), [](
char c) {
51 return (c >=
'0' && c <=
'9') || (c >=
'a' && c <=
'f');
55bool IsOperationId(std::string_view value) {
56 return !value.empty() && value.size() <= 128 &&
57 std::all_of(value.begin(), value.end(),
58 [](
char c) { return c >= 0x20 && c <= 0x7E; });
61std::string PathText(
const fs::path& path) {
62 const auto bytes = path.generic_u8string();
63 return std::string(bytes.begin(), bytes.end());
66bool IsAbsolutePath(
const fs::path& path) {
67 return !path.empty() && path.is_absolute() &&
68 path.string().find(
'\0') == std::string::npos;
71bool AreBindingErrorsValid(
const std::vector<std::string>& errors) {
72 return errors.size() <= kMaxArtifacts &&
73 std::all_of(errors.begin(), errors.end(), [](
const auto& error) {
74 return !error.empty() && error.size() <= 1024 &&
75 error.find(
'\0') == std::string::npos;
79absl::StatusOr<fs::path> ResolveRegularFile(
const fs::path& path) {
80 std::error_code error;
81 const auto status = fs::symlink_status(path, error);
82 if (error || !fs::is_regular_file(status)) {
83 return absl::FailedPreconditionError(
84 absl::StrCat(
"Native save input must be a regular, non-symlink file: ",
87 auto resolved = fs::canonical(path, error);
89 return absl::FailedPreconditionError(
90 absl::StrCat(
"Cannot resolve native save input: ", path.string()));
95absl::StatusOr<uint64_t> FileSize(
const fs::path& path) {
96 std::error_code error;
97 const auto size = fs::file_size(path, error);
98 if (error || size > std::numeric_limits<uint64_t>::max()) {
99 return absl::FailedPreconditionError(
100 absl::StrCat(
"Cannot measure native save input: ", path.string()));
102 return static_cast<uint64_t
>(size);
108 if (size > maximum) {
109 return absl::InvalidArgumentError(
"Native save input exceeds size limit");
111 std::ifstream input(path, std::ios::binary);
112 if (!input.is_open()) {
113 return absl::FailedPreconditionError(
"Cannot read native save input");
116 std::array<char, 64 * 1024> buffer;
118 input.read(buffer.data(), buffer.size());
119 const auto count =
static_cast<uint64_t
>(input.gcount());
120 if (count > maximum - content.size()) {
121 return absl::InvalidArgumentError(
"Native save input exceeds size limit");
123 content.append(buffer.data(),
static_cast<size_t>(count));
125 if (!input.eof() || input.bad()) {
126 return absl::DataLossError(
"Failed while reading native save input");
131absl::StatusOr<std::optional<std::string>> ReadOptionalOutput(
132 const fs::path& path) {
133 std::error_code error;
134 const auto status = fs::symlink_status(path, error);
135 if (error == std::errc::no_such_file_or_directory ||
136 (!error && status.type() == fs::file_type::not_found)) {
141 return std::optional<std::string>(std::move(content));
144bool IsProducerArtifact(
const Json& value) {
145 return value.is_object() && value.size() == 4 &&
146 value.value(
"role",
Json()) ==
"producer" && value.contains(
"path") &&
147 value[
"path"].is_string() &&
148 IsAbsolutePath(fs::u8path(value[
"path"].get<std::string>())) &&
149 value.contains(
"sha256") && value[
"sha256"].is_string() &&
150 IsSha256(value[
"sha256"].get_ref<const std::string&>()) &&
151 value.contains(
"bytes") && value[
"bytes"].is_number_unsigned();
154absl::Status ValidateContext(
const NativeSaveContext& context) {
158 if (context.path != expected.path ||
159 context.editing_source_path != expected.editing_source_path ||
160 context.project_path != expected.project_path) {
161 return absl::InvalidArgumentError(
"Native save context is not current");
163 return absl::OkStatus();
166absl::Status ValidatePreviousContext(
const Json& value,
167 const NativeSaveContext& context) {
168 if (!value.is_object() || value.size() != 7 ||
169 value.value(
"kind",
Json()) != kContextKind ||
170 value.value(
"editing_source_path",
Json()) !=
171 context.editing_source_path ||
172 !value.contains(
"project_path") || !value[
"project_path"].is_string() ||
173 !value.contains(
"state") || !value[
"state"].is_string() ||
174 !value.contains(
"rom_path") || !value[
"rom_path"].is_string() ||
175 !IsAbsolutePath(fs::u8path(value[
"rom_path"].get<std::string>())) ||
176 !value.contains(
"producer") || !IsProducerArtifact(value[
"producer"]) ||
177 !value.contains(
"native_commit")) {
178 return absl::FailedPreconditionError(
"Invalid prior native save context");
180 const auto project_path = value[
"project_path"].get<std::string>();
181 if (!project_path.empty() && !IsAbsolutePath(fs::u8path(project_path))) {
182 return absl::FailedPreconditionError(
"Invalid native save context project");
184 if (value[
"state"] ==
"committed") {
185 const auto& commit = value[
"native_commit"];
186 if (!commit.is_object() || commit.size() != 3 ||
187 !commit.contains(
"receipt_path") ||
188 !commit[
"receipt_path"].is_string() ||
190 fs::u8path(commit[
"receipt_path"].get<std::string>())) ||
191 !commit.contains(
"generation") ||
192 !commit[
"generation"].is_number_unsigned() ||
193 commit[
"generation"].get<uint64_t>() == 0 ||
194 !commit.contains(
"operation_id") ||
195 !commit[
"operation_id"].is_string() ||
196 !IsOperationId(commit[
"operation_id"].get_ref<const std::string&>())) {
197 return absl::FailedPreconditionError(
198 "Invalid committed native save context");
200 }
else if (value[
"state"] !=
"rebound" || !value[
"native_commit"].is_null()) {
201 return absl::FailedPreconditionError(
"Invalid retired native save context");
203 return absl::OkStatus();
206absl::Status ValidatePreviousReceipt(
const Json& receipt,
207 const fs::path& receipt_path) {
208 if (!receipt.is_object() || receipt.size() != 8 ||
209 receipt.value(
"kind",
Json()) != kKind ||
210 !receipt.contains(
"generation") ||
211 !receipt[
"generation"].is_number_unsigned() ||
212 receipt[
"generation"].get<uint64_t>() == 0 ||
213 !receipt.contains(
"operation_id") ||
214 !receipt[
"operation_id"].is_string() ||
215 !IsOperationId(receipt[
"operation_id"].get_ref<const std::string&>()) ||
216 (receipt.value(
"operation",
Json()) !=
"save" &&
217 receipt.value(
"operation",
Json()) !=
"save_as") ||
218 receipt.value(
"receipt_path",
Json()) != PathText(receipt_path) ||
219 !receipt.contains(
"bindings_complete") ||
220 !receipt[
"bindings_complete"].is_boolean() ||
221 !receipt.contains(
"binding_errors") ||
222 !receipt[
"binding_errors"].is_array() || !receipt.contains(
"artifacts") ||
223 !receipt[
"artifacts"].is_array() ||
224 receipt[
"artifacts"].size() > kMaxArtifacts) {
225 return absl::FailedPreconditionError(
"Invalid prior native save receipt");
227 std::vector<std::string> errors;
228 for (
const auto& error : receipt[
"binding_errors"]) {
229 if (!error.is_string()) {
230 return absl::FailedPreconditionError(
"Invalid prior native save errors");
232 errors.push_back(error.get<std::string>());
234 if (!AreBindingErrorsValid(errors) ||
235 receipt[
"bindings_complete"] != errors.empty()) {
236 return absl::FailedPreconditionError(
"Invalid prior native save errors");
238 std::set<std::string> roles;
239 std::map<std::string, const Json*> paths;
240 for (
const auto& artifact : receipt[
"artifacts"]) {
241 if (!artifact.is_object() || artifact.size() != 4 ||
242 !artifact.contains(
"role") || !artifact[
"role"].is_string() ||
243 !artifact.contains(
"path") || !artifact[
"path"].is_string() ||
244 !artifact.contains(
"sha256") || !artifact[
"sha256"].is_string() ||
245 !artifact.contains(
"bytes") ||
246 !artifact[
"bytes"].is_number_unsigned()) {
247 return absl::FailedPreconditionError(
248 "Invalid prior native save artifact");
250 const auto& role = artifact[
"role"].get_ref<
const std::string&>();
251 const fs::path path = fs::u8path(artifact[
"path"].get<std::string>());
252 const auto& hash = artifact[
"sha256"].get_ref<
const std::string&>();
253 const auto [existing, inserted] =
254 paths.emplace(absl::AsciiStrToLower(path.generic_string()), &artifact);
255 const bool conflicting_path =
256 !inserted && ((*existing->second)[
"path"] != artifact[
"path"] ||
257 (*existing->second)[
"sha256"] != artifact[
"sha256"] ||
258 (*existing->second)[
"bytes"] != artifact[
"bytes"]);
259 if (!
IsIdentifier(role,
true) || !roles.insert(role).second ||
260 !IsAbsolutePath(path) || path != path.lexically_normal() ||
261 conflicting_path || !
IsSha256(hash)) {
262 return absl::FailedPreconditionError(
"Invalid prior native save binding");
265 if (!roles.contains(
"rom") || !roles.contains(
"producer")) {
266 return absl::FailedPreconditionError(
"Prior native save roles are missing");
268 return absl::OkStatus();
271struct ArtifactBinding {
272 NativeSaveArtifact artifact;
277absl::Status VerifyBinding(
const ArtifactBinding& binding) {
279 ResolveRegularFile(binding.artifact.path));
280 if (resolved != binding.resolved) {
281 return absl::AbortedError(
"Native save input path changed");
286 ResolveRegularFile(binding.artifact.path));
288 if (final_resolved != resolved || size != binding.bytes ||
289 final_size != binding.bytes || hash != binding.artifact.expected_sha256) {
290 return absl::AbortedError(
"Native save artifact changed after save");
292 return absl::OkStatus();
298 const std::string& editing_source_path,
const std::string& project_path) {
299 if (!IsAbsolutePath(fs::u8path(editing_source_path)) ||
300 (!project_path.empty() && !IsAbsolutePath(fs::u8path(project_path)))) {
301 return absl::InvalidArgumentError(
302 "Native save editing-source and project paths must be absolute");
304 std::error_code error;
306 fs::weakly_canonical(fs::u8path(editing_source_path), error);
308 return absl::FailedPreconditionError(
309 "Cannot resolve native editing source");
311 const auto origin_text = PathText(origin);
314 const auto directory = fs::absolute(app_data, error) /
"native-saves";
316 return absl::FailedPreconditionError(
317 "Cannot resolve native save directory");
319 fs::create_directories(directory, error);
320 if (error || !fs::is_directory(fs::symlink_status(directory, error))) {
321 return absl::FailedPreconditionError(
"Cannot create native save directory");
323 const auto path = fs::canonical(directory, error) /
326 return absl::FailedPreconditionError(
"Cannot resolve native save context");
328 std::string normalized_project;
329 if (!project_path.empty()) {
331 PathText(fs::weakly_canonical(fs::u8path(project_path), error));
333 return absl::FailedPreconditionError(
334 "Cannot resolve native save project");
337 return NativeSaveContext{path, origin_text, std::move(normalized_project)};
341 const std::string& state) {
342 if (state !=
"rebound" && state !=
"session_changed") {
343 return absl::InvalidArgumentError(
"Invalid native save retirement state");
347 if (!existing.has_value()) {
348 return absl::OkStatus();
353 if (!before.has_value()) {
354 return absl::AbortedError(
"Native save context disappeared");
357 ValidatePreviousContext(
Json::parse(*before,
nullptr,
false), context));
359 const Json retired = {{
"kind", kContextKind},
360 {
"editing_source_path", context.editing_source_path},
361 {
"state",
"rebound"},
362 {
"project_path", context.project_path},
363 {
"rom_path", previous[
"rom_path"]},
364 {
"producer", previous[
"producer"]},
365 {
"native_commit",
nullptr}};
368 text = retired.dump(2) +
"\n";
369 }
catch (
const Json::exception&) {
370 return absl::InvalidArgumentError(
371 "Native save context must be valid UTF-8");
374 *lock, {{context.path, before, text}},
377 if (resolved != context.path) {
378 return absl::AbortedError(
"Native save context path changed");
380 return absl::OkStatus();
385 const NativeSaveReceiptRequest& request) {
386 if (!IsAbsolutePath(request.receipt_path) ||
387 !IsOperationId(request.operation_id) ||
388 (request.operation !=
"save" && request.operation !=
"save_as") ||
389 request.artifacts.size() > kMaxArtifacts ||
390 !AreBindingErrorsValid(request.binding_errors)) {
391 return absl::InvalidArgumentError(
"Invalid native save receipt request");
393 std::set<std::string> roles;
394 std::vector<fs::path> inputs;
395 std::map<fs::path, std::string> input_hashes;
396 std::vector<ArtifactBinding> bindings;
397 for (
const auto& artifact : request.artifacts) {
399 !roles.insert(artifact.role).second || !IsAbsolutePath(artifact.path) ||
400 !
IsSha256(artifact.expected_sha256)) {
401 return absl::InvalidArgumentError(
"Invalid native save artifact binding");
405 if (artifact.role ==
"rom" && size > kMaxRomBytes) {
406 return absl::InvalidArgumentError(
"Native save ROM exceeds size limit");
408 const auto [existing, inserted] =
409 input_hashes.emplace(resolved, artifact.expected_sha256);
410 if (!inserted && existing->second != artifact.expected_sha256) {
411 return absl::InvalidArgumentError(
"Native save input hashes conflict");
414 inputs.push_back(resolved);
416 bindings.push_back({artifact, std::move(resolved), size});
418 if (!roles.contains(
"rom") || !roles.contains(
"producer")) {
419 return absl::InvalidArgumentError(
"Native save requires rom and producer");
421 std::vector<fs::path> outputs{request.receipt_path};
422 if (request.context.has_value()) {
424 outputs.push_back(request.context->path);
427 std::error_code error;
428 const auto receipt_path = fs::weakly_canonical(request.receipt_path, error);
430 return absl::FailedPreconditionError(
"Cannot resolve native save receipt");
435 outputs, {
"native save receipt",
"native save receipt"}, inputs));
436 const auto receipt_after_lock =
437 fs::weakly_canonical(request.receipt_path, error);
438 if (error || receipt_after_lock != receipt_path) {
439 return absl::AbortedError(
"Native save receipt path changed");
441 for (
const auto& binding : bindings) {
444 std::sort(bindings.begin(), bindings.end(),
445 [](
const auto& left,
const auto& right) {
446 return left.artifact.role < right.artifact.role;
449 const ArtifactBinding*
rom =
nullptr;
451 for (
const auto& binding : bindings) {
452 artifacts.push_back({{
"role", binding.artifact.role},
453 {
"path", PathText(binding.resolved)},
454 {
"sha256", binding.artifact.expected_sha256},
455 {
"bytes", binding.bytes}});
456 if (binding.artifact.role ==
"rom") {
458 }
else if (binding.artifact.role ==
"producer") {
459 producer = artifacts.back();
465 return absl::AbortedError(
"Native save ROM changed after save");
468 std::optional<std::string> before;
469 std::optional<std::string> context_before;
470 std::optional<Json> context_previous;
471 if (request.context.has_value()) {
472 ASSIGN_OR_RETURN(context_before, ReadOptionalOutput(request.context->path));
473 if (context_before.has_value()) {
474 context_previous =
Json::parse(*context_before,
nullptr,
false);
476 ValidatePreviousContext(*context_previous, *request.context));
479 uint64_t generation = 1;
480 Json receipt = {{
"kind", kKind},
481 {
"generation", generation},
482 {
"operation_id", request.operation_id},
483 {
"operation", request.operation},
484 {
"receipt_path", PathText(receipt_path)},
485 {
"bindings_complete", request.binding_errors.empty()},
486 {
"binding_errors", request.binding_errors},
487 {
"artifacts", std::move(artifacts)}};
489 const bool exists = fs::exists(receipt_path, error);
491 return absl::FailedPreconditionError(
"Cannot inspect native save receipt");
496 const auto previous =
Json::parse(*before,
nullptr,
false);
498 generation = previous[
"generation"].get<uint64_t>();
499 if (previous[
"operation_id"] == request.operation_id) {
501 if (previous[
"operation"] != request.operation ||
502 previous[
"artifacts"] != receipt[
"artifacts"] ||
503 previous[
"binding_errors"] != receipt[
"binding_errors"]) {
504 return absl::AbortedError(
"Native save operation_id was reused");
506 }
else if (generation == std::numeric_limits<uint64_t>::max()) {
507 return absl::OutOfRangeError(
"Native save generation overflow");
511 receipt[
"generation"] = generation;
515 text = receipt.dump(2) +
"\n";
516 }
catch (
const Json::exception&) {
517 return absl::InvalidArgumentError(
"Native save paths must be valid UTF-8");
519 if (text.size() > kMaxReceiptBytes) {
520 return absl::InvalidArgumentError(
"Native save receipt exceeds size limit");
523 if (before.has_value() &&
Json::parse(*before) == receipt) {
526 std::optional<std::string> context_text;
527 if (request.context.has_value()) {
528 const auto& context = *request.context;
529 const Json committed = {
530 {
"kind", kContextKind},
531 {
"editing_source_path", context.editing_source_path},
532 {
"state",
"committed"},
533 {
"project_path", context.project_path},
534 {
"rom_path", PathText(
rom->resolved)},
535 {
"producer", producer},
537 {{
"receipt_path", PathText(receipt_path)},
538 {
"generation", generation},
539 {
"operation_id", request.operation_id}}}};
541 (!context_previous.has_value() || *context_previous != committed)) {
542 return absl::AbortedError(
543 "Native save retry cannot reactivate a retired context");
546 context_text = committed.dump(2) +
"\n";
547 }
catch (
const Json::exception&) {
548 return absl::InvalidArgumentError(
549 "Native save context must be valid UTF-8");
552 context_text = context_before;
555 std::vector<SourceArtifactUpdate> updates;
556 updates.push_back({
rom->resolved, rom_bytes, std::move(rom_bytes)});
557 updates.push_back({receipt_path, std::move(before), text});
558 if (request.context.has_value()) {
559 updates.push_back({request.context->path, context_before, *context_text});
562 *lock, std::move(updates),
rom->artifact.expected_sha256, [&]() {
563 for (const auto& binding : bindings) {
564 RETURN_IF_ERROR(VerifyBinding(binding));
567 ResolveRegularFile(request.receipt_path));
568 if (resolved_receipt != receipt_path) {
569 return absl::AbortedError(
"Native save receipt path changed");
573 if (readback != text) {
574 return absl::DataLossError(
"Native save receipt readback changed");
576 if (request.context.has_value()) {
578 ResolveRegularFile(request.context->path));
579 if (resolved_context != request.context->path) {
580 return absl::AbortedError(
"Native save context path changed");
584 if (context_readback != *context_text) {
585 return absl::DataLossError(
"Native save context readback changed");
588 return absl::OkStatus();
590 return NativeSaveReceipt{generation, std::move(text)};
600 const std::string&) {
601 return absl::UnimplementedError(
"Native save contexts require JSON support");
605 const std::string&) {
606 return absl::UnimplementedError(
"Native save contexts require JSON support");
611 return absl::UnimplementedError(
"Native save receipts require JSON support");
static Json parse(const std::string &)
#define ASSIGN_OR_RETURN(type_variable_name, expression)
bool IsSha256(std::string_view text)
absl::Status PublishSourceArtifacts(const SourceArtifactPublicationLock &lock, std::vector< SourceArtifactUpdate > updates, std::string_view expected_primary_sha256, SourceArtifactReadbackValidator readback_validator)
absl::Status RetireNativeSaveContext(const NativeSaveContext &, const std::string &)
std::string ComputeSourceArtifactSha256(std::string_view content)
absl::StatusOr< std::string > ComputeSourceArtifactFileSha256(const fs::path &path)
absl::StatusOr< NativeSaveContext > GetNativeSaveContext(const std::string &, const std::string &)
absl::StatusOr< std::unique_ptr< SourceArtifactPublicationLock > > AcquireSourceArtifactPublicationLock(const std::vector< fs::path > &targets, const SourceArtifactPublisherLabels &labels, const std::vector< fs::path > &read_only_targets)
absl::StatusOr< NativeSaveReceipt > PublishNativeSaveReceipt(const NativeSaveReceiptRequest &)
Rom * rom()
Get the current ROM instance.
absl::StatusOr< std::string > ReadBoundedFile(const std::filesystem::path &path, size_t limit)
bool IsIdentifier(const std::string &value)
#define RETURN_IF_ERROR(expr)