diff --git a/CBL_C.xcodeproj/project.pbxproj b/CBL_C.xcodeproj/project.pbxproj index 9f6364f3..cb06b9fe 100644 --- a/CBL_C.xcodeproj/project.pbxproj +++ b/CBL_C.xcodeproj/project.pbxproj @@ -20,6 +20,9 @@ 2736A634242E5A74002B9D65 /* ReplicatorEETest.cc in Sources */ = {isa = PBXBuildFile; fileRef = 2736A633242E5A74002B9D65 /* ReplicatorEETest.cc */; }; 2736A635242E5A74002B9D65 /* ReplicatorEETest.cc in Sources */ = {isa = PBXBuildFile; fileRef = 2736A633242E5A74002B9D65 /* ReplicatorEETest.cc */; }; 273CE7E22452123400D01CA2 /* libfleeceBase.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 27B61DBA21D6FF2D0027CCDB /* libfleeceBase.a */; }; + 274E22D42742D49A00B7D1AC /* CBLBlob+FILE.h in Headers */ = {isa = PBXBuildFile; fileRef = 274E22D22742D49A00B7D1AC /* CBLBlob+FILE.h */; }; + 274E22D52742D49A00B7D1AC /* CBLBlob+FILE.cc in Sources */ = {isa = PBXBuildFile; fileRef = 274E22D32742D49A00B7D1AC /* CBLBlob+FILE.cc */; }; + 274E22E32742F46900B7D1AC /* CBLBlob+FILE.cc in Sources */ = {isa = PBXBuildFile; fileRef = 274E22D32742D49A00B7D1AC /* CBLBlob+FILE.cc */; }; 275B3576234810C400FE9CF0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 275B3567234810C400FE9CF0 /* Foundation.framework */; }; 275B358723481AE700FE9CF0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 275B3567234810C400FE9CF0 /* Foundation.framework */; }; 275B358923481D0C00FE9CF0 /* CBLTest.cc in Sources */ = {isa = PBXBuildFile; fileRef = 27B61D6921D6B60D0027CCDB /* CBLTest.cc */; }; @@ -298,6 +301,8 @@ 2736A633242E5A74002B9D65 /* ReplicatorEETest.cc */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = ReplicatorEETest.cc; sourceTree = ""; }; 273CD2D025E81C8F00B93C59 /* cmake */ = {isa = PBXFileReference; lastKnownFileType = folder; path = cmake; sourceTree = ""; }; 274BAB9D24DA2DB900F4F810 /* generate_exports.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = generate_exports.sh; sourceTree = ""; }; + 274E22D22742D49A00B7D1AC /* CBLBlob+FILE.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "CBLBlob+FILE.h"; sourceTree = ""; }; + 274E22D32742D49A00B7D1AC /* CBLBlob+FILE.cc */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = "CBLBlob+FILE.cc"; sourceTree = ""; }; 275B3567234810C400FE9CF0 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; 275B357C234812C800FE9CF0 /* CouchbaseLiteTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CouchbaseLiteTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 275B358823481C5200FE9CF0 /* XCTests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = XCTests.xcconfig; sourceTree = ""; }; @@ -490,6 +495,8 @@ isa = PBXGroup; children = ( 275BC4F52209080E00DBE7D2 /* CBLBlob_Internal.hh */, + 274E22D22742D49A00B7D1AC /* CBLBlob+FILE.h */, + 274E22D32742D49A00B7D1AC /* CBLBlob+FILE.cc */, 271C2A7121CADB170045856E /* CBLDatabase.cc */, 27C9B5E021F655110040BC45 /* CBLDatabase_Internal.hh */, 27DBD097246C9DE7002FD7A7 /* CBLDatabase+Apple.mm */, @@ -662,6 +669,7 @@ 93C70CE026C4D3F80093E927 /* CBLEncryptable.h in Headers */, 271C2A3321CAC98F0045856E /* CBLDocument.h in Headers */, 277B77D3245B44BE00B222D3 /* CBLLog.h in Headers */, + 274E22D42742D49A00B7D1AC /* CBLBlob+FILE.h in Headers */, 27B61D6A21D6B60D0027CCDB /* CBLTest.hh in Headers */, 271C2A3521CAC98F0045856E /* CBLQuery.h in Headers */, 271C2A3421CAC98F0045856E /* CBLDatabase.h in Headers */, @@ -1179,6 +1187,7 @@ 277FEE7521ED3C4900B60E3C /* CBLReplicator_CAPI.cc in Sources */, 27D11BF02351043B00C58A70 /* ConflictResolver.cc in Sources */, 27886C8E21F64C1400069BEA /* Listener.cc in Sources */, + 274E22D52742D49A00B7D1AC /* CBLBlob+FILE.cc in Sources */, 271C2A7621CC4BD60045856E /* Internal.cc in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1230,6 +1239,7 @@ 27B61DB921D6ECA70027CCDB /* DatabaseTest.cc in Sources */, 277FEE5321E6BCA500B60E3C /* DatabaseTest_Cpp.cc in Sources */, 93C70D1826CB535D0093E927 /* ReplicatorPropEncTest.cc in Sources */, + 274E22E32742F46900B7D1AC /* CBLBlob+FILE.cc in Sources */, 275BC4F42204FB1400DBE7D2 /* BlobTest_Cpp.cc in Sources */, 93965A6326A7CD50008728EE /* LogTest.cc in Sources */, 275B359E234D064600FE9CF0 /* QueryTest.cc in Sources */, diff --git a/include/cbl/CBL_Compat.h b/include/cbl/CBL_Compat.h index 26a2773c..920080de 100644 --- a/include/cbl/CBL_Compat.h +++ b/include/cbl/CBL_Compat.h @@ -36,10 +36,12 @@ #define _cbl_nonnull _In_ #define _cbl_warn_unused _Check_return_ #define _cbl_deprecated + #define _cbl_unused #else #define CBLINLINE inline #define _cbl_warn_unused __attribute__((warn_unused_result)) #define _cbl_deprecated __attribute__((deprecated())) + #define _cbl_unused __attribute__((unused())) #endif // Macros for defining typed enumerations and option flags. diff --git a/src/CBLBlob+FILE.cc b/src/CBLBlob+FILE.cc new file mode 100644 index 00000000..881e566c --- /dev/null +++ b/src/CBLBlob+FILE.cc @@ -0,0 +1,217 @@ +// +// CBLBlob+FILE.cc +// +// Copyright © 2021 Couchbase. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "CBLBlob+FILE.h" +#include "CBLLog.h" + +using namespace std; +using namespace fleece; + + +/* + There are two nonstandard APIs in for opening a `FILE*` with custom read/write/seek + behavior: + - Apple platforms and BSD have `funopen`. + - GNU's libc (Linux) has a similar API called `fopencookie`. + - Sadly, Windows does not support this :( + + The two functions, and the callbacks they use, have slightly different parameter types and + semantics. Since `fopencookie`s callbacks have more sensible types, we've implemented those + and then added some `funopen`-compatible wrapper functions. + + `funopen` callback error reporting is consistent: + > All user I/O functions can report an error by returning -1. Additionally, + > all of the functions should set the external variable errno appropriately + > if an error occurs. + + `fopencookie`s man page doesn't mention setting `errno`, but presumably it's allowed. + The return values are somewhat inconsistent in that the write callback is supposed to return + 0, not -1, on error. (Even though the man page itself shows an example that returns -1...) + + References: + + + + */ + + +#ifndef _MSC_VER + +#ifdef __APPLE__ // ...or BSD... + #define USE_FUNOPEN +#endif + + +static inline int withErrno(int err) { + errno = err; + return -1; +} + + +#pragma mark - STDIO READ CALLBACKS: + + +static ssize_t readFn_cookie(void *cookie, char *dst, size_t len) noexcept { + int result = CBLBlobReader_Read((CBLBlobReadStream*)cookie, dst, len, nullptr); + if (result < 0) + return withErrno(EIO); + return result; +} + + +_cbl_unused +static int readFn_fun(void *cookie, char *dst, int len) { + if (len < 0) + return withErrno(EINVAL); + return int(readFn_cookie(cookie, dst, len)); +} + + +static int seekFn_cookie(void *cookie, int64_t *offset, int mode) noexcept { + CBLSeekBase base; + switch (mode) { + case SEEK_SET: base = kCBLSeekModeFromStart; break; + case SEEK_CUR: base = kCBLSeekModeRelative; break; + case SEEK_END: base = kCBLSeekModeFromEnd; break; + default: return withErrno(EINVAL); + } + int64_t newOffset = CBLBlobReader_Seek((CBLBlobReadStream*)cookie, *offset, base, nullptr); + if (newOffset < 0) + return withErrno(EINVAL); + *offset = newOffset; + return 0; +} + + +_cbl_unused +static fpos_t seekFn_fun(void *cookie, fpos_t pos, int mode) noexcept { + return (seekFn_cookie(cookie, &pos, mode) == 0) ? pos : -1; +} + + +static int closeReaderFn(void *cookie) noexcept { + CBLBlobReader_Close((CBLBlobReadStream*)cookie); + return 0; +} + + +#pragma mark - STDIO WRITE CALLBACKS: + + +static ssize_t writeFn_cookie(void *cookie, const char *src, size_t len) noexcept { + // "the write function should return the number of bytes copied from buf, or 0 on error. + // (The function must not return a negative value.)" --Linux man page + if (len == 0) { + errno = EINVAL; + return 0; + } + if (!CBLBlobWriter_Write((CBLBlobWriteStream*)cookie, src, len, nullptr)) { + errno = EIO; + return 0; + } + return len; +} + + +_cbl_unused +static int writeFn_fun(void *cookie, const char *src, int len) noexcept { + if (len < 0) + return withErrno(EINVAL); + if (len == 0) + return 0; + auto bytesWritten = writeFn_cookie(cookie, src, len); + return bytesWritten > 0 ? int(bytesWritten) : -1; +} + + +// Coordinator between `closeWriterFn` and `CBLBlobWriter_CreateFILE`. +// (It's thread-local to avoid race conditions if multiple threads create blobs at once.) +__thread static CBLBlobWriteStream** sPutStreamHereOnClose = nullptr; + + +static int closeWriterFn(void *cookie) noexcept { + if (sPutStreamHereOnClose) { + // Instead of actually closing, copy the pointer to the blob write stream where the + // `CBLBlob_CreateWithFILE` function can retrieve it. + *sPutStreamHereOnClose = (CBLBlobWriteStream*)cookie; + } else { + // If our secret pointer is NULL, then `CBLBlobWriter_CreateFILE` isn't being called, + // so the app must just be calling `fclose` itself to cancel creating a blob. + CBLBlobWriter_Close((CBLBlobWriteStream*)cookie); + } + return 0; +} + + +static CBLBlobWriteStream* closeFILEAndRecoverStream(FILE *f) { + // There's no stdio API to recover the "cookie" value from a custom `FILE*`, so how are we + // going to get the `CBLBlobWriteStream*` back? + // Kludgy solution: the "close" callback (`closeWriterFn`) stores the cookie into a variable + // pointed to by the static pointer `sPutStreamHereOnClose`, so after calling `fclose` + // -- which we need to do anyway to flush the buffer -- our variable will be set. + // If it wasn't, it means the caller passed in a `FILE*` we didn't open, which is an error. + CBLBlobWriteStream *stream = nullptr; + sPutStreamHereOnClose = &stream; + fclose(f); + sPutStreamHereOnClose = nullptr; + return stream; +} + + +#pragma mark - API FUNCTIONS: + + +FILE* CBLBlob_OpenAsFILE(CBLBlob* blob, CBLError* outError) noexcept { + auto stream = CBLBlob_OpenContentStream(blob, outError); + if (!stream) + return nullptr; +#ifdef USE_FUNOPEN + return funopen(stream, &readFn_fun, nullptr, &seekFn_fun, &closeReaderFn); +#else + return fopencookie(stream, "r", {&readfn_cookie, nullptr, &seekfn_cookie, &closeReaderFn}); +#endif +} + + +FILE* _cbl_nullable CBLBlobWriter_CreateFILE(CBLDatabase* db, CBLError* outError) noexcept { + CBLBlobWriteStream *stream = CBLBlobWriter_Create(db, outError); + if (!stream) + return nullptr; +#ifdef USE_FUNOPEN + FILE *f = funopen(stream, nullptr, writeFn_fun, nullptr, closeWriterFn); +#else + FILE *f = fopencookie(stream, "w", {nullptr, &writefn_cookie, nullptr, closeWriterFn}); +#endif + if (!f) + CBLBlobWriter_Close(stream); + return f; +} + + +CBLBlob* CBLBlob_CreateWithFILE(FLString contentType, FILE* file) noexcept { + CBLBlobWriteStream *stream = closeFILEAndRecoverStream(file); + if (!stream) { + CBL_LogMessage(kCBLLogDomainDatabase, kCBLLogError, + FLSTR("CBLBlob_CreateWithFILE was called with a FILE* not opened by" + " CBLBlobWriter_CreateFILE")); + return nullptr; + } + return CBLBlob_CreateWithStream(contentType, stream); +} + +#endif // _MSC_VER diff --git a/src/CBLBlob+FILE.h b/src/CBLBlob+FILE.h new file mode 100644 index 00000000..6e3cde7f --- /dev/null +++ b/src/CBLBlob+FILE.h @@ -0,0 +1,69 @@ +// +// CBLBlob+FILE.h +// +// Copyright (c) 2021 Couchbase, Inc All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#pragma once +#include "cbl/CBLBlob.h" + +CBL_CAPI_BEGIN + +#ifndef _MSC_VER // sorry, not available on Windows + +/** \defgroup blobs Blobs + @{ + \name Blob I/O with stdio `FILE` + @{ */ + + /** Opens a stdio `FILE` on a blob's content. You can use this with any read-only stdio + function that takes a `FILE*`, such as `fread` or `fscanf`. + @note You are responsible for calling `fclose` when done with the "file". */ + _cbl_warn_unused + FILE* _cbl_nullable CBLBlob_OpenAsFILE(CBLBlob* blob, + CBLError* _cbl_nullable) CBLAPI; + + /** Opens a stdio `FILE*` stream for creating a new Blob. You can pass this stream to any + C library function that writes to a `FILE*`, such as `fwrite` or `fprintf`; + but you cannot read from nor seek this stream, so `fread` and `fseek` will fail. + + After writing the data, call \ref CBLBlob_CreateWithFILE to create the blob, + instead of `fclose`. + + If you need to cancel without creating a blob, simply call `fclose` instead. */ + _cbl_warn_unused + FILE* _cbl_nullable CBLBlobWriter_CreateFILE(CBLDatabase* db, + CBLError* _cbl_nullable) CBLAPI; + + /** Creates a new blob object from the data written to a `FILE*` stream that was created with + \ref CBLBlobWriter_CreateFILE. + You should then add the blob to a mutable document as a property -- see + \ref FLSlot_SetBlob. + @note You are responsible for releasing the CBLBlob reference. + @note Do not call `fclose` on the stream; the blob will do that. + @param contentType The MIME type (optional). + @param blobWriter The stream the data was written to, which must have been created with + \ref CBLBlobWriter_CreateFILE. + @return A new CBLBlob instance. */ + _cbl_warn_unused + CBLBlob* CBLBlob_CreateWithFILE(FLString contentType, + FILE* blobWriter) CBLAPI; + +/** @} */ +/** @} */ + +CBL_CAPI_END + +#endif // _MSC_VER diff --git a/test/BlobTest.cc b/test/BlobTest.cc index d9b4c248..0838094d 100644 --- a/test/BlobTest.cc +++ b/test/BlobTest.cc @@ -17,6 +17,7 @@ // #include "CBLTest.hh" +#include "CBLBlob+FILE.h" #include "CBLPrivate.h" #include @@ -137,9 +138,103 @@ TEST_CASE_METHOD(BlobTest, "Create blob with stream", "[Blob]") { CBLBlobReader_Close(in); } +#ifndef _MSC_VER + // Read content as stdio FILE stream: + { + static_assert(kBlobContent.size == 34, "the checks below assume the blob is 34 bytes long"); + char buf[20]; + FILE *f = CBLBlob_OpenAsFILE(blob, &error); + REQUIRE(f); + CHECK(fileno(f) < 0); + + CHECK(ftell(f) == 0); + CHECK(fread(buf, 1, 20, f) == 20); + CHECK(memcmp(buf, &kBlobContent[0], 20) == 0); + CHECK(!feof(f)); + + CHECK(ftell(f) == 20); + CHECK(fread(buf, 1, 20, f) == 14); + CHECK(memcmp(buf, &kBlobContent[20], 14) == 0); + CHECK(feof(f)); + CHECK(!ferror(f)); + + CHECK(ftell(f) == 34); + CHECK(fread(buf, 1, 20, f) == 0); + + CHECK(fseek(f, 12, SEEK_SET) == 0); + CHECK(ftell(f) == 12); + CHECK(!feof(f)); + CHECK(fread(buf, 1, 7, f) == 7); + CHECK(memcmp(buf, &kBlobContent[12], 7) == 0); + CHECK(ftell(f) == 12 + 7); + + CHECK(fseek(f, 1, SEEK_CUR) == 0); + CHECK(ftell(f) == 20); + + CHECK(fseek(f, -5, SEEK_END) == 0); + CHECK(ftell(f) == kBlobContent.size - 5); + + CHECK(fseek(f, 9999, SEEK_SET) == 0); // fseek past EOF is not error + CHECK(ftell(f) == kBlobContent.size); // but pos is pinned to the EOF + + ExpectingExceptions x; + CHECK(fseek(f, -9999, SEEK_SET) < 0); // but fseek to negative pos is an error + CHECK(errno == EINVAL); + CHECK(ftell(f) == kBlobContent.size); // but pos is pinned to the EOF + + fclose(f); + } +#endif + + CBLBlob_Release(blob); + CBLDocument_Release(doc); +} + + +#ifndef _MSC_VER +TEST_CASE_METHOD(BlobTest, "Create blob with FILE stream", "[Blob]") { + CBLError error; + CBLBlob *blob; + { + FILE *f = CBLBlobWriter_CreateFILE(db, &error); + REQUIRE(f); + CHECK(fileno(f) < 0); + + CHECK(fprintf(f, "Pi is about %.5f", M_PI) == 19); + CHECK(fputc('.', f) == '.'); + CHECK(fwrite("TESTING", 1, 7, f) == 7); + + // seek and read will fail with errors: + CHECK(fseek(f, 2, SEEK_SET) < 0); + char buf[10]; + CHECK(fread(buf, 1, 10, f) == 0); + CHECK(ferror(f) != 0); + CHECK(feof(f) == 0); + clearerr(f); + + blob = CBLBlob_CreateWithFILE("text/plain"_sl, f); + REQUIRE(blob); + // Note: After creating a blob with the stream, the created blob will take + // ownership of the stream so do not close the stream. + } + + // Set blob in a document and save: + auto doc = CBLDocument_CreateWithID("doc1"_sl); + auto props = CBLDocument_MutableProperties(doc); + FLMutableDict_SetBlob(props, "blob"_sl, blob); + CHECK(CBLDatabase_SaveDocument(db, doc, &error)); + + // Read content as a slice: + { + FLSliceResult gotContent = CBLBlob_Content(blob, &error); + CHECK(gotContent == "Pi is about 3.14159.TESTING"_sl); + FLSliceResult_Release(gotContent); + } + CBLBlob_Release(blob); CBLDocument_Release(doc); } +#endif TEST_CASE_METHOD(BlobTest, "Create JSON from Blob", "[Blob]") {