diff --git a/CMakeLists.txt b/CMakeLists.txt index 187a8db..52e0df6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,6 +30,21 @@ include(CheckStructHasMember) check_struct_has_member("struct stat" st_mtim sys/stat.h HAVE_STAT_ST_MTIM LANGUAGE CXX) check_struct_has_member("struct stat" st_mtimespec sys/stat.h HAVE_STAT_ST_MTIMESPEC LANGUAGE CXX) +# getdelim and mkstemps are POSIX/BSD extensions that MSVCRT/UCRT does not +# provide. win32_compat.h supplies fallback implementations, scoped to +# Windows only, guarded by these HAVE_* macros so we only compile them in +# when the target runtime doesn't already have the real thing. + +include(CheckFunctionExists) +check_function_exists(getdelim HAVE_GETDELIM) +check_function_exists(mkstemps HAVE_MKSTEMPS) +if(HAVE_GETDELIM) + add_definitions(-DHAVE_GETDELIM) +endif() +if(HAVE_MKSTEMPS) + add_definitions(-DHAVE_MKSTEMPS) +endif() + configure_file(src/config.h.in config.h @ONLY) include_directories(BEFORE src "${CMAKE_BINARY_DIR}" ${OGG_INCLUDE_DIRS} ${Iconv_INCLUDE_DIRS}) diff --git a/src/cli.cc b/src/cli.cc index 0635d30..bbb6401 100644 --- a/src/cli.cc +++ b/src/cli.cc @@ -17,6 +17,12 @@ #include #include +#ifdef _WIN32 +#include +#include +#include "win32_compat.h" +#endif + static const char help_message[] = PROJECT_NAME " version " PROJECT_VERSION R"raw( @@ -442,7 +448,7 @@ static void edit_tags_interactively(ot::opus_tags& tags, const std::optionalc_str(), "w"); + output = fopen(opt.cover_out->c_str(), OT_FOPEN_W); if (output == nullptr) throw ot::status {ot::st::standard_error, "Could not open '" + opt.cover_out.value() + "' for writing: " + strerror(errno)}; } @@ -557,7 +563,7 @@ static void run_single(const ot::options& opt, const std::string& path_in, const ot::file input; if (path_in == "-") input = stdin; - else if ((input = fopen(path_in.c_str(), "re")) == nullptr) + else if ((input = fopen(path_in.c_str(), OT_FOPEN_RE)) == nullptr) throw ot::status {ot::st::standard_error, "Could not open '" + path_in + "' for reading: " + strerror(errno)}; ot::ogg_reader reader(input.get()); @@ -598,7 +604,7 @@ static void run_single(const ot::options& opt, const std::string& path_in, const /* The output file exists. */ if (!S_ISREG(output_info.st_mode)) { /* Special files are opened for writing directly. */ - if ((final_output = fopen(path_out->c_str(), "we")) == nullptr) + if ((final_output = fopen(path_out->c_str(), OT_FOPEN_WE)) == nullptr) throw ot::status {ot::st::standard_error, "Could not open '" + path_out.value() + "' for writing: " + strerror(errno)}; output = final_output.get(); diff --git a/src/opustags.h b/src/opustags.h index ec41596..aed3fa1 100644 --- a/src/opustags.h +++ b/src/opustags.h @@ -31,6 +31,7 @@ #include #include +#include #include #include #include @@ -55,6 +56,40 @@ #define be32toh(x) OSSwapBigToHostInt32(x) #endif +#ifdef _WIN32 +// Windows has no . It's always little-endian in practice, so the +// LE conversions are no-ops; for BE, use the compiler's byte-swap builtin +// rather than hand-rolled masking/shifting. Both MinGW-w64 (GCC/Clang) and +// MSVC provide one. + +# if defined(__GNUC__) || defined(__clang__) +inline uint32_t htobe32(uint32_t x) { return __builtin_bswap32(x); } +inline uint32_t be32toh(uint32_t x) { return __builtin_bswap32(x); } +# else +# include +inline uint32_t htobe32(uint32_t x) { return _byteswap_ulong(x); } +inline uint32_t be32toh(uint32_t x) { return _byteswap_ulong(x); } +# endif +inline uint32_t htole32(uint32_t x) { return x; } +inline uint32_t le32toh(uint32_t x) { return x; } +#endif + +#ifdef _WIN32 +// MSVCRT/UCRT's fopen does not understand the glibc "e" mode character +// (O_CLOEXEC). We gate it per platform instead of dropping it outright, so +// POSIX builds keep close-on-exec, and Windows builds get explicit binary +// mode, which it needs since text mode does CRLF translation that would +// corrupt tag data and Ogg streams. + +# define OT_FOPEN_RE "rb" +# define OT_FOPEN_WE "wb" +# define OT_FOPEN_W "wb" +#else +# define OT_FOPEN_RE "re" +# define OT_FOPEN_WE "we" +# define OT_FOPEN_W "w" +#endif + using namespace std::literals; namespace ot { diff --git a/src/system.cc b/src/system.cc index 7bf1920..75797b7 100644 --- a/src/system.cc +++ b/src/system.cc @@ -16,9 +16,117 @@ #include #include #include -#include #include +#ifdef _WIN32 +#include +#include +#include +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include "win32_compat.h" +#else +#include +#endif + +#ifdef _WIN32 +// getdelim() and mkstemps() are POSIX/BSD extensions the MSVCRT/UCRT does +// not provide. HAVE_GETDELIM / HAVE_MKSTEMPS are defined by CMake via +// check_function_exists() when the target runtime already has them, so +// these definitions only compile in when actually needed, rather than +// relying on a macro-name guard that can't detect a real function. +// +// These are scoped to Windows only and match the real functions' exact +// signatures, so they're drop-in replacements: ot::read_comments() in +// cli.cc calls getdelim() exactly the same way on every platform, and +// this file is the only place that changes. + +#ifndef HAVE_GETDELIM +ssize_t getdelim(char** lineptr, size_t* n, int delim, FILE* stream) +{ + if (lineptr == nullptr || n == nullptr || stream == nullptr) { + errno = EINVAL; + return -1; + } + + // Accumulate into a std::string, which grows itself, rather than + // manually doubling a malloc'd buffer by hand. We still hand the + // result back through the caller's malloc'd buffer at the end, since + // that's the contract getdelim() callers (and free()) expect. + std::string buffer; + int c; + while ((c = fgetc(stream)) != EOF) { + buffer.push_back(static_cast(c)); + if (c == delim) + break; + } + if (buffer.empty()) + return -1; // Nothing left to read. + + size_t needed = buffer.size() + 1; // +1 for the null terminator. + if (*lineptr == nullptr || *n < needed) { + char* newbuf = static_cast(realloc(*lineptr, needed)); + if (newbuf == nullptr) + return -1; + *lineptr = newbuf; + *n = needed; + } + memcpy(*lineptr, buffer.data(), buffer.size()); + (*lineptr)[buffer.size()] = '\0'; + return static_cast(buffer.size()); +} +#endif // HAVE_GETDELIM + +#ifndef HAVE_MKSTEMPS +int mkstemps(char* tmpl, int suffixlen) +{ + size_t len = strlen(tmpl); + if (suffixlen < 0 || len < static_cast(6 + suffixlen)) + return -1; + size_t placeholder_end = len - static_cast(suffixlen); + + // _mktemp_s requires the "XXXXXX" placeholder to be the last six + // characters of the string it operates on, but mkstemps' templates + // have a suffix after the placeholder (e.g. ".part"), which _mktemp_s + // does not support directly. Work around this by temporarily + // truncating the suffix off, letting _mktemp_s fill in the XXXXXX + // portion with its own (better than rand()) uniqueness algorithm, + // then restoring the suffix. + + char saved_suffix[32]; + if (static_cast(suffixlen) >= sizeof(saved_suffix)) + return -1; + memcpy(saved_suffix, tmpl + placeholder_end, static_cast(suffixlen)); + tmpl[placeholder_end] = '\0'; + + errno_t err = _mktemp_s(tmpl, placeholder_end + 1); + + memcpy(tmpl + placeholder_end, saved_suffix, static_cast(suffixlen)); + tmpl[len] = '\0'; + + if (err != 0) { + errno = err; + return -1; + } + + // _mktemp_s only picks a name, it does not create or open the file, so + // the O_CREAT|O_EXCL open below is what actually gives us mkstemps' + // atomicity guarantee (the name didn't exist and now it's ours). + + int fd; + if (_sopen_s(&fd, tmpl, _O_CREAT | _O_EXCL | _O_RDWR | _O_BINARY, _SH_DENYNO, + _S_IREAD | _S_IWRITE) != 0) + return -1; + return fd; +} +#endif // HAVE_MKSTEMPS +#endif // _WIN32 + void ot::close_file(FILE* file) { fclose(file); @@ -40,6 +148,7 @@ void ot::partial_file::open(const char* destination) strerror(errno)}; } +#ifndef _WIN32 static mode_t get_umask() { // libc doesn’t seem to provide a way to get umask without changing it, so we need this workaround. @@ -72,13 +181,27 @@ static void copy_permissions(const char* source, const char* dest) if (chmod(dest, target_mode) == -1) fprintf(stderr, "warning: Could not set mode of %s: %s\n", dest, strerror(errno)); } +#endif void ot::partial_file::commit() { if (file == nullptr) return; file.reset(); +#ifndef _WIN32 + // Windows does not use Unix-style file modes; the temporary file already has the correct + // default permissions. On Unix, we copy the original file's permissions. copy_permissions(final_name.c_str(), temporary_name.c_str()); +#endif + +#ifdef _WIN32 + // Windows rename() refuses to overwrite an existing file + if (remove(final_name.c_str()) != 0 && errno != ENOENT) { + throw status {st::standard_error, + "Could not remove original file '" + final_name + "': " + + strerror(errno) + "."}; + } +#endif if (rename(temporary_name.c_str(), final_name.c_str()) == -1) throw status {st::standard_error, "Could not move the result file '" + temporary_name + "' to '" + @@ -219,6 +342,34 @@ std::string ot::decode_utf8(std::u8string_view in) std::string ot::shell_escape(std::string_view word) { +#ifdef _WIN32 + if (!word.empty() && word.find_first_of(" \t\n\v\"") == std::string_view::npos) + return std::string(word); + + std::string escaped = "\""; + for (auto it = word.begin();; ++it) { + unsigned backslashes = 0; + while (it != word.end() && *it == '\\') { + ++backslashes; + ++it; + } + if (it == word.end()) { + // Escape all backslashes, since they're followed by the closing quote. + escaped.append(backslashes * 2, '\\'); + break; + } else if (*it == '"') { + // Escape all backslashes and the quote itself. + escaped.append(backslashes * 2 + 1, '\\'); + escaped += '"'; + } else { + // Backslashes aren't special here. + escaped.append(backslashes, '\\'); + escaped += *it; + } + } + escaped += '"'; + return escaped; +#else std::string escaped_word; // Pre-allocate the result, assuming most of the time enclosing it in single quotes is enough. escaped_word.reserve(2 + word.size()); @@ -235,13 +386,56 @@ std::string ot::shell_escape(std::string_view word) escaped_word += '\''; return escaped_word; +#endif } +#ifdef _WIN32 +/** + * Resolve a path to an absolute one using the Win32 API directly, rather + * than std::filesystem::absolute(). libstdc++'s filesystem implementation + * on MinGW does a narrow<->wide character-set conversion internally and + * throws filesystem_error on byte sequences it considers "illegal" under + * the current locale/codepage -- this was observed to crash on ordinary + * accented Latin characters. + * + * GetFullPathNameA operates on raw bytes with no encoding validation at + * all, so it can't fail this way. + */ +static std::string win32_absolute_path(std::string_view path) +{ + std::string input(path); + char buffer[MAX_PATH]; + DWORD len = GetFullPathNameA(input.c_str(), MAX_PATH, buffer, nullptr); + if (len == 0 || len >= MAX_PATH) + return input; // Fall back to the original path if resolution fails. + return std::string(buffer, len); +} +#endif + void ot::run_editor(std::string_view editor, std::string_view path) { + // Always pass an absolute path to the editor. This is the surest way to + // avoid the editor misinterpreting the path as an option if it happens + // to start with '-' -- more reliable than "--", which not every editor + // respects the same way (observed difference in behavior between + // Notepad and Neovim on Windows). +#ifdef _WIN32 + std::string abs_path = win32_absolute_path(path); + std::string command = std::string(editor) + " " + shell_escape(abs_path); +#else std::string command = std::string(editor) + " -- " + shell_escape(path); +#endif + int status = system(command.c_str()); +#ifdef _WIN32 + // On Windows, system() returns the exit code directly (or -1 on error) + if (status == -1) + throw ot::status {st::standard_error, "system() error: "s + strerror(errno)}; + else if (status != 0) + throw ot::status {st::child_process_failed, + "Child process exited with " + std::to_string(status)}; +#else if (status == -1) throw ot::status {st::standard_error, "waitpid error: "s + strerror(errno)}; else if (!WIFEXITED(status)) @@ -250,6 +444,7 @@ void ot::run_editor(std::string_view editor, std::string_view path) else if (WEXITSTATUS(status) != 0) throw ot::status {st::child_process_failed, "Child process exited with " + std::to_string(WEXITSTATUS(status))}; +#endif } timespec ot::get_file_timestamp(const char* path) @@ -262,6 +457,9 @@ timespec ot::get_file_timestamp(const char* path) mtime = st.st_mtim; #elif defined(HAVE_STAT_ST_MTIMESPEC) mtime = st.st_mtimespec; +#elif defined(_WIN32) + mtime.tv_sec = st.st_mtime; + mtime.tv_nsec = 0; #else mtime.tv_sec = st.st_mtime; mtime.tv_nsec = st.st_mtimensec; diff --git a/src/win32_compat.h b/src/win32_compat.h new file mode 100644 index 0000000..6648f5e --- /dev/null +++ b/src/win32_compat.h @@ -0,0 +1,26 @@ +#pragma once +#ifdef _WIN32 + +#include +#include +#include +#include +#include + +#define strncasecmp _strnicmp + +// getdelim() and mkstemps() are POSIX/BSD extensions MSVCRT/UCRT does not +// provide. HAVE_GETDELIM / HAVE_MKSTEMPS are defined by CMake via +// check_function_exists() when the target runtime already has them, so +// these declarations (and their implementations in system.cc) only apply +// when actually needed. This is scoped to Windows only, so it has no +// effect on the Linux/Mac build. +#ifndef HAVE_GETDELIM +ssize_t getdelim(char** lineptr, size_t* n, int delim, FILE* stream); +#endif + +#ifndef HAVE_MKSTEMPS +int mkstemps(char* tmpl, int suffixlen); +#endif + +#endif