yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
project_bundle_verify_commands.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <cctype>
5#include <filesystem>
6#include <fstream>
7#include <ios>
8#include <string>
9#include <utility>
10#include <vector>
11
12#include "absl/status/status.h"
13#include "absl/strings/str_format.h"
14#include "core/project.h"
15#include "nlohmann/json.hpp"
16#include "rom/rom.h"
17#include "util/rom_hash.h"
18
19namespace yaze::cli::handlers {
20
21namespace fs = std::filesystem;
22
23namespace {
24
25std::string Trim(std::string value) {
26 auto is_space = [](unsigned char ch) {
27 return std::isspace(ch);
28 };
29 while (!value.empty() && is_space(value.front())) {
30 value.erase(value.begin());
31 }
32 while (!value.empty() && is_space(value.back())) {
33 value.pop_back();
34 }
35 return value;
36}
37
38// Normalize a hex hash string: trim surrounding whitespace, lowercase.
39std::string NormalizeHash(std::string hash) {
40 hash = Trim(std::move(hash));
41 std::transform(hash.begin(), hash.end(), hash.begin(), [](unsigned char ch) {
42 return static_cast<char>(std::tolower(ch));
43 });
44 return hash;
45}
46
47bool IsHexHash(const std::string& hash) {
48 return !hash.empty() &&
49 std::all_of(hash.begin(), hash.end(),
50 [](unsigned char ch) { return std::isxdigit(ch); });
51}
52
54 std::string name;
55 std::string status; // "pass" | "warn" | "fail"
56 std::string detail;
57};
58
60 bool available = false;
61 std::string expected;
62 std::string source;
63 std::string error;
64};
65
66BundleHashMetadata ParseBundleHashMetadata(const nlohmann::json& manifest) {
67 BundleHashMetadata metadata;
68 if (!manifest.is_object()) {
69 metadata.error = "manifest.json root must be an object";
70 return metadata;
71 }
72
73 // A recognized field whose string value is empty after trimming carries no
74 // digest, so it is treated as absent instead of malformed. That lets a
75 // manifest written with a placeholder key fall back to the sibling field, or
76 // to the "no hash available" warning, without reporting a false failure.
77 auto read_field = [&](const char* field, std::string* value) {
78 if (!manifest.contains(field)) {
79 return true;
80 }
81 const auto& json_value = manifest.at(field);
82 if (!json_value.is_string()) {
83 metadata.error = absl::StrFormat(
84 "manifest.json %s must be a string containing a 40-character SHA1",
85 field);
86 return false;
87 }
88 const std::string normalized = NormalizeHash(json_value.get<std::string>());
89 if (normalized.empty()) {
90 return true;
91 }
92 if (normalized.size() != 40 || !IsHexHash(normalized)) {
93 metadata.error = absl::StrFormat(
94 "manifest.json %s must be 40 hexadecimal characters", field);
95 return false;
96 }
97 *value = normalized;
98 return true;
99 };
100
101 std::string rom_checksum;
102 std::string rom_sha1;
103 if (!read_field("romChecksum", &rom_checksum)) {
104 return metadata;
105 }
106 if (!read_field("rom_sha1", &rom_sha1)) {
107 return metadata;
108 }
109
110 const bool has_rom_checksum = !rom_checksum.empty();
111 const bool has_rom_sha1 = !rom_sha1.empty();
112 metadata.available = has_rom_checksum || has_rom_sha1;
113 if (!metadata.available) {
114 return metadata;
115 }
116 if (has_rom_checksum && has_rom_sha1 && rom_checksum != rom_sha1) {
117 metadata.error = "manifest.json romChecksum and rom_sha1 fields disagree";
118 return metadata;
119 }
120
121 metadata.expected = has_rom_checksum ? rom_checksum : rom_sha1;
122 metadata.source = has_rom_checksum && has_rom_sha1
123 ? "romChecksum/rom_sha1"
124 : (has_rom_checksum ? "romChecksum" : "rom_sha1");
125 return metadata;
126}
127
128bool IsAbsoluteConfigPath(const std::string& value) {
129 if (value.empty()) {
130 return false;
131 }
132 // std::filesystem only recognizes the host platform's absolute-path syntax.
133 // Detect POSIX and Windows rooted paths explicitly so projects authored on
134 // another platform are still audited correctly.
135 if (value.front() == '/' || value.front() == '\\') {
136 return true;
137 }
138 if (fs::path(value).is_absolute()) {
139 return true;
140 }
141 return value.size() >= 3 &&
142 std::isalpha(static_cast<unsigned char>(value[0])) &&
143 value[1] == ':' && (value[2] == '/' || value[2] == '\\');
144}
145
146std::string StripMatchingQuotes(std::string value) {
147 value = Trim(std::move(value));
148 if (value.size() >= 2 && ((value.front() == '"' && value.back() == '"') ||
149 (value.front() == '\'' && value.back() == '\''))) {
150 return value.substr(1, value.size() - 2);
151 }
152 return value;
153}
154
155std::vector<std::string> ConfigPathValues(const std::string& key,
156 const std::string& raw_value) {
157 if (key != "additional_roms") {
158 return {StripMatchingQuotes(raw_value)};
159 }
160
161 std::vector<std::string> values;
162 size_t begin = 0;
163 while (begin <= raw_value.size()) {
164 const size_t comma = raw_value.find(',', begin);
165 const size_t count =
166 comma == std::string::npos ? std::string::npos : comma - begin;
167 std::string value = StripMatchingQuotes(raw_value.substr(begin, count));
168 if (!value.empty()) {
169 values.push_back(std::move(value));
170 }
171 if (comma == std::string::npos) {
172 break;
173 }
174 begin = comma + 1;
175 }
176 return values;
177}
178
179std::vector<std::string> ListAbsolutePathsInProjectFile(
180 const fs::path& project_file) {
181 std::vector<std::string> results;
182 std::ifstream input(project_file);
183 if (!input.is_open()) {
184 return results;
185 }
186
187 bool in_files_section = false;
188 std::string line;
189 while (std::getline(input, line)) {
190 line = Trim(line);
191 if (line.empty() || line[0] == '#' || line[0] == ';') {
192 continue;
193 }
194 if (line.front() == '[' && line.back() == ']') {
195 in_files_section = line == "[files]";
196 continue;
197 }
198 if (!in_files_section) {
199 continue;
200 }
201
202 const size_t equals = line.find('=');
203 if (equals == std::string::npos) {
204 continue;
205 }
206 const std::string key = Trim(line.substr(0, equals));
207 const std::string raw_value = line.substr(equals + 1);
208 for (const auto& value : ConfigPathValues(key, raw_value)) {
209 if (IsAbsoluteConfigPath(value)) {
210 results.push_back(absl::StrFormat("%s = %s", key, value));
211 }
212 }
213 }
214 return results;
215}
216
217} // namespace
218
221 Descriptor desc;
222 desc.display_name = "Project Bundle Verify";
223 desc.summary =
224 "Verify the structural integrity and portability of a .yaze project "
225 "file or .yazeproj bundle directory. Checks path existence, config "
226 "parsing, reference sanity, and ROM accessibility.";
227 desc.todo_reference = "todo#project-bundle-infra";
228 desc.entries = {
229 {"--project",
230 "Path to .yaze project file or .yazeproj bundle directory (required)",
231 ""},
232 {"--check-rom-hash",
233 "Verify bundle manifest romChecksum/rom_sha1 or standalone "
234 "expected_hash (CRC32/SHA1)",
235 ""},
236 {"--report", "Write full JSON summary to this path in addition to stdout",
237 ""},
238 };
239 return desc;
240}
241
243 const resources::ArgumentParser& parser) {
244 auto project_path = parser.GetString("project");
245 if (!project_path.has_value() || project_path->empty()) {
246 return absl::InvalidArgumentError(
247 "project-bundle-verify: --project is required");
248 }
249
250 // Probe --report path writability.
251 if (auto rp = parser.GetString("report"); rp.has_value() && !rp->empty()) {
252 const fs::path rp_path(*rp);
253 std::error_code ec;
254 const bool existed_before = fs::exists(rp_path, ec);
255 std::ofstream probe(*rp, std::ios::out | std::ios::binary | std::ios::app);
256 if (!probe.is_open()) {
257 return absl::PermissionDeniedError(absl::StrFormat(
258 "project-bundle-verify: cannot open report file for writing: %s",
259 *rp));
260 }
261 probe.close();
262 if (!existed_before) {
263 fs::remove(rp_path, ec);
264 }
265 }
266 return absl::OkStatus();
267}
268
270 Rom* /*rom*/, const resources::ArgumentParser& parser,
271 resources::OutputFormatter& formatter) {
272 const std::string project_path = *parser.GetString("project");
273 std::vector<CheckResult> checks;
274 bool any_fail = false;
275
276 // ------------------------------------------------------------------
277 // Check 1: Path exists
278 // ------------------------------------------------------------------
279 {
280 std::error_code ec;
281 bool exists = fs::exists(project_path, ec);
282 if (!exists || ec) {
283 checks.push_back(
284 {"path_exists", "fail",
285 absl::StrFormat("Path does not exist: %s", project_path)});
286 any_fail = true;
287 } else {
288 checks.push_back({"path_exists", "pass", project_path});
289 }
290 }
291
292 // ------------------------------------------------------------------
293 // Check 2: Recognized format
294 // ------------------------------------------------------------------
295 std::string resolved_path = project_path;
296 bool is_bundle = false;
297 {
298 fs::path fsp(project_path);
299 std::string ext = fsp.extension().string();
300
301 // Try bundle root resolution (handles paths inside bundles too)
302 std::string bundle_root =
304 if (!bundle_root.empty()) {
305 resolved_path = bundle_root;
306 is_bundle = true;
307 } else if (ext == ".yazeproj") {
308 // Accept .yazeproj by extension (directory may or may not exist yet)
309 resolved_path = project_path;
310 is_bundle = true;
311 } else if (ext != ".yaze" && ext != ".zsproj") {
312 checks.push_back(
313 {"format_recognized", "fail",
314 absl::StrFormat("Unrecognized project format: %s", ext)});
315 any_fail = true;
316 }
317
318 if (!any_fail) {
319 checks.push_back(
320 {"format_recognized", "pass",
321 is_bundle ? "yazeproj bundle" : absl::StrFormat("file (%s)", ext)});
322 }
323 }
324
325 // ------------------------------------------------------------------
326 // Check 3: Bundle structure (yazeproj only)
327 // ------------------------------------------------------------------
328 if (is_bundle && !any_fail) {
329 fs::path bundle_dir(resolved_path);
330 fs::path project_yaze = bundle_dir / "project.yaze";
331 std::error_code ec;
332 if (fs::exists(project_yaze, ec) && !ec) {
333 checks.push_back(
334 {"bundle_project_yaze", "pass", "project.yaze found in bundle root"});
335 } else {
336 checks.push_back({"bundle_project_yaze", "warn",
337 "project.yaze missing — will be auto-created on open"});
338 }
339 }
340
341 // ------------------------------------------------------------------
342 // Check 4: Project parses
343 // ------------------------------------------------------------------
345 bool parse_ok = false;
346 if (!any_fail) {
347 auto status = proj.Open(resolved_path);
348 if (status.ok()) {
349 parse_ok = true;
350 checks.push_back(
351 {"project_parses", "pass", absl::StrFormat("name=%s", proj.name)});
352 } else {
353 checks.push_back(
354 {"project_parses", "fail",
355 absl::StrFormat("Parse failed: %s", std::string(status.message()))});
356 any_fail = true;
357 }
358 }
359
360 // ------------------------------------------------------------------
361 // Check 5: Path portability (warnings for absolute paths)
362 // ------------------------------------------------------------------
363 if (parse_ok) {
364 const fs::path project_config =
365 is_bundle ? fs::path(resolved_path) / "project.yaze"
366 : fs::path(resolved_path);
367 const auto abs_paths = ListAbsolutePathsInProjectFile(project_config);
368 if (!abs_paths.empty()) {
369 std::string detail = "Absolute paths reduce portability: ";
370 for (size_t i = 0; i < abs_paths.size(); ++i) {
371 if (i > 0)
372 detail += "; ";
373 detail += abs_paths[i];
374 }
375 checks.push_back({"path_portability", "warn", detail});
376 } else {
377 checks.push_back({"path_portability", "pass", "All paths are relative"});
378 }
379 }
380
381 // ------------------------------------------------------------------
382 // Check 6: Explicit hack manifest readiness
383 // ------------------------------------------------------------------
384 if (parse_ok && !proj.hack_manifest_file.empty()) {
385 const std::string manifest_abs =
387 std::error_code ec;
388 if (!fs::exists(manifest_abs, ec) || ec) {
389 checks.push_back(
390 {"hack_manifest_ready", "fail",
391 absl::StrFormat("Configured hack manifest not found: %s",
392 manifest_abs)});
393 any_fail = true;
394 } else if (!proj.hack_manifest.loaded()) {
395 checks.push_back(
396 {"hack_manifest_ready", "fail",
397 absl::StrFormat("Configured hack manifest failed to load: %s",
398 manifest_abs)});
399 any_fail = true;
400 } else {
401 checks.push_back({"hack_manifest_ready", "pass",
402 absl::StrFormat("Loaded configured hack manifest: %s",
403 manifest_abs)});
404 }
405 }
406
407 // ------------------------------------------------------------------
408 // Check 7: ROM accessibility
409 // ------------------------------------------------------------------
410 if (parse_ok && !proj.rom_filename.empty()) {
411 std::string rom_abs = proj.GetAbsolutePath(proj.rom_filename);
412 std::error_code ec;
413 if (fs::exists(rom_abs, ec) && !ec) {
414 auto fsize = fs::file_size(rom_abs, ec);
415 if (ec) {
416 checks.push_back(
417 {"rom_accessible", "warn",
418 absl::StrFormat("ROM exists but size unreadable: %s", rom_abs)});
419 } else {
420 checks.push_back({"rom_accessible", "pass",
421 absl::StrFormat("%s (%zu bytes)", rom_abs, fsize)});
422 }
423 } else {
424 checks.push_back({"rom_accessible", "fail",
425 absl::StrFormat("ROM not found: %s (resolved: %s)",
426 proj.rom_filename, rom_abs)});
427 any_fail = true;
428 }
429 } else if (parse_ok) {
430 checks.push_back({"rom_accessible", "warn", "No ROM path in project"});
431 }
432
433 // ------------------------------------------------------------------
434 // Check 8: ROM hash verification (optional, --check-rom-hash)
435 // ------------------------------------------------------------------
436 if (parser.HasFlag("check-rom-hash") && parse_ok) {
437 std::string raw_expected;
438 std::string bundle_hash_source;
439 bool hash_metadata_ready = true;
440
441 if (is_bundle) {
442 fs::path manifest_path = fs::path(resolved_path) / "manifest.json";
443 std::error_code ec;
444 if (!fs::exists(manifest_path, ec) || ec) {
445 checks.push_back({"rom_hash_check", "warn",
446 "No manifest.json in bundle (hash unavailable)"});
447 hash_metadata_ready = false;
448 } else {
449 std::ifstream mf(manifest_path);
450 auto manifest = nlohmann::json::parse(mf, nullptr, false);
451 if (manifest.is_discarded()) {
452 checks.push_back(
453 {"rom_hash_check", "fail", "manifest.json parse failed"});
454 any_fail = true;
455 hash_metadata_ready = false;
456 } else {
457 const BundleHashMetadata metadata = ParseBundleHashMetadata(manifest);
458 if (!metadata.error.empty()) {
459 checks.push_back({"rom_hash_check", "fail", metadata.error});
460 any_fail = true;
461 hash_metadata_ready = false;
462 } else if (!metadata.available) {
463 checks.push_back(
464 {"rom_hash_check", "warn",
465 "No usable romChecksum or rom_sha1 digest in manifest.json"});
466 hash_metadata_ready = false;
467 } else {
468 raw_expected = metadata.expected;
469 bundle_hash_source = metadata.source;
470 }
471 }
472 }
473 } else {
474 raw_expected = proj.rom_metadata.expected_hash;
475 if (raw_expected.empty()) {
476 checks.push_back({"rom_hash_check", "warn",
477 "No expected_hash in standalone project"});
478 hash_metadata_ready = false;
479 }
480 }
481
482 if (hash_metadata_ready) {
483 const std::string expected = NormalizeHash(raw_expected);
484 const std::string rom_abs = proj.GetAbsolutePath(proj.rom_filename);
485 std::string actual;
486 std::string algorithm;
487
488 if (is_bundle) {
489 // Bundle manifests explicitly declare a raw-file SHA1. Keep that
490 // contract distinct from standalone project hashes, which describe
491 // the header-stripped ROM buffer loaded by the editor.
492 algorithm = "SHA1";
493 actual = util::ComputeFileSha1Hex(rom_abs);
494 } else if ((expected.size() == 8 || expected.size() == 40) &&
495 IsHexHash(expected)) {
496 algorithm = expected.size() == 8 ? "CRC32" : "SHA1";
497 Rom loaded_rom;
498 Rom::LoadOptions load_options;
499 load_options.load_resource_labels = false;
500 const auto load_status = loaded_rom.LoadFromFile(rom_abs, load_options);
501 if (!load_status.ok()) {
502 checks.push_back(
503 {"rom_hash_check", "fail",
504 absl::StrFormat("Cannot load ROM for hashing: %s (%s)", rom_abs,
505 load_status.message())});
506 any_fail = true;
507 hash_metadata_ready = false;
508 } else if (expected.size() == 8) {
509 actual = util::ComputeRomHash(loaded_rom.data(), loaded_rom.size());
510 } else {
511 actual = util::ComputeSha1Hex(loaded_rom.data(), loaded_rom.size());
512 }
513 } else {
514 checks.push_back(
515 {"rom_hash_check", "fail",
516 "Standalone expected_hash must be an 8-character CRC32 or "
517 "40-character SHA1 hexadecimal digest"});
518 any_fail = true;
519 hash_metadata_ready = false;
520 }
521
522 if (hash_metadata_ready) {
523 if (actual.empty()) {
524 checks.push_back(
525 {"rom_hash_check", "fail",
526 absl::StrFormat("Cannot read ROM for hashing: %s", rom_abs)});
527 any_fail = true;
528 } else if (NormalizeHash(actual) == expected) {
529 const std::string detail =
530 is_bundle ? absl::StrFormat("%s match (%s): %s", algorithm,
531 bundle_hash_source, actual)
532 : absl::StrFormat("%s match: %s", algorithm, actual);
533 checks.push_back({"rom_hash_check", "pass", detail});
534 } else {
535 checks.push_back(
536 {"rom_hash_check", "fail",
537 absl::StrFormat("%s mismatch: expected=%s actual=%s", algorithm,
538 expected, actual)});
539 any_fail = true;
540 }
541 }
542 }
543 }
544
545 // ------------------------------------------------------------------
546 // Emit results
547 // ------------------------------------------------------------------
548 bool overall_ok = !any_fail;
549 int pass_count = 0, warn_count = 0, fail_count = 0;
550 for (const auto& chk : checks) {
551 if (chk.status == "pass")
552 ++pass_count;
553 else if (chk.status == "warn")
554 ++warn_count;
555 else
556 ++fail_count;
557 }
558
559 formatter.AddField("ok", overall_ok);
560 formatter.AddField("status",
561 overall_ok ? std::string("pass") : std::string("fail"));
562 formatter.AddField("project_path", resolved_path);
563 formatter.AddField("is_bundle", is_bundle);
564 formatter.AddField("pass_count", pass_count);
565 formatter.AddField("warn_count", warn_count);
566 formatter.AddField("fail_count", fail_count);
567
568 formatter.BeginArray("checks");
569 for (const auto& chk : checks) {
570 formatter.BeginObject("");
571 formatter.AddField("name", chk.name);
572 formatter.AddField("status", chk.status);
573 formatter.AddField("detail", chk.detail);
574 formatter.EndObject();
575 }
576 formatter.EndArray();
577
578 // ------------------------------------------------------------------
579 // Write report file
580 // ------------------------------------------------------------------
581 if (auto rp = parser.GetString("report"); rp.has_value() && !rp->empty()) {
582 // Build a standalone JSON report (formatter may be text mode)
583 nlohmann::json report;
584 report["ok"] = overall_ok;
585 report["status"] = overall_ok ? "pass" : "fail";
586 report["project_path"] = resolved_path;
587 report["is_bundle"] = is_bundle;
588 report["pass_count"] = pass_count;
589 report["warn_count"] = warn_count;
590 report["fail_count"] = fail_count;
591 nlohmann::json checks_json = nlohmann::json::array();
592 for (const auto& chk : checks) {
593 checks_json.push_back(
594 {{"name", chk.name}, {"status", chk.status}, {"detail", chk.detail}});
595 }
596 report["checks"] = std::move(checks_json);
597
598 std::ofstream report_file(
599 *rp, std::ios::out | std::ios::binary | std::ios::trunc);
600 if (!report_file.is_open()) {
601 return absl::PermissionDeniedError(absl::StrFormat(
602 "project-bundle-verify: cannot open report file: %s", *rp));
603 }
604 report_file << report.dump(2) << "\n";
605 if (!report_file.good()) {
606 return absl::InternalError(absl::StrFormat(
607 "project-bundle-verify: failed writing report: %s", *rp));
608 }
609 }
610
611 if (!overall_ok) {
612 return absl::FailedPreconditionError(absl::StrFormat(
613 "project-bundle-verify: %d check(s) failed", fail_count));
614 }
615 return absl::OkStatus();
616}
617
618} // namespace yaze::cli::handlers
The Rom class is used to load, save, and modify Rom data. This is a generic SNES ROM container and do...
Definition rom.h:28
absl::Status LoadFromFile(const std::string &filename, const LoadOptions &options=LoadOptions::Defaults())
Definition rom.cc:270
auto data() const
Definition rom.h:169
auto size() const
Definition rom.h:168
absl::Status ValidateArgs(const resources::ArgumentParser &parser) override
Validate command arguments.
Descriptor Describe() const override
Provide metadata for TUI/help summaries.
absl::Status Execute(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter) override
Execute the command business logic.
Utility for parsing common CLI argument patterns.
std::optional< std::string > GetString(const std::string &name) const
Parse a named argument (e.g., –format=json or –format json)
bool HasFlag(const std::string &name) const
Check if a flag is present.
Utility for consistent output formatting across commands.
void BeginArray(const std::string &key)
Begin an array.
void BeginObject(const std::string &title="")
Start a JSON object or text section.
void EndObject()
End a JSON object or text section.
void AddField(const std::string &key, const std::string &value)
Add a key-value pair.
bool loaded() const
Check if the manifest has been loaded.
std::vector< std::string > ConfigPathValues(const std::string &key, const std::string &raw_value)
std::vector< std::string > ListAbsolutePathsInProjectFile(const fs::path &project_file)
std::string ComputeSha1Hex(const uint8_t *data, size_t size)
Compute SHA-1 hash of data, return lowercase hex string (40 chars).
Definition rom_hash.cc:196
std::string ComputeFileSha1Hex(const std::string &path)
Definition rom_hash.cc:213
std::string ComputeRomHash(const uint8_t *data, size_t size)
Definition rom_hash.cc:70
bool load_resource_labels
Definition rom.h:43
std::string expected_hash
Definition project.h:109
Modern project structure with comprehensive settings consolidation.
Definition project.h:172
static std::string ResolveBundleRoot(const std::string &path)
Definition project.cc:419
core::HackManifest hack_manifest
Definition project.h:212
std::string hack_manifest_file
Definition project.h:194
std::string GetAbsolutePath(const std::string &relative_path) const
Definition project.cc:1548
absl::Status Open(const std::string &project_path)
Definition project.cc:445
bool equals(InputIt1 first1, InputIt1 last1, InputIt2 first2, InputIt2 last2, BinaryPredicate p)