diff --git a/include/bx/file.h b/include/bx/file.h index 1b91af1..9674ba4 100644 --- a/include/bx/file.h +++ b/include/bx/file.h @@ -138,6 +138,18 @@ namespace bx /// bool removeAll(const FilePath& _filePath, Error* _err = ErrorIgnore{}); + /// Copies file contents. + /// + bool copy(const FilePath& _from, const FilePath& _to, Error* _err = ErrorIgnore{}); + + /// Moves file or directory. + /// + bool move(const FilePath& _from, const FilePath& _to, Error* _err = ErrorIgnore{}); + + /// Moves file or directory into OS trash (Recycle Bin). + /// + bool moveToTrash(const FilePath& _filePath, Error* _err = ErrorIgnore{}); + } // namespace bx #endif // BX_FILE_H_HEADER_GUARD diff --git a/include/bx/url.h b/include/bx/url.h index 93130eb..dee2d7a 100644 --- a/include/bx/url.h +++ b/include/bx/url.h @@ -10,33 +10,51 @@ namespace bx { + /// Splits URL into tokens. Tokens are views into the parsed string, and they + /// don't own any storage. /// class UrlView { public: + /// URL token. + /// enum Enum { - Scheme, - UserName, - Password, - Host, - Port, - Path, - Query, - Fragment, + Scheme, //!< Scheme, without `://` separator. + UserName, //!< User name from authority's user info. + Password, //!< Password from authority's user info. + Host, //!< Host name, or IP address. + Port, //!< Port, as it appears in URL, without `:` separator. + Path, //!< Path, including leading `/`. + Query, //!< Query, without leading `?`. + Fragment, //!< Fragment, without leading `#`. Count }; + /// Constructor. All tokens are empty until `parse` succeeds. /// UrlView(); + /// Reset all tokens to empty string view. /// void clear(); + /// Parse URL into tokens. + /// + /// @param[in] _url URL to parse. It must have scheme, or path, or both. + /// It's not copied, and it must outlive UrlView. + /// + /// @returns True if URL is parsed successfully. Otherwise tokens are + /// left in unspecified state, and must not be used. /// bool parse(const StringView& _url); + /// Get token. + /// + /// @param[in] _token Token to get. + /// + /// @returns Token, or empty string view if URL doesn't contain it. /// const StringView& get(Enum _token) const; @@ -44,8 +62,39 @@ namespace bx StringView m_tokens[Count]; }; + /// Characters `urlEncode` passes through unescaped, in addition to the + /// unreserved set `A-Z a-z 0-9 - _ . ~`. /// - void urlEncode(char* _out, uint32_t _max, const StringView& _str); + struct UrlEncoding + { + /// Encoding. + /// + enum Enum + { + Component, //!< Nothing. Use for single path segment, query value, etc. + Path, //!< `/` separators. Use for whole path. + + Count + }; + }; + + /// Percent-encode string. + /// + /// @param[out] _out Output buffer. It's always zero terminated, unless + /// `_max` is zero. Encoding stops on truncation, and escape sequence is + /// never split. + /// @param[in] _max Size of output buffer, including zero terminator. + /// @param[in] _str String to encode. + /// @param[in] _encoding Characters to pass through unescaped. + /// + /// @returns Number of characters written, excluding zero terminator. + /// + int32_t urlEncode( + char* _out + , int32_t _max + , const StringView& _str + , UrlEncoding::Enum _encoding = UrlEncoding::Component + ); } // namespace bx diff --git a/src/file.cpp b/src/file.cpp index 4ed8a70..cf9696d 100644 --- a/src/file.cpp +++ b/src/file.cpp @@ -4,6 +4,8 @@ */ #include +#include +#include #ifndef BX_CONFIG_CRT_FILE_READER_WRITER # define BX_CONFIG_CRT_FILE_READER_WRITER !BX_CRT_NONE @@ -19,15 +21,26 @@ # if BX_CONFIG_CRT_DIRECTORY_READER # include # endif // BX_CONFIG_CRT_DIRECTORY_READER -# include // remove +# include // open +# include // remove, rename # include // stat, mkdir +# include // time, localtime # if BX_CRT_MSVC # include // _getcwd +# include // _close, _open # else -# include // getcwd +# include // close, getcwd # endif // BX_CRT_MSVC #endif // !BX_CRT_NONE +#if BX_PLATFORM_WINDOWS +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif // WIN32_LEAN_AND_MEAN +# include +# include // SHFileOperationW +#endif // BX_PLATFORM_WINDOWS + namespace bx { class NoopWriterImpl : public FileWriterI @@ -960,4 +973,445 @@ namespace bx return remove(_filePath, _err); } +#if BX_PLATFORM_WINDOWS + static int32_t widen(wchar_t* _out, int32_t _max, const FilePath& _filePath) + { + return MultiByteToWideChar(CP_UTF8, 0, _filePath.getCPtr(), -1, _out, _max); + } +#endif // BX_PLATFORM_WINDOWS + + bool copy(const FilePath& _from, const FilePath& _to, Error* _err) + { + BX_ERROR_SCOPE(_err); + + if (!_err->isOk() ) + { + return false; + } + + FileReader reader; + + if (!reader.open(_from, _err) ) + { + return false; + } + + FileWriter writer; + + if (!writer.open(_to, false, _err) ) + { + reader.close(); + return false; + } + + bool result = true; + uint8_t buffer[4096]; + + while (result) + { + Error err; + const int32_t size = reader.read(buffer, sizeof(buffer), &err); + + if (0 < size) + { + result = size == writer.write(buffer, size, _err); + } + + if (!err.isOk() ) + { + result = result && err == kErrorReaderWriterEof; + break; + } + } + + writer.close(); + reader.close(); + + if (!result + && _err->isOk() ) + { + BX_ERROR_SET(_err, kErrorAccess, "Failed to copy file."); + } + + return result; + } + + bool move(const FilePath& _from, const FilePath& _to, Error* _err) + { + BX_ERROR_SCOPE(_err); + + if (!_err->isOk() ) + { + return false; + } + +#if BX_PLATFORM_WINDOWS + wchar_t from[kMaxFilePath+1]; + wchar_t to[kMaxFilePath+1]; + + if (0 == widen(from, BX_COUNTOF(from), _from) + || 0 == widen(to, BX_COUNTOF(to), _to) ) + { + BX_ERROR_SET(_err, kErrorAccess, "Failed to convert file path."); + return false; + } + + if (0 == MoveFileExW(from, to, MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED) ) + { + BX_ERROR_SET(_err, kErrorAccess, "Failed to move file."); + return false; + } + + return true; +#else +# if BX_CRT_NONE + int32_t result = -1; +# else + int32_t result = ::rename(_from.getCPtr(), _to.getCPtr() ); +# endif // BX_CRT_NONE + + if (0 == result) + { + return true; + } + + if (!copy(_from, _to, _err) ) + { + return false; + } + + return remove(_from, _err); +#endif // BX_PLATFORM_WINDOWS + } + + [[maybe_unused]] static bool claimFilePath(const FilePath& _filePath, bool _isDir) + { + if (_isDir) + { + return make(_filePath); + } + +#if BX_CRT_NONE + BX_UNUSED(_filePath); + return false; +#else +# if BX_CRT_MSVC + const int32_t fd = ::_open(_filePath.getCPtr(), _O_WRONLY | _O_CREAT | _O_EXCL, _S_IREAD | _S_IWRITE); +# else + const int32_t fd = ::open(_filePath.getCPtr(), O_WRONLY | O_CREAT | O_EXCL, 0600); +# endif // BX_CRT_MSVC + + if (0 > fd) + { + return false; + } + +# if BX_CRT_MSVC + ::_close(fd); +# else + ::close(fd); +# endif // BX_CRT_MSVC + + return true; +#endif // BX_CRT_NONE + } + +#if BX_PLATFORM_WINDOWS + static bool moveToTrashImpl(const FilePath& _filePath, Error* _err) + { + wchar_t from[kMaxFilePath+1] = {}; + + const int32_t num = widen(from, BX_COUNTOF(from)-1, _filePath); + + if (0 == num) + { + BX_ERROR_SET(_err, kErrorAccess, "Trash: Failed to convert file path."); + return false; + } + + for (int32_t ii = 0; ii < num; ++ii) + { + if (L'/' == from[ii]) + { + from[ii] = L'\\'; + } + } + + void* shell32 = dlopen("shell32.dll"); + + if (NULL == shell32) + { + BX_ERROR_SET(_err, kErrorAccess, "Trash: Failed to load `shell32.dll`."); + return false; + } + + typedef int (WINAPI *SHFileOperationWFn)(SHFILEOPSTRUCTW*); + SHFileOperationWFn shFileOperationW = dlsym(shell32, "SHFileOperationW"); + + int32_t result = -1; + + if (NULL != shFileOperationW) + { + SHFILEOPSTRUCTW op = {}; + op.wFunc = FO_DELETE; + op.pFrom = from; + op.fFlags = FOF_ALLOWUNDO | FOF_NO_UI; + + result = shFileOperationW(&op); + } + + dlclose(shell32); + + if (0 != result) + { + BX_ERROR_SET(_err, kErrorAccess, "Trash: Failed to move file into Recycle Bin."); + return false; + } + + return true; + } +#elif BX_CRT_NONE + static bool moveToTrashImpl(const FilePath& _filePath, Error* _err) + { + BX_UNUSED(_filePath); + + BX_ERROR_SET(_err, kErrorAccess, "Trash: Not supported."); + return false; + } +#else + static bool reserveTrashPath( + FilePath& _outFilePath + , FilePath& _outInfoPath + , const FilePath& _filesDir + , const FilePath& _infoDir + , const StringView& _fileName + , const tm* _lt + , bool _isDir + , Error* _err + ) + { +#if BX_PLATFORM_OSX + BX_UNUSED(_outInfoPath, _infoDir); +#endif // BX_PLATFORM_OSX + + const FilePath tmp(_fileName); + const StringView baseName = tmp.getBaseName(); + const StringView ext = tmp.getExt(); + + for (int32_t ii = 0; ii < 8; ++ii) + { + char name[kMaxFilePath]; + + if (0 == ii) + { + strCopy(name, BX_COUNTOF(name), _fileName); + } + else + { + snprintf(name, BX_COUNTOF(name), "%.*s.%04d%02d%02dT%02d%02d%02d-%d%.*s" + , baseName.getLength() + , baseName.getPtr() + , _lt->tm_year + 1900 + , _lt->tm_mon + 1 + , _lt->tm_mday + , _lt->tm_hour + , _lt->tm_min + , _lt->tm_sec + , ii + , ext.getLength() + , ext.getPtr() + ); + } + +#if !BX_PLATFORM_OSX + char infoName[kMaxFilePath]; + snprintf(infoName, BX_COUNTOF(infoName), "%s.trashinfo", name); + + FilePath infoPath(_infoDir); + infoPath.join(infoName); + + if (!claimFilePath(infoPath, false) ) + { + continue; + } +#endif // !BX_PLATFORM_OSX + + FilePath filePath(_filesDir); + filePath.join(name); + + if (!claimFilePath(filePath, _isDir) ) + { +#if !BX_PLATFORM_OSX + remove(infoPath); +#endif // !BX_PLATFORM_OSX + continue; + } + + _outFilePath = filePath; + +#if !BX_PLATFORM_OSX + _outInfoPath = infoPath; +#endif // !BX_PLATFORM_OSX + + return true; + } + + BX_ERROR_SET(_err, kErrorAccess, "Trash: Failed to reserve unique file name."); + + return false; + } + + static bool moveToTrashImpl(const FilePath& _filePath, Error* _err) + { + FilePath trashDir; + FilePath filesDir; + FilePath infoDir; + +#if BX_PLATFORM_OSX + trashDir.set(Dir::Home); + trashDir.join(".Trash"); + + filesDir = trashDir; +#else + // Reference(s): + // - The FreeDesktop.org Trash specification + // https://specifications.freedesktop.org/trash-spec/latest/ + // + char tmp[kMaxFilePath]; + uint32_t size = BX_COUNTOF(tmp); + + if (getEnv(tmp, &size, "XDG_DATA_HOME") + && 0 < size) + { + trashDir.set(StringView(tmp, size) ); + } + else + { + trashDir.set(Dir::Home); + trashDir.join(".local/share"); + } + + trashDir.join("Trash"); + + filesDir = trashDir; + filesDir.join("files"); + + infoDir = trashDir; + infoDir.join("info"); +#endif // BX_PLATFORM_OSX + + if (!makeAll(filesDir, _err) ) + { + return false; + } + +#if !BX_PLATFORM_OSX + if (!makeAll(infoDir, _err) ) + { + return false; + } +#endif // !BX_PLATFORM_OSX + + const time_t tt = ::time(NULL); + const tm* lt = ::localtime(&tt); + + FileInfo fi; + + if (!stat(fi, _filePath) ) + { + BX_ERROR_SET(_err, kErrorAccess, "Trash: File doesn't exist."); + return false; + } + + FilePath dstPath; + FilePath infoPath; + + if (!reserveTrashPath(dstPath, infoPath, filesDir, infoDir, _filePath.getFileName(), lt, FileType::Dir == fi.type, _err) ) + { + return false; + } + +#if !BX_PLATFORM_OSX + { + FileWriter writer; + + if (!writer.open(infoPath, false, _err) ) + { + remove(infoPath); + remove(dstPath); + return false; + } + + char url[kMaxFilePath*3]; + urlEncode(url, BX_COUNTOF(url), _filePath, UrlEncoding::Path); + + char info[BX_COUNTOF(url) + 128]; + const int32_t len = snprintf(info, BX_COUNTOF(info) + , "[Trash Info]\nPath=%s\nDeletionDate=%04d-%02d-%02dT%02d:%02d:%02d\n" + , url + , lt->tm_year + 1900 + , lt->tm_mon + 1 + , lt->tm_mday + , lt->tm_hour + , lt->tm_min + , lt->tm_sec + ); + + write(&writer, info, len, _err); + writer.close(); + + if (!_err->isOk() ) + { + remove(infoPath); + remove(dstPath); + return false; + } + } +#endif // !BX_PLATFORM_OSX + + if (!move(_filePath, dstPath, _err) ) + { +#if !BX_PLATFORM_OSX + remove(infoPath); +#endif // !BX_PLATFORM_OSX + remove(dstPath); + return false; + } + + return true; + } +#endif // BX_PLATFORM_WINDOWS + + bool moveToTrash(const FilePath& _filePath, Error* _err) + { + BX_ERROR_SCOPE(_err); + + if (!_err->isOk() ) + { + return false; + } + + FileInfo fi; + + if (!stat(fi, _filePath) ) + { + BX_ERROR_SET(_err, kErrorAccess, "Trash: File doesn't exist."); + return false; + } + + FilePath filePath; + + if (_filePath.isAbsolute() ) + { + filePath = _filePath; + } + else + { + filePath.set(Dir::Current); + filePath.join(_filePath); + } + + return moveToTrashImpl(filePath, _err); + } + } // namespace bx diff --git a/src/url.cpp b/src/url.cpp index ca6c3e6..d67f28d 100644 --- a/src/url.cpp +++ b/src/url.cpp @@ -127,42 +127,59 @@ namespace bx return m_tokens[_token]; } - static char toHex(char _nible) + static char toHex(uint8_t _nible) { return "0123456789ABCDEF"[_nible&0xf]; } // https://secure.wikimedia.org/wikipedia/en/wiki/URL_encoding - void urlEncode(char* _out, uint32_t _max, const StringView& _str) + int32_t urlEncode(char* _out, int32_t _max, const StringView& _str, UrlEncoding::Enum _encoding) { - _max--; // need space for zero terminator - - const char* str = _str.getPtr(); - const char* term = _str.getTerm(); - - uint32_t ii = 0; - for (char ch = *str++ - ; str <= term && ii < _max - ; ch = *str++ - ) + if (0 >= _max) { + return 0; + } + + const char* str = _str.getPtr(); + + const int32_t max = _max-1; // need space for zero terminator + int32_t len = 0; + + for (int32_t ii = 0, num = _str.getLength(); ii < num; ++ii) + { + const char ch = str[ii]; + if (isAlphaNum(ch) - || ch == '-' - || ch == '_' - || ch == '.' - || ch == '~') + || '-' == ch + || '_' == ch + || '.' == ch + || '~' == ch + || ('/' == ch && UrlEncoding::Path == _encoding) ) { - _out[ii++] = ch; + if (max < len+1) + { + break; + } + + _out[len++] = ch; } - else if (ii+3 < _max) + else { - _out[ii++] = '%'; - _out[ii++] = toHex(ch>>4); - _out[ii++] = toHex(ch); + // Escape sequence must not be split by truncation. + if (max < len+3) + { + break; + } + + _out[len++] = '%'; + _out[len++] = toHex(uint8_t(ch)>>4); + _out[len++] = toHex(uint8_t(ch) ); } } - _out[ii] = '\0'; + _out[len] = '\0'; + + return len; } } // namespace bx diff --git a/tests/url_test.cpp b/tests/url_test.cpp index 15c6c79..dded4f2 100644 --- a/tests/url_test.cpp +++ b/tests/url_test.cpp @@ -100,3 +100,74 @@ TEST_CASE("tokenizeUrl", "[url][string]") } } } + +struct UrlEncodeTest +{ + const char* input; + bx::UrlEncoding::Enum encoding; + const char* expected; +}; + +static const UrlEncodeTest s_urlEncodeTest[] = +{ + { "", bx::UrlEncoding::Component, "" }, + { "abcABC123", bx::UrlEncoding::Component, "abcABC123" }, + { "-_.~", bx::UrlEncoding::Component, "-_.~" }, + { "a b", bx::UrlEncoding::Component, "a%20b" }, + { "100%", bx::UrlEncoding::Component, "100%25" }, + { "a+b=c&d", bx::UrlEncoding::Component, "a%2Bb%3Dc%26d" }, + { "\xc3\xa9", bx::UrlEncoding::Component, "%C3%A9" }, + { "/tmp/a b.txt", bx::UrlEncoding::Component, "%2Ftmp%2Fa%20b.txt" }, + { "/tmp/a b.txt", bx::UrlEncoding::Path, "/tmp/a%20b.txt" }, + { "\xc3\xa9", bx::UrlEncoding::Path, "%C3%A9" }, +}; + +TEST_CASE("urlEncode", "[url][string]") +{ + for (uint32_t ii = 0; ii < BX_COUNTOF(s_urlEncodeTest); ++ii) + { + const UrlEncodeTest& test = s_urlEncodeTest[ii]; + + char tmp[128]; + const int32_t len = bx::urlEncode(tmp, BX_COUNTOF(tmp), test.input, test.encoding); + + REQUIRE(0 == bx::strCmp(test.expected, tmp) ); + REQUIRE(len == bx::strLen(test.expected) ); + } +} + +TEST_CASE("urlEncode truncate", "[url][string]") +{ + { + char tmp[4]; + REQUIRE(3 == bx::urlEncode(tmp, BX_COUNTOF(tmp), "abcdef") ); + REQUIRE(0 == bx::strCmp("abc", tmp) ); + } + + { + char tmp[4]; + REQUIRE(3 == bx::urlEncode(tmp, BX_COUNTOF(tmp), " ") ); + REQUIRE(0 == bx::strCmp("%20", tmp) ); + } + + { + char tmp[3]; + REQUIRE(0 == bx::urlEncode(tmp, BX_COUNTOF(tmp), " ") ); + REQUIRE(0 == bx::strCmp("", tmp) ); + } + + { + char tmp[1]; + REQUIRE(0 == bx::urlEncode(tmp, BX_COUNTOF(tmp), "abc") ); + REQUIRE(0 == bx::strCmp("", tmp) ); + } +} + +TEST_CASE("urlEncode StringView", "[url][string]") +{ + const bx::StringView str("abc def", 3); + + char tmp[128]; + REQUIRE(3 == bx::urlEncode(tmp, BX_COUNTOF(tmp), str) ); + REQUIRE(0 == bx::strCmp("abc", tmp) ); +}