diff --git a/CMakeLists.txt b/CMakeLists.txt index 5064c25..a3ccf05 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,25 @@ option(BFC_COVERAGE "Enable coverage reporting" OFF) option(BFC_BUILD_BENCHMARKS "Build benchmarks" ON) option(BFC_BUILD_EXAMPLES "Build examples" ON) +# Sanitizers, applied to Debug builds only. +# +# AddressSanitizer is off by default on macOS: as of macOS 26 its runtime +# deadlocks inside its own initialisation. It calls into dyld, dyld allocates, +# ASan intercepts that allocation and re-enters its initialiser, then spins +# forever on a lock it already holds. Every binary hangs before reaching +# main(), including `bfc --help`. This affects any program built with +# -fsanitize=address, not just this project, and a newer compiler-rt does not +# help. UndefinedBehaviorSanitizer is unaffected and stays on. +# +# Re-enable once the platform is fixed: cmake -DBFC_ASAN=ON +if(APPLE) + set(BFC_ASAN_DEFAULT OFF) +else() + set(BFC_ASAN_DEFAULT ON) +endif() +option(BFC_ASAN "Enable AddressSanitizer in Debug builds" ${BFC_ASAN_DEFAULT}) +option(BFC_UBSAN "Enable UndefinedBehaviorSanitizer in Debug builds" ON) + # Automatically disable tests for Release builds to avoid unused variable warnings if(CMAKE_BUILD_TYPE STREQUAL "Release") option(BFC_BUILD_TESTS "Build tests" OFF) @@ -37,7 +56,22 @@ endif() # Compiler flags if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wall -Wextra -Werror") - set(CMAKE_C_FLAGS_DEBUG "${CMAKE_C_FLAGS_DEBUG} -g -O0 -fsanitize=address,undefined") + set(CMAKE_C_FLAGS_DEBUG "${CMAKE_C_FLAGS_DEBUG} -g -O0") + + set(BFC_SANITIZERS "") + if(BFC_ASAN) + list(APPEND BFC_SANITIZERS address) + endif() + if(BFC_UBSAN) + list(APPEND BFC_SANITIZERS undefined) + endif() + if(BFC_SANITIZERS) + list(JOIN BFC_SANITIZERS "," BFC_SANITIZER_ARG) + set(CMAKE_C_FLAGS_DEBUG "${CMAKE_C_FLAGS_DEBUG} -fsanitize=${BFC_SANITIZER_ARG}") + message(STATUS "Debug sanitizers: ${BFC_SANITIZER_ARG}") + else() + message(STATUS "Debug sanitizers: none") + endif() set(CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS_RELEASE} -O3 -DNDEBUG") if(BFC_COVERAGE) diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 9bd9ead..f00d80e 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -67,7 +67,6 @@ endif() # Link ZSTD if enabled (avoid duplicates by handling all targets together) if(BFC_WITH_ZSTD) foreach(target ${all_benchmark_targets}) - target_link_libraries(${target} ${ZSTD_LIBRARIES}) target_link_directories(${target} PRIVATE ${ZSTD_LIBRARY_DIRS}) target_compile_definitions(${target} PRIVATE BFC_WITH_ZSTD) endforeach() @@ -76,7 +75,6 @@ endif() # Link libsodium if enabled (avoid duplicates by handling all targets together) if(BFC_WITH_SODIUM) foreach(target ${all_benchmark_targets}) - target_link_libraries(${target} ${SODIUM_LIBRARIES}) target_link_directories(${target} PRIVATE ${SODIUM_LIBRARY_DIRS}) target_compile_definitions(${target} PRIVATE BFC_WITH_SODIUM) endforeach() diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index c47fdbd..0a1581d 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -54,7 +54,6 @@ endif() # Link ZSTD if enabled (avoid duplicates by handling all targets together) if(BFC_WITH_ZSTD) foreach(target ${all_example_targets}) - target_link_libraries(${target} ${ZSTD_LIBRARIES}) target_link_directories(${target} PRIVATE ${ZSTD_LIBRARY_DIRS}) target_compile_definitions(${target} PRIVATE BFC_WITH_ZSTD) endforeach() @@ -63,7 +62,6 @@ endif() # Link libsodium if enabled (avoid duplicates by handling all targets together) if(BFC_WITH_SODIUM) foreach(target ${all_example_targets}) - target_link_libraries(${target} ${SODIUM_LIBRARIES}) target_link_directories(${target} PRIVATE ${SODIUM_LIBRARY_DIRS}) target_compile_definitions(${target} PRIVATE BFC_WITH_SODIUM) endforeach() diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index 5e6f125..6e7837e 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -28,13 +28,11 @@ target_link_libraries(bfc_cli bfc) # Link ZSTD if enabled (needed for static library dependencies) if(BFC_WITH_ZSTD) - target_link_libraries(bfc_cli ${ZSTD_LIBRARIES}) target_link_directories(bfc_cli PRIVATE ${ZSTD_LIBRARY_DIRS}) endif() # Link libsodium if enabled (needed for encryption support) if(BFC_WITH_SODIUM) - target_link_libraries(bfc_cli ${SODIUM_LIBRARIES}) target_link_directories(bfc_cli PRIVATE ${SODIUM_LIBRARY_DIRS}) target_compile_definitions(bfc_cli PRIVATE BFC_WITH_SODIUM) endif() diff --git a/src/lib/bfc_reader.c b/src/lib/bfc_reader.c index 4660feb..c15b12e 100644 --- a/src/lib/bfc_reader.c +++ b/src/lib/bfc_reader.c @@ -398,6 +398,7 @@ int bfc_list(bfc_t* r, const char* prefix_dir, bfc_list_cb cb, void* user) { .mode = r->entries[i].mode, .mtime_ns = r->entries[i].mtime_ns, .comp = r->entries[i].comp, + .enc = r->entries[i].enc, .size = r->entries[i].orig_size, .crc32c = r->entries[i].crc32c, .obj_offset = r->entries[i].obj_offset, @@ -939,7 +940,41 @@ int bfc_verify(bfc_t* r, int deep) { if (deep) { // Deep verification - read and verify CRC of each file if ((entry->mode & S_IFMT) == S_IFREG) { - // Verify file content CRC + // entry->crc32c covers the *original* content. When the object was + // compressed or encrypted the stored bytes are something else, so the + // checksum can only be recomputed after undoing those transforms. + // bfc_read already performs exactly that pipeline. + if (entry->comp != BFC_COMP_NONE || entry->enc != BFC_ENC_NONE) { + if (entry->enc != BFC_ENC_NONE && !r->has_encryption_key) { + // Encrypted content cannot be checked without a key. Saying so is + // honest; reporting corruption would not be. + return BFC_E_PERM; + } + + if (entry->orig_size > 0) { + uint8_t* plaintext = bfc_malloc((size_t) entry->orig_size); + if (!plaintext) { + return BFC_E_IO; + } + + size_t got = bfc_read(r, entry->path, 0, plaintext, (size_t) entry->orig_size); + if (got != entry->orig_size) { + bfc_free(plaintext); + return BFC_E_CRC; + } + + uint32_t plain_crc = bfc_crc32c_compute(plaintext, got); + bfc_free(plaintext); + + if (plain_crc != entry->crc32c) { + return BFC_E_CRC; + } + } + continue; + } + + // Stored as-is: stream it, so a large file is not materialised in + // memory just to be checksummed. if (bfc_os_seek(r->file, (int64_t) entry->obj_offset, SEEK_SET) != BFC_OK) { return BFC_E_IO; } diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index facd091..8b7f2ef 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -34,14 +34,12 @@ target_link_libraries(unit_tests bfc) # Link ZSTD if enabled (needed for static library dependencies) if(BFC_WITH_ZSTD) - target_link_libraries(unit_tests ${ZSTD_LIBRARIES}) target_link_directories(unit_tests PRIVATE ${ZSTD_LIBRARY_DIRS}) target_compile_definitions(unit_tests PRIVATE BFC_WITH_ZSTD) endif() # Link libsodium if enabled (needed for encryption tests) if(BFC_WITH_SODIUM) - target_link_libraries(unit_tests ${SODIUM_LIBRARIES}) target_link_directories(unit_tests PRIVATE ${SODIUM_LIBRARY_DIRS}) target_compile_definitions(unit_tests PRIVATE BFC_WITH_SODIUM) endif() diff --git a/tests/unit/test_reader.c b/tests/unit/test_reader.c index e629671..0108db9 100644 --- a/tests/unit/test_reader.c +++ b/tests/unit/test_reader.c @@ -1363,8 +1363,187 @@ static int test_read_encrypted_uncompressed(void) { return 0; } + +// Collect the compression and encryption reported for one path by bfc_list. +struct enc_probe { + const char* wanted; + int seen; + uint32_t comp; + uint32_t enc; +}; + +static int enc_probe_cb(const bfc_entry_t* entry, void* user) { + struct enc_probe* probe = (struct enc_probe*) user; + if (strcmp(entry->path, probe->wanted) == 0) { + probe->seen = 1; + probe->comp = entry->comp; + probe->enc = entry->enc; + } + return 0; +} + +// Regression: bfc_list built its bfc_entry_t with a designated initializer that +// omitted `enc`, so every listed entry silently reported BFC_ENC_NONE even in an +// encrypted container. bfc_stat has always reported it, so the two disagreed. +static int test_list_reports_encryption(void) { + const char* filename = "reader_test_list_enc.bfc"; + const char* content = "listed and encrypted"; + + uint8_t key[32]; + for (size_t i = 0; i < sizeof(key); i++) { + key[i] = (uint8_t) (i * 3 + 1); + } + + unlink(filename); + + bfc_t* writer = NULL; + int result = bfc_create(filename, 4096, 0, &writer); + assert(result == BFC_OK); + assert(bfc_set_encryption_key(writer, key) == BFC_OK); + + FILE* temp = tmpfile(); + assert(temp != NULL); + fwrite(content, 1, strlen(content), temp); + rewind(temp); + assert(bfc_add_file(writer, "secret.txt", temp, 0644, bfc_os_current_time_ns(), NULL) == BFC_OK); + fclose(temp); + + assert(bfc_finish(writer) == BFC_OK); + bfc_close(writer); + + bfc_t* reader = NULL; + assert(bfc_open(filename, &reader) == BFC_OK); + + bfc_entry_t stated; + assert(bfc_stat(reader, "secret.txt", &stated) == BFC_OK); + assert(stated.enc != BFC_ENC_NONE); + + struct enc_probe probe = {.wanted = "secret.txt", .seen = 0, .comp = 0, .enc = 0}; + assert(bfc_list(reader, NULL, enc_probe_cb, &probe) == BFC_OK); + assert(probe.seen == 1); + + // What bfc_list reports must agree with bfc_stat. + assert(probe.enc == stated.enc); + assert(probe.comp == stated.comp); + assert(probe.enc != BFC_ENC_NONE); + + bfc_close_read(reader); + unlink(filename); + + return 0; +} +#endif + +// Write a container holding one compressible file, optionally compressed and +// optionally encrypted with `key`. Returns BFC_OK on success. +static int make_verify_container(const char* filename, int compress, const uint8_t* key) { + unlink(filename); + + bfc_t* writer = NULL; + int result = bfc_create(filename, 4096, 0, &writer); + if (result != BFC_OK) { + return result; + } + + if (compress) { + result = bfc_set_compression(writer, BFC_COMP_ZSTD, 3); + if (result != BFC_OK) { + bfc_close(writer); + return result; + } + bfc_set_compression_threshold(writer, 16); + } + if (key) { + result = bfc_set_encryption_key(writer, key); + if (result != BFC_OK) { + bfc_close(writer); + return result; + } + } + + FILE* temp = tmpfile(); + if (!temp) { + bfc_close(writer); + return BFC_E_IO; + } + // Repetitive, so zstd actually shrinks it and the stored bytes differ from + // the original — which is the whole point of the test. + for (int i = 0; i < 400; i++) { + fputs("verify me verify me verify me\n", temp); + } + rewind(temp); + + result = bfc_add_file(writer, "payload.txt", temp, 0644, bfc_os_current_time_ns(), NULL); + fclose(temp); + if (result != BFC_OK) { + bfc_close(writer); + return result; + } + + result = bfc_finish(writer); + bfc_close(writer); + return result; +} + +// Regression: bfc_verify(deep) checksummed the raw stored bytes against the +// checksum of the original content. For anything compressed or encrypted the +// two differ by definition, so it reported corruption on perfectly good +// archives — including ones it had just written itself. +static int test_verify_deep_transformed_content(void) { + const char* filename = "reader_test_verify_deep.bfc"; + + // Stored as-is: this path always worked, and must keep working. + assert(make_verify_container(filename, 0, NULL) == BFC_OK); + bfc_t* reader = NULL; + assert(bfc_open(filename, &reader) == BFC_OK); + assert(bfc_verify(reader, 0) == BFC_OK); + assert(bfc_verify(reader, 1) == BFC_OK); + bfc_close_read(reader); + unlink(filename); + +#ifdef BFC_WITH_ZSTD + // Compressed: the stored bytes are a zstd frame, so the checksum can only be + // recomputed after decompressing. + assert(make_verify_container(filename, 1, NULL) == BFC_OK); + reader = NULL; + assert(bfc_open(filename, &reader) == BFC_OK); + + bfc_entry_t entry; + assert(bfc_stat(reader, "payload.txt", &entry) == BFC_OK); + assert(entry.comp == BFC_COMP_ZSTD); + assert(entry.obj_size < entry.size); // it really did shrink + + assert(bfc_verify(reader, 1) == BFC_OK); + bfc_close_read(reader); + unlink(filename); +#endif + +#ifdef BFC_WITH_SODIUM + // Encrypted, with the key supplied: same story, plus decryption. + uint8_t key[32]; + for (size_t i = 0; i < sizeof(key); i++) { + key[i] = (uint8_t) (i + 7); + } + + assert(make_verify_container(filename, 1, key) == BFC_OK); + reader = NULL; + assert(bfc_open(filename, &reader) == BFC_OK); + assert(bfc_reader_set_encryption_key(reader, key) == BFC_OK); + assert(bfc_verify(reader, 1) == BFC_OK); + bfc_close_read(reader); + + // Without the key the content cannot be checked at all. Saying so is honest; + // claiming corruption is not. + reader = NULL; + assert(bfc_open(filename, &reader) == BFC_OK); + assert(bfc_verify(reader, 1) == BFC_E_PERM); + bfc_close_read(reader); + unlink(filename); #endif + return 0; +} + int test_reader(void) { if (test_open_container() != 0) return 1; @@ -1419,7 +1598,11 @@ int test_reader(void) { #ifdef BFC_WITH_SODIUM if (test_read_encrypted_uncompressed() != 0) return 1; + if (test_list_reports_encryption() != 0) + return 1; #endif + if (test_verify_deep_transformed_content() != 0) + return 1; return 0; }