1 /**
2  * \file src/cli.cc
3  * \ingroup cli
4  *
5  * Provide all the features of the opustags executable from a C++ API. The main point of separating
6  * this module from the main one is to allow easy testing.
7  */
8 
9 #include <opustags.h>
10 
11 #include <errno.h>
12 #include <getopt.h>
13 #include <limits.h>
14 #include <stdlib.h>
15 #include <string.h>
16 #include <sys/stat.h>
17 #include <unistd.h>
18 
19 using namespace std::literals::string_literals;
20 
21 static const char help_message[] =
22 PROJECT_NAME " version " PROJECT_VERSION
23 R"raw(
24 
25 Usage: opustags --help
26        opustags [OPTIONS] FILE
27        opustags OPTIONS -i FILE...
28        opustags OPTIONS FILE -o FILE
29 
30 Options:
31   -h, --help                    print this help
32   -o, --output FILE             specify the output file
33   -i, --in-place                overwrite the input files
34   -y, --overwrite               overwrite the output file if it already exists
35   -a, --add FIELD=VALUE         add a comment
36   -d, --delete FIELD[=VALUE]    delete previously existing comments
37   -D, --delete-all              delete all the previously existing comments
38   -s, --set FIELD=VALUE         replace a comment
39   -S, --set-all                 import comments from standard input
40   -e, --edit                    edit tags interactively in VISUAL/EDITOR
41   --raw                         disable encoding conversion
42 
43 See the man page for extensive documentation.
44 )raw";
45 
46 static struct option getopt_options[] = {
47 	{"help", no_argument, 0, 'h'},
48 	{"output", required_argument, 0, 'o'},
49 	{"in-place", optional_argument, 0, 'i'},
50 	{"overwrite", no_argument, 0, 'y'},
51 	{"delete", required_argument, 0, 'd'},
52 	{"add", required_argument, 0, 'a'},
53 	{"set", required_argument, 0, 's'},
54 	{"delete-all", no_argument, 0, 'D'},
55 	{"set-all", no_argument, 0, 'S'},
56 	{"edit", no_argument, 0, 'e'},
57 	{"raw", no_argument, 0, 'r'},
58 	{NULL, 0, 0, 0}
59 };
60 
parse_options(int argc,char ** argv,ot::options & opt,FILE * comments_input)61 ot::status ot::parse_options(int argc, char** argv, ot::options& opt, FILE* comments_input)
62 {
63 	static ot::encoding_converter to_utf8("", "UTF-8");
64 	std::string utf8;
65 	const char* equal;
66 	ot::status rc;
67 	bool set_all = false;
68 	opt = {};
69 	if (argc == 1)
70 		return {st::bad_arguments, "No arguments specified. Use -h for help."};
71 	int c;
72 	optind = 0;
73 	while ((c = getopt_long(argc, argv, ":ho:iyd:a:s:DSe", getopt_options, NULL)) != -1) {
74 		switch (c) {
75 		case 'h':
76 			opt.print_help = true;
77 			break;
78 		case 'o':
79 			if (opt.path_out)
80 				return {st::bad_arguments, "Cannot specify --output more than once."};
81 			opt.path_out = optarg;
82 			break;
83 		case 'i':
84 			opt.in_place = true;
85 			opt.overwrite = true;
86 			break;
87 		case 'y':
88 			opt.overwrite = true;
89 			break;
90 		case 'd':
91 			opt.to_delete.emplace_back(optarg);
92 			break;
93 		case 'a':
94 		case 's':
95 			equal = strchr(optarg, '=');
96 			if (equal == nullptr)
97 				return {st::bad_arguments, "Comment does not contain an equal sign: "s + optarg + "."};
98 			if (c == 's')
99 				opt.to_delete.emplace_back(optarg, equal - optarg);
100 			opt.to_add.emplace_back(optarg);
101 			break;
102 		case 'S':
103 			opt.delete_all = true;
104 			set_all = true;
105 			break;
106 		case 'D':
107 			opt.delete_all = true;
108 			break;
109 		case 'e':
110 			opt.edit_interactively = true;
111 			break;
112 		case 'r':
113 			opt.raw = true;
114 			break;
115 		case ':':
116 			return {st::bad_arguments,
117 			        "Missing value for option '"s + argv[optind - 1] + "'."};
118 		default:
119 			return {st::bad_arguments, "Unrecognized option '" +
120 			        (optopt ? "-"s + static_cast<char>(optopt) : argv[optind - 1]) + "'."};
121 		}
122 	}
123 	if (opt.print_help)
124 		return st::ok;
125 
126 	// All non-option arguments are input files.
127 	bool stdin_as_input = false;
128 	for (int i = optind; i < argc; i++) {
129 		stdin_as_input = stdin_as_input || strcmp(argv[i], "-") == 0;
130 		opt.paths_in.emplace_back(argv[i]);
131 	}
132 
133 	// Convert arguments to UTF-8.
134 	if (!opt.raw) {
135 		for (std::list<std::string>* args : { &opt.to_add, &opt.to_delete }) {
136 			for (std::string& arg : *args) {
137 				rc = to_utf8(arg, utf8);
138 				if (rc != ot::st::ok)
139 					return {st::bad_arguments, "Could not encode argument into UTF-8: " + rc.message};
140 				arg = std::move(utf8);
141 			}
142 		}
143 	}
144 
145 	if (opt.in_place && opt.path_out)
146 		return {st::bad_arguments, "Cannot combine --in-place and --output."};
147 
148 	if (opt.in_place && stdin_as_input)
149 		return {st::bad_arguments, "Cannot modify standard input in place."};
150 
151 	if ((!opt.in_place || opt.edit_interactively) && opt.paths_in.size() != 1)
152 		return {st::bad_arguments, "Exactly one input file must be specified."};
153 
154 	if (set_all && stdin_as_input)
155 		return {st::bad_arguments, "Cannot use standard input as input file when --set-all is specified."};
156 
157 	if (opt.edit_interactively && (stdin_as_input || opt.path_out == "-"))
158 		return {st::bad_arguments, "Cannot edit interactively when standard input or standard output are already used."};
159 
160 	if (opt.edit_interactively && !opt.path_out.has_value() && !opt.in_place)
161 		return {st::bad_arguments, "Cannot edit interactively when no output is specified."};
162 
163 	if (opt.edit_interactively && (opt.delete_all || !opt.to_add.empty() || !opt.to_delete.empty()))
164 		return {st::bad_arguments, "Cannot mix --edit with -adDsS."};
165 
166 	if (set_all) {
167 		// Read comments from stdin and prepend them to opt.to_add.
168 		std::list<std::string> comments;
169 		auto rc = read_comments(comments_input, comments, opt.raw);
170 		if (rc != st::ok)
171 			return rc;
172 		opt.to_add.splice(opt.to_add.begin(), std::move(comments));
173 	}
174 	return st::ok;
175 }
176 
177 /**
178  * \todo Find a way to support new lines such that they can be read back by #read_comment without
179  *       ambiguity. We could add a raw mode and separate comments with a \0, or escape control
180  *       characters with a backslash, but we should also preserve compatibiltity with potential
181  *       callers that don’t escape backslashes. Maybe add options to select a mode between simple,
182  *       raw, and escaped.
183  */
print_comments(const std::list<std::string> & comments,FILE * output,bool raw)184 ot::status ot::print_comments(const std::list<std::string>& comments, FILE* output, bool raw)
185 {
186 	static ot::encoding_converter from_utf8("UTF-8", "");
187 	std::string local;
188 	bool has_newline = false;
189 	bool has_control = false;
190 	for (const std::string& utf8_comment : comments) {
191 		const std::string* comment;
192 		// Convert the comment from UTF-8 to the system encoding if relevant.
193 		if (raw) {
194 			comment = &utf8_comment;
195 		} else {
196 			ot::status rc = from_utf8(utf8_comment, local);
197 			comment = &local;
198 			if (rc != ot::st::ok) {
199 				rc.message += " See --raw.";
200 				return rc;
201 			}
202 		}
203 
204 		for (unsigned char c : *comment) {
205 			if (c == '\n')
206 				has_newline = true;
207 			else if (c < 0x20)
208 				has_control = true;
209 		}
210 		fwrite(comment->data(), 1, comment->size(), output);
211 		putc('\n', output);
212 	}
213 	if (has_newline)
214 		fputs("warning: Some tags contain unsupported newline characters.\n", stderr);
215 	if (has_control)
216 		fputs("warning: Some tags contain control characters.\n", stderr);
217 	return st::ok;
218 }
219 
read_comments(FILE * input,std::list<std::string> & comments,bool raw)220 ot::status ot::read_comments(FILE* input, std::list<std::string>& comments, bool raw)
221 {
222 	static ot::encoding_converter to_utf8("", "UTF-8");
223 	comments.clear();
224 	char* line = nullptr;
225 	size_t buflen = 0;
226 	ssize_t nread;
227 	while ((nread = getline(&line, &buflen, input)) != -1) {
228 		if (nread > 0 && line[nread - 1] == '\n')
229 			--nread;
230 		if (nread == 0)
231 			continue;
232 		if (line[0] == '#') // comment
233 			continue;
234 		if (memchr(line, '=', nread) == nullptr) {
235 			ot::status rc = {ot::st::error, "Malformed tag: " + std::string(line, nread)};
236 			free(line);
237 			return rc;
238 		}
239 		if (raw) {
240 			comments.emplace_back(line, nread);
241 		} else {
242 			std::string utf8;
243 			ot::status rc = to_utf8(std::string_view(line, nread), utf8);
244 			if (rc == ot::st::ok) {
245 				comments.emplace_back(std::move(utf8));
246 			} else {
247 				free(line);
248 				return {ot::st::badly_encoded, "UTF-8 conversion error: " + rc.message};
249 			}
250 		}
251 	}
252 	free(line);
253 	return ot::st::ok;
254 }
255 
delete_comments(std::list<std::string> & comments,const std::string & selector)256 void ot::delete_comments(std::list<std::string>& comments, const std::string& selector)
257 {
258 	auto name = selector.data();
259 	auto equal = selector.find('=');
260 	auto value = (equal == std::string::npos ? nullptr : name + equal + 1);
261 	auto name_len = value ? equal : selector.size();
262 	auto value_len = value ? selector.size() - equal - 1 : 0;
263 	auto it = comments.begin(), end = comments.end();
264 	while (it != end) {
265 		auto current = it++;
266 		bool name_match = current->size() > name_len + 1 &&
267 		                  (*current)[name_len] == '=' &&
268 		                  strncasecmp(current->data(), name, name_len) == 0;
269 		if (!name_match)
270 			continue;
271 		bool value_match = value == nullptr ||
272 		                   (current->size() == selector.size() &&
273 		                    memcmp(current->data() + equal + 1, value, value_len) == 0);
274 		if (value_match)
275 			comments.erase(current);
276 	}
277 }
278 
279 /** Apply the modifications requested by the user to the opustags packet. */
edit_tags(ot::opus_tags & tags,const ot::options & opt)280 static ot::status edit_tags(ot::opus_tags& tags, const ot::options& opt)
281 {
282 	if (opt.delete_all) {
283 		tags.comments.clear();
284 	} else for (const std::string& name : opt.to_delete) {
285 		ot::delete_comments(tags.comments, name.c_str());
286 	}
287 
288 	for (const std::string& comment : opt.to_add)
289 		tags.comments.emplace_back(comment);
290 
291 	return ot::st::ok;
292 }
293 
294 /** Spawn VISUAL or EDITOR to edit the given tags. */
edit_tags_interactively(ot::opus_tags & tags,const std::optional<std::string> & base_path,bool raw)295 static ot::status edit_tags_interactively(ot::opus_tags& tags, const std::optional<std::string>& base_path, bool raw)
296 {
297 	const char* editor = nullptr;
298 	if (getenv("TERM") != nullptr)
299 		editor = getenv("VISUAL");
300 	if (editor == nullptr) // without a terminal, or if VISUAL is unset
301 		editor = getenv("EDITOR");
302 	if (editor == nullptr)
303 		return {ot::st::error,
304 		        "No editor specified in environment variable VISUAL or EDITOR."};
305 
306 	// Building the temporary tags file.
307 	ot::status rc;
308 	std::string tags_path = base_path.value_or("tags") + ".XXXXXX.opustags";
309 	int fd = mkstemps(const_cast<char*>(tags_path.data()), 9);
310 	ot::file tags_file;
311 	if (fd == -1 || (tags_file = fdopen(fd, "w")) == nullptr)
312 		return {ot::st::standard_error,
313 		        "Could not open '" + tags_path + "': " + strerror(errno)};
314 	if ((rc = ot::print_comments(tags.comments, tags_file.get(), raw)) != ot::st::ok)
315 		return rc;
316 	tags_file.reset();
317 
318 	// Spawn the editor, and watch the modification timestamps.
319 	timespec before, after;
320 	if ((rc = ot::get_file_timestamp(tags_path.c_str(), before)) != ot::st::ok)
321 		return rc;
322 	ot::status editor_rc = ot::run_editor(editor, tags_path);
323 	if ((rc = ot::get_file_timestamp(tags_path.c_str(), after)) != ot::st::ok)
324 		return rc; // probably because the file was deleted
325 	bool modified = (before.tv_sec != after.tv_sec || before.tv_nsec != after.tv_nsec);
326 	if (editor_rc != ot::st::ok) {
327 		if (modified)
328 			fprintf(stderr, "warning: Leaving %s on the disk.\n", tags_path.c_str());
329 		else
330 			remove(tags_path.c_str());
331 		return editor_rc;
332 	} else if (!modified) {
333 		remove(tags_path.c_str());
334 		fputs("Cancelling edition because the tags file was not modified.\n", stderr);
335 		return ot::st::cancel;
336 	}
337 
338 	// Applying the new tags.
339 	tags_file = fopen(tags_path.c_str(), "re");
340 	if (tags_file == nullptr)
341 		return {ot::st::standard_error, "Error opening " + tags_path + ": " + strerror(errno)};
342 	if ((rc = ot::read_comments(tags_file.get(), tags.comments, raw)) != ot::st::ok) {
343 		fprintf(stderr, "warning: Leaving %s on the disk.\n", tags_path.c_str());
344 		return rc;
345 	}
346 	tags_file.reset();
347 
348 	// Remove the temporary tags file only on success, because unlike the
349 	// partial Ogg file that is irrecoverable, the edited tags file
350 	// contains user data, so let’s leave users a chance to recover it.
351 	remove(tags_path.c_str());
352 
353 	return ot::st::ok;
354 }
355 
356 /**
357  * Main loop of opustags. Read the packets from the reader, and forwards them to the writer.
358  * Transform the OpusTags packet on the fly.
359  *
360  * The writer is optional. When writer is nullptr, opustags runs in read-only mode.
361  */
process(ot::ogg_reader & reader,ot::ogg_writer * writer,const ot::options & opt)362 static ot::status process(ot::ogg_reader& reader, ot::ogg_writer* writer, const ot::options &opt)
363 {
364 	bool focused = false; /*< the stream on which we operate is defined */
365 	int focused_serialno; /*< when focused, the serialno of the focused stream */
366 	int absolute_page_no = -1; /*< page number in the physical stream, not logical */
367 	for (;;) {
368 		ot::status rc = reader.next_page();
369 		if (rc == ot::st::end_of_stream)
370 			break;
371 		else if (rc == ot::st::bad_stream && absolute_page_no == -1)
372 			return {ot::st::bad_stream, "Input is not a valid Ogg file."};
373 		else if (rc != ot::st::ok)
374 			return rc;
375 		++absolute_page_no;
376 		auto serialno = ogg_page_serialno(&reader.page);
377 		auto pageno = ogg_page_pageno(&reader.page);
378 		if (!focused) {
379 			focused = true;
380 			focused_serialno = serialno;
381 		} else if (serialno != focused_serialno) {
382 			/** \todo Support mixed streams. */
383 			return {ot::st::error, "Muxed streams are not supported yet."};
384 		}
385 		if (absolute_page_no == 0) { // Identification header
386 			if (!ot::is_opus_stream(reader.page))
387 				return {ot::st::error, "Not an Opus stream."};
388 			if (writer) {
389 				rc = writer->write_page(reader.page);
390 				if (rc != ot::st::ok)
391 					return rc;
392 			}
393 		} else if (absolute_page_no == 1) { // Comment header
394 			ot::opus_tags tags;
395 			rc = reader.process_header_packet(
396 				[&tags](ogg_packet& p) { return ot::parse_tags(p, tags); });
397 			if (rc != ot::st::ok)
398 				return rc;
399 			if ((rc = edit_tags(tags, opt)) != ot::st::ok)
400 				return rc;
401 			if (writer) {
402 				if (opt.edit_interactively) {
403 					fflush(writer->file); // flush before calling the subprocess
404 					if ((rc = edit_tags_interactively(tags, writer->path, opt.raw)) != ot::st::ok)
405 						return rc;
406 				}
407 				auto packet = ot::render_tags(tags);
408 				rc = writer->write_header_packet(serialno, pageno, packet);
409 				if (rc != ot::st::ok)
410 					return rc;
411 			} else {
412 				if ((rc = ot::print_comments(tags.comments, stdout, opt.raw)) != ot::st::ok)
413 					return rc;
414 				break;
415 			}
416 		} else {
417 			if (writer && (rc = writer->write_page(reader.page)) != ot::st::ok)
418 				return rc;
419 		}
420 	}
421 	if (absolute_page_no < 1)
422 		return {ot::st::error, "Expected at least 2 Ogg pages."};
423 	return ot::st::ok;
424 }
425 
run_single(const ot::options & opt,const std::string & path_in,const std::optional<std::string> & path_out)426 static ot::status run_single(const ot::options& opt, const std::string& path_in, const std::optional<std::string>& path_out)
427 {
428 	ot::file input;
429 	if (path_in == "-")
430 		input = stdin;
431 	else if ((input = fopen(path_in.c_str(), "re")) == nullptr)
432 		return {ot::st::standard_error,
433 		        "Could not open '" + path_in + "' for reading: " + strerror(errno)};
434 	ot::ogg_reader reader(input.get());
435 
436 	/* Read-only mode. */
437 	if (!path_out)
438 		return process(reader, nullptr, opt);
439 
440 	/* Read-write mode.
441 	 *
442 	 * The output pointer is set to one of:
443 	 *  - stdout for "-",
444 	 *  - final_output.get() for special files like /dev/null,
445 	 *  - temporary_output.get() for regular files.
446 	 *
447 	 * We use a temporary output file for the following reasons:
448 	 *  1. A partial .opus output would be seen by softwares like media players, but a .part
449 	 *     (for partial) won’t.
450 	 *  2. If the process crashes badly, or the power cuts off, we don't want to leave a partial
451 	 *     file at the final location. The temporary file is going to remain though.
452 	 *  3. If we're overwriting a regular file, we'd rather avoid wiping its content before we
453 	 *     even started reading the input file. That way, the original file is always preserved
454 	 *     on error or crash.
455 	 *  4. It is necessary for in-place editing. We can't reliably open the same file as both
456 	 *     input and output.
457 	 */
458 
459 	FILE* output = nullptr;
460 	ot::partial_file temporary_output;
461 	ot::file final_output;
462 
463 	ot::status rc = ot::st::ok;
464 	struct stat output_info;
465 	if (path_out == "-") {
466 		output = stdout;
467 	} else if (stat(path_out->c_str(), &output_info) == 0) {
468 		/* The output file exists. */
469 		if (!S_ISREG(output_info.st_mode)) {
470 			/* Special files are opened for writing directly. */
471 			if ((final_output = fopen(path_out->c_str(), "we")) == nullptr)
472 				rc = {ot::st::standard_error,
473 				      "Could not open '" + path_out.value() + "' for writing: " +
474 				      strerror(errno)};
475 			output = final_output.get();
476 		} else if (opt.overwrite) {
477 			rc = temporary_output.open(path_out->c_str());
478 			output = temporary_output.get();
479 		} else {
480 			rc = {ot::st::error,
481 			      "'" + path_out.value() + "' already exists. Use -y to overwrite."};
482 		}
483 	} else if (errno == ENOENT) {
484 		rc = temporary_output.open(path_out->c_str());
485 		output = temporary_output.get();
486 	} else {
487 		rc = {ot::st::error,
488 		      "Could not identify '" + path_in + "': " + strerror(errno)};
489 	}
490 	if (rc != ot::st::ok)
491 		return rc;
492 
493 	ot::ogg_writer writer(output);
494 	writer.path = path_out;
495 	rc = process(reader, &writer, opt);
496 	if (rc == ot::st::ok)
497 		rc = temporary_output.commit();
498 
499 	return rc;
500 }
501 
run(const ot::options & opt)502 ot::status ot::run(const ot::options& opt)
503 {
504 	if (opt.print_help) {
505 		fputs(help_message, stdout);
506 		return st::ok;
507 	}
508 
509 	ot::status global_rc = st::ok;
510 	for (const auto& path_in : opt.paths_in) {
511 		ot::status rc = run_single(opt, path_in, opt.in_place ? path_in : opt.path_out);
512 		if (rc != st::ok) {
513 			global_rc = st::error;
514 			if (!rc.message.empty())
515 				fprintf(stderr, "%s: error: %s\n", path_in.c_str(), rc.message.c_str());
516 		}
517 	}
518 	return global_rc;
519 }
520