yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
project_bundle_archive_commands.cc
Go to the documentation of this file.
2
3#include <cstring>
4#include <filesystem>
5#include <fstream>
6#include <ios>
7#include <string>
8#include <vector>
9
10#include "absl/status/status.h"
11#include "absl/strings/str_format.h"
12#include "miniz.h"
13
14namespace yaze::cli::handlers {
15
16namespace fs = std::filesystem;
17
18namespace {
19
20bool HasTraversalPathComponent(const fs::path& path) {
21 for (const auto& component : path) {
22 if (component == "..") {
23 return true;
24 }
25 }
26 return false;
27}
28
29} // namespace
30
31// ============================================================================
32// Pack
33// ============================================================================
34
37 Descriptor desc;
38 desc.display_name = "Project Bundle Pack";
39 desc.summary =
40 "Pack a .yazeproj bundle directory into a .zip archive for "
41 "cross-platform sharing (Windows/macOS/Linux). Preserves the "
42 "bundle root folder name inside the archive.";
43 desc.todo_reference = "todo#project-bundle-infra";
44 desc.entries = {
45 {"--project", "Path to .yazeproj bundle directory (required)", ""},
46 {"--out", "Output .zip file path (required)", ""},
47 {"--overwrite", "Overwrite existing output file", ""},
48 };
49 return desc;
50}
51
53 const resources::ArgumentParser& parser) {
54 auto project = parser.GetString("project");
55 if (!project.has_value() || project->empty()) {
56 return absl::InvalidArgumentError(
57 "project-bundle-pack: --project is required");
58 }
59
60 auto out = parser.GetString("out");
61 if (!out.has_value() || out->empty()) {
62 return absl::InvalidArgumentError(
63 "project-bundle-pack: --out is required");
64 }
65 return absl::OkStatus();
66}
67
69 Rom* /*rom*/, const resources::ArgumentParser& parser,
70 resources::OutputFormatter& formatter) {
71 const std::string project_path = *parser.GetString("project");
72 const std::string out_path = *parser.GetString("out");
73 const bool overwrite = parser.HasFlag("overwrite");
74
75 // Validate input is a .yazeproj directory
76 std::error_code fserr;
77 fs::path bundle_dir(project_path);
78 if (bundle_dir.extension() != ".yazeproj") {
79 formatter.AddField("ok", false);
80 formatter.AddField("error", std::string("Input must be a .yazeproj directory"));
81 return absl::InvalidArgumentError(
82 "project-bundle-pack: input must be a .yazeproj directory");
83 }
84 if (!fs::is_directory(bundle_dir, fserr) || fserr) {
85 formatter.AddField("ok", false);
86 formatter.AddField("error",
87 absl::StrFormat("Not a directory: %s", project_path));
88 return absl::NotFoundError(
89 absl::StrFormat("project-bundle-pack: not a directory: %s",
90 project_path));
91 }
92
93 // Check output doesn't exist (unless --overwrite)
94 if (fs::exists(out_path, fserr) && !overwrite) {
95 formatter.AddField("ok", false);
96 formatter.AddField("error",
97 std::string("Output file exists (use --overwrite)"));
98 return absl::AlreadyExistsError(
99 "project-bundle-pack: output file exists; use --overwrite");
100 }
101
102 // Collect all files in the bundle
103 std::vector<fs::path> entries;
104 for (auto it = fs::recursive_directory_iterator(
105 bundle_dir, fs::directory_options::skip_permission_denied, fserr);
106 it != fs::recursive_directory_iterator(); ++it) {
107 if (it->is_regular_file(fserr) && !fserr) {
108 entries.push_back(it->path());
109 }
110 }
111
112 // Create the zip archive using miniz
113 mz_zip_archive zip;
114 std::memset(&zip, 0, sizeof(zip));
115
116 if (!mz_zip_writer_init_file(&zip, out_path.c_str(), 0)) {
117 formatter.AddField("ok", false);
118 formatter.AddField("error",
119 absl::StrFormat("Cannot create zip: %s", out_path));
120 return absl::InternalError(
121 absl::StrFormat("project-bundle-pack: cannot create zip: %s",
122 out_path));
123 }
124
125 // Use the bundle directory name as the archive root
126 std::string bundle_name = bundle_dir.filename().string();
127 int files_added = 0;
128
129 for (const auto& entry : entries) {
130 // Compute relative path inside zip: BundleName/relative/path
131 fs::path rel = fs::relative(entry, bundle_dir.parent_path(), fserr);
132 if (fserr) continue;
133 // Use generic_string() for forward-slash paths in zip (cross-platform safe)
134 std::string archive_name = rel.generic_string();
135
136 // Read file contents
137 std::ifstream infile(entry, std::ios::binary | std::ios::ate);
138 if (!infile.is_open()) continue;
139 auto file_size = infile.tellg();
140 infile.seekg(0, std::ios::beg);
141 std::vector<char> buffer(static_cast<size_t>(file_size));
142 infile.read(buffer.data(), file_size);
143
144 if (!mz_zip_writer_add_mem(&zip, archive_name.c_str(), buffer.data(),
145 buffer.size(), MZ_DEFAULT_COMPRESSION)) {
146 mz_zip_writer_end(&zip);
147 fs::remove(out_path, fserr);
148 formatter.AddField("ok", false);
149 formatter.AddField("error",
150 absl::StrFormat("Failed to add: %s", archive_name));
151 return absl::InternalError(
152 absl::StrFormat("project-bundle-pack: failed to add: %s",
153 archive_name));
154 }
155 ++files_added;
156 }
157
158 if (!mz_zip_writer_finalize_archive(&zip)) {
159 mz_zip_writer_end(&zip);
160 fs::remove(out_path, fserr);
161 formatter.AddField("ok", false);
162 formatter.AddField("error", std::string("Failed to finalize archive"));
163 return absl::InternalError(
164 "project-bundle-pack: failed to finalize archive");
165 }
166 mz_zip_writer_end(&zip);
167
168 auto archive_size = fs::file_size(out_path, fserr);
169
170 formatter.AddField("ok", true);
171 formatter.AddField("status", std::string("pass"));
172 formatter.AddField("archive_path", out_path);
173 formatter.AddField("bundle_name", bundle_name);
174 formatter.AddField("files_packed", files_added);
175 formatter.AddField("archive_bytes",
176 static_cast<int>(fserr ? 0 : archive_size));
177
178 return absl::OkStatus();
179}
180
181// ============================================================================
182// Unpack
183// ============================================================================
184
187 Descriptor desc;
188 desc.display_name = "Project Bundle Unpack";
189 desc.summary =
190 "Unpack a .zip archive into a .yazeproj bundle directory. Enforces "
191 "path traversal safety (rejects .. and absolute-path entries).";
192 desc.todo_reference = "todo#project-bundle-infra";
193 desc.entries = {
194 {"--archive", "Path to .zip archive (required)", ""},
195 {"--out", "Output directory where bundle will be extracted (required)",
196 ""},
197 {"--overwrite", "Overwrite existing output directory", ""},
198 {"--dry-run",
199 "Validate archive structure without extracting files", ""},
200 {"--keep-partial-output",
201 "Keep extracted files on failure (for debugging). Default: clean up.",
202 ""},
203 };
204 return desc;
205}
206
208 const resources::ArgumentParser& parser) {
209 auto archive = parser.GetString("archive");
210 if (!archive.has_value() || archive->empty()) {
211 return absl::InvalidArgumentError(
212 "project-bundle-unpack: --archive is required");
213 }
214
215 auto out = parser.GetString("out");
216 if (!out.has_value() || out->empty()) {
217 return absl::InvalidArgumentError(
218 "project-bundle-unpack: --out is required");
219 }
220 return absl::OkStatus();
221}
222
224 Rom* /*rom*/, const resources::ArgumentParser& parser,
225 resources::OutputFormatter& formatter) {
226 const std::string archive_path = *parser.GetString("archive");
227 const std::string out_dir = *parser.GetString("out");
228 const bool overwrite = parser.HasFlag("overwrite");
229 const bool keep_partial = parser.HasFlag("keep-partial-output");
230 const bool dry_run = parser.HasFlag("dry-run");
231
232 std::error_code fserr;
233 auto add_cleanup_field = [&]() {
234 if (dry_run) {
235 formatter.AddField("cleanup", std::string("skipped (dry-run)"));
236 return;
237 }
238 if (keep_partial) {
239 formatter.AddField("cleanup",
240 std::string("skipped (--keep-partial-output)"));
241 return;
242 }
243 std::error_code cleanup_err;
244 if (!fs::exists(out_dir, cleanup_err) || cleanup_err) {
245 formatter.AddField(
246 "cleanup",
247 cleanup_err
248 ? std::string("failed: " + cleanup_err.message())
249 : std::string("not-needed"));
250 return;
251 }
252 fs::remove_all(out_dir, cleanup_err);
253 formatter.AddField("cleanup", cleanup_err
254 ? std::string("failed: " +
255 cleanup_err.message())
256 : std::string("removed"));
257 };
258
259 // Validate archive exists
260 if (!fs::exists(archive_path, fserr) || fserr) {
261 formatter.AddField("ok", false);
262 formatter.AddField("error",
263 absl::StrFormat("Archive not found: %s", archive_path));
264 return absl::NotFoundError(
265 absl::StrFormat("project-bundle-unpack: archive not found: %s",
266 archive_path));
267 }
268
269 // Check output directory
270 if (fs::exists(out_dir, fserr) && !overwrite) {
271 formatter.AddField("ok", false);
272 formatter.AddField("error",
273 std::string("Output exists (use --overwrite)"));
274 return absl::AlreadyExistsError(
275 "project-bundle-unpack: output exists; use --overwrite");
276 }
277
278 // When --overwrite, remove stale content before extraction to prevent
279 // leftover files from a previous unpack polluting the result.
280 if (overwrite && !dry_run && fs::exists(out_dir, fserr)) {
281 fs::remove_all(out_dir, fserr);
282 }
283
284 // Open zip for reading
285 mz_zip_archive zip;
286 std::memset(&zip, 0, sizeof(zip));
287
288 if (!mz_zip_reader_init_file(&zip, archive_path.c_str(), 0)) {
289 formatter.AddField("ok", false);
290 formatter.AddField("error",
291 absl::StrFormat("Cannot open zip: %s", archive_path));
292 return absl::InternalError(
293 absl::StrFormat("project-bundle-unpack: cannot open zip: %s",
294 archive_path));
295 }
296
297 int num_files = static_cast<int>(mz_zip_reader_get_num_files(&zip));
298 int files_counted = 0;
299 int files_extracted = 0;
300 std::string detected_bundle_name;
301 bool mixed_roots = false;
302 bool has_project_yaze = false; // Tracks if any entry is <root>/project.yaze
303
304 for (int idx = 0; idx < num_files; ++idx) {
305 mz_zip_archive_file_stat file_stat;
306 if (!mz_zip_reader_file_stat(&zip, static_cast<mz_uint>(idx),
307 &file_stat)) {
308 continue;
309 }
310
311 std::string entry_name(file_stat.m_filename);
312
313 // Skip directory entries
314 if (mz_zip_reader_is_file_a_directory(
315 &zip, static_cast<mz_uint>(idx))) {
316 continue;
317 }
318
319 // ---- Path traversal safety ----
320 // Reject entries with explicit ".." path components.
321 if (HasTraversalPathComponent(fs::path(entry_name))) {
322 mz_zip_reader_end(&zip);
323 formatter.AddField("ok", false);
324 formatter.AddField("error",
325 absl::StrFormat("Path traversal rejected: %s",
326 entry_name));
327 add_cleanup_field();
328 return absl::FailedPreconditionError(
329 absl::StrFormat(
330 "project-bundle-unpack: path traversal rejected: %s",
331 entry_name));
332 }
333 // Reject absolute paths
334 if (fs::path(entry_name).is_absolute()) {
335 mz_zip_reader_end(&zip);
336 formatter.AddField("ok", false);
337 formatter.AddField("error",
338 absl::StrFormat("Absolute path rejected: %s",
339 entry_name));
340 add_cleanup_field();
341 return absl::FailedPreconditionError(
342 absl::StrFormat(
343 "project-bundle-unpack: absolute path rejected: %s",
344 entry_name));
345 }
346
347 // Detect bundle root name and enforce a single-root archive.
348 {
349 fs::path entry_path(entry_name);
350 auto root_component = *entry_path.begin();
351 if (detected_bundle_name.empty()) {
352 detected_bundle_name = root_component.string();
353 } else if (detected_bundle_name != root_component.string()) {
354 mixed_roots = true;
355 }
356 // Check for project.yaze directly under the root
357 fs::path relative_to_root = entry_path.lexically_relative(root_component);
358 if (relative_to_root == "project.yaze") {
359 has_project_yaze = true;
360 }
361 }
362
363 ++files_counted;
364
365 // In dry-run mode, validate entries but don't write files.
366 if (dry_run) {
367 continue;
368 }
369
370 // Construct output path and extract file
371 fs::path dest = fs::path(out_dir) / entry_name;
372 fs::create_directories(dest.parent_path(), fserr);
373
374 size_t uncomp_size = 0;
375 void* data = mz_zip_reader_extract_to_heap(
376 &zip, static_cast<mz_uint>(idx), &uncomp_size, 0);
377 if (!data) {
378 mz_zip_reader_end(&zip);
379 formatter.AddField("ok", false);
380 formatter.AddField("error",
381 absl::StrFormat("Failed to extract: %s", entry_name));
382 add_cleanup_field();
383 return absl::InternalError(
384 absl::StrFormat("project-bundle-unpack: failed to extract: %s",
385 entry_name));
386 }
387
388 std::ofstream outfile(dest, std::ios::binary | std::ios::trunc);
389 if (!outfile.is_open()) {
390 mz_free(data);
391 mz_zip_reader_end(&zip);
392 formatter.AddField("ok", false);
393 formatter.AddField("error",
394 absl::StrFormat("Cannot write: %s", dest.string()));
395 add_cleanup_field();
396 return absl::InternalError(
397 absl::StrFormat("project-bundle-unpack: cannot write: %s",
398 dest.string()));
399 }
400 outfile.write(static_cast<const char*>(data), uncomp_size);
401 outfile.close();
402 mz_free(data);
403 ++files_extracted;
404 }
405
406 mz_zip_reader_end(&zip);
407
408 // Determine extracted bundle path
409 std::string bundle_path;
410 if (!detected_bundle_name.empty()) {
411 bundle_path = (fs::path(out_dir) / detected_bundle_name).string();
412 }
413
414 // Check if result is (or would be) a valid .yazeproj
415 bool is_valid_bundle = false;
416 if (!bundle_path.empty()) {
417 fs::path bp(bundle_path);
418 if (dry_run) {
419 // Structural dry-run: root must end in .yazeproj AND archive must
420 // contain project.yaze under that root.
421 is_valid_bundle =
422 bp.extension() == ".yazeproj" && has_project_yaze && !mixed_roots;
423 } else {
424 is_valid_bundle =
425 bp.extension() == ".yazeproj" && fs::is_directory(bp, fserr) &&
426 has_project_yaze && !mixed_roots;
427 }
428 }
429
430 formatter.AddField("ok", is_valid_bundle);
431 formatter.AddField("status",
432 is_valid_bundle ? std::string("pass")
433 : std::string("fail"));
434 formatter.AddField("archive_path", archive_path);
435 formatter.AddField("output_directory", out_dir);
436 formatter.AddField("bundle_path", bundle_path);
437 formatter.AddField("bundle_name", detected_bundle_name);
438 formatter.AddField("files_counted", files_counted);
439 formatter.AddField("files_extracted", files_extracted);
440 formatter.AddField("is_valid_bundle", is_valid_bundle);
441 formatter.AddField("dry_run", dry_run);
442
443 if (!is_valid_bundle) {
444 if (mixed_roots) {
445 formatter.AddField("error",
446 std::string("Archive contains multiple root folders"));
447 } else if (!has_project_yaze) {
448 formatter.AddField(
449 "error",
450 std::string("Bundle is missing required root file: project.yaze"));
451 }
452 // Clean up partial output unless --keep-partial-output is set.
453 add_cleanup_field();
454 return absl::FailedPreconditionError(
455 "project-bundle-unpack: extracted archive is not a valid "
456 ".yazeproj bundle");
457 }
458 return absl::OkStatus();
459}
460
461} // 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 Execute(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter) override
Execute the command business logic.
Descriptor Describe() const override
Provide metadata for TUI/help summaries.
absl::Status ValidateArgs(const resources::ArgumentParser &parser) override
Validate command arguments.
Descriptor Describe() const override
Provide metadata for TUI/help summaries.
absl::Status ValidateArgs(const resources::ArgumentParser &parser) override
Validate command arguments.
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 AddField(const std::string &key, const std::string &value)
Add a key-value pair.