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