diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index f867fd467..caad54018 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -75,79 +75,14 @@ jobs: -DENABLE_MQTT=OFF \ -DCMAKE_C_FLAGS="--coverage -fprofile-update=atomic" \ -DCMAKE_EXE_LINKER_FLAGS="--coverage" - make -j$(nproc) \ - test_storage_pressure \ - test_storage_pressure_extended \ - test_detection_result_structures \ - test_stream_frame_rate \ - test_recording_video_params \ - test_mp4_segment_boundaries \ - test_storage_retention_sqlite \ - test_storage_manager_retention \ - test_storage_target_pressure_cleanup \ - test_storage_migration \ - test_storage_archive \ - test_authorization \ - test_api_handlers_recordings_playback \ - test_db_storage_targets \ - test_db_storage_policies \ - test_api_handlers_storage_targets \ - test_api_handlers_storage_policies \ - test_sqlite_migration_boundaries \ - test_config \ - test_logger \ - test_strings \ - test_memory \ - test_request_response \ - test_shutdown_coordinator \ - test_system_health_startup \ - test_detection_config \ - test_detection_model_motion \ - test_db_streams \ - test_db_camera_favorites \ - test_api_handlers_camera_favorites \ - test_db_backup \ - test_mp4_failed_open_memory \ - test_db_recordings_extended \ - test_db_detections \ - test_db_zones \ - test_db_events \ - test_db_auth \ - test_db_transactions \ - test_db_maintenance \ - test_db_query_builder \ - test_logger_json \ - test_batch_delete_progress \ - test_db_recordings_sync \ - test_httpd_utils \ - test_zone_filter \ - test_onvif_soap_fault \ - test_faac_encoder \ - test_audio_transcoder \ - test_mp4_audio_recovery \ - test_mp4_forced_reconnect \ - test_mp4_recovery_watchdog \ - test_audio_voice_enhancement \ - test_audio_voice_filter_faults \ - test_udt_recording \ - test_detection_current_frame \ - test_detection_io_recovery \ - test_hls_cleanup \ - test_stream_manager \ - test_stream_state \ - test_packet_buffer \ - test_timestamp_manager \ - test_motion_trigger_parse \ - test_motion_max_area \ - test_url_utils + cmake --build . --parallel "$(nproc)" --target ci-unit-tests echo "Unity test binaries built:" ls -la bin/test_* - name: Run Unity tests run: | cd build - ctest --output-on-failure -V \ - -R "test_sqlite_migration_boundaries|test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_stream_frame_rate|test_recording_video_params|test_mp4_segment_boundaries|test_storage_retention_sqlite|test_storage_manager_retention|test_storage_target_pressure_cleanup|test_storage_migration|test_storage_archive_s3|test_storage_archive_operator|test_authorization|test_api_handlers_recordings_playback|test_db_storage_targets|test_db_storage_policies|test_api_handlers_storage_targets|test_api_handlers_storage_policies|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_camera_favorites|test_api_handlers_camera_favorites|test_db_backup|test_mp4_failed_open_memory|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_onvif_soap_fault|test_faac_encoder|test_audio_transcoder|test_mp4_audio_recovery|test_mp4_forced_reconnect|test_mp4_recovery_watchdog|test_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|test_hls_cleanup|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_motion_trigger_parse|test_motion_max_area|test_url_utils|test_system_health_startup$" + ctest --output-on-failure --no-tests=error -V -L '^ci-unit$' - name: Generate coverage report if: always() @@ -606,13 +541,9 @@ jobs: npm ci npx playwright install --with-deps chromium - - name: Bulk tag reconciliation regression - if: ${{ matrix.shard == 1 }} - run: npm --prefix web run test:e2e -- --testPathPatterns=bulkTagsReconciliation - - - name: Zone Editor checkbox regression + - name: Component browser regressions if: ${{ matrix.shard == 1 }} - run: npm --prefix web run test:e2e -- --testPathPatterns=zoneEditorCheckbox + run: npm --prefix web run test:e2e -- --testPathPatterns='\.browser\.spec\.js$' - name: Run Playwright UI tests run: | diff --git a/.github/workflows/sanitizer.yml b/.github/workflows/sanitizer.yml index 5768c9a2c..7183485c6 100644 --- a/.github/workflows/sanitizer.yml +++ b/.github/workflows/sanitizer.yml @@ -59,8 +59,7 @@ jobs: run: | cd build echo "Running tests with ASan + UBSan enabled..." - ctest --output-on-failure -V \ - -R "test_sqlite_migration_boundaries|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_faac_encoder|test_audio_transcoder|test_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_stream_frame_rate|test_recording_video_params|test_mp4_segment_boundaries|test_storage_retention_sqlite|test_audit_log|test_audit_summary" + ctest --output-on-failure --no-tests=error -V -L '^ci-sanitizer$' - name: Smoke test - start and stop service run: | diff --git a/src/database/db_core.c b/src/database/db_core.c index 534e26515..5cba77b64 100644 --- a/src/database/db_core.c +++ b/src/database/db_core.c @@ -26,6 +26,9 @@ #include #include #include +#ifdef __linux__ +#include +#endif #include "database/db_core.h" #include "database/db_schema.h" @@ -37,6 +40,7 @@ #include "core/path_utils.h" #include "core/shutdown_coordinator.h" #include "utils/strings.h" +#include "utils/uuid.h" // Database handle static sqlite3 *db = NULL; @@ -218,49 +222,186 @@ static bool is_temporary_backup_artifact(const char *name) { * initialized, so startup is the safe point to remove artifacts left by a * previous process. */ -static int cleanup_stale_backup_artifacts(const char *backup_dir) { - DIR *dir = opendir(backup_dir); - if (!dir) { - return errno == ENOENT ? 0 : -1; +typedef struct { + int fd; + char name[64]; + struct stat identity; + bool identity_known; +} backup_cleanup_quarantine_t; + +static bool same_backup_inode(const struct stat *left, const struct stat *right) { + return left->st_dev == right->st_dev && left->st_ino == right->st_ino; +} + +static bool private_backup_directory(const struct stat *info) { + return S_ISDIR(info->st_mode) && info->st_uid == geteuid() && + (info->st_mode & 0777) == 0700; +} + +static void report_backup_quarantine(const char *backup_dir, + const backup_cleanup_quarantine_t *quarantine, + const char *name) { + char actual[PATH_MAX], descriptor[64]; + ssize_t length = -1; + if (quarantine->fd >= 0) { + snprintf(descriptor, sizeof(descriptor), "/proc/self/fd/%d", quarantine->fd); + length = readlink(descriptor, actual, sizeof(actual) - 1); + } + if (length >= 0) { + actual[length] = '\0'; + log_warn("Retained backup cleanup quarantine: %s/%s", actual, name); + } else { + log_warn("Retained backup cleanup quarantine: %s/%s/%s " + "(parent may have moved; directory device=%ju inode=%ju)", + backup_dir, quarantine->name, name, + (uintmax_t)quarantine->identity.st_dev, + (uintmax_t)quarantine->identity.st_ino); } +} +/* Best-effort identity check before nonrecursive empty-directory removal. + * A mutable parent can still replace the name between this check and rmdir; + * AT_REMOVEDIR cannot delete files/nonempty directories. File deletion below + * instead uses the held private directory. Never recurse into crash leftovers. */ +static int finish_backup_quarantine(int parent, const char *backup_dir, + backup_cleanup_quarantine_t *quarantine, + bool retained, const char *name) { int result = 0; - struct dirent *entry; - while ((entry = readdir(dir)) != NULL) { - if (!is_temporary_backup_artifact(entry->d_name)) { - continue; - } - - char full_path[PATH_MAX]; - if (snprintf(full_path, sizeof(full_path), "%s/%s", backup_dir, - entry->d_name) >= (int)sizeof(full_path)) { - log_warn("Skipping overlong temporary backup path in %s", backup_dir); + if (quarantine->name[0]) { + struct stat current; + if (retained || !quarantine->identity_known || + fstatat(parent, quarantine->name, ¤t, AT_SYMLINK_NOFOLLOW) != 0 || + !same_backup_inode(¤t, &quarantine->identity) || + !private_backup_directory(¤t) || + unlinkat(parent, quarantine->name, AT_REMOVEDIR) != 0) { + report_backup_quarantine(backup_dir, quarantine, name); result = -1; - continue; } + } + if (quarantine->fd >= 0) close(quarantine->fd); + quarantine->fd = -1; + return result; +} - struct stat st; - if (lstat(full_path, &st) != 0) { - if (errno != ENOENT) { - log_warn("Failed to inspect temporary backup %s: %s", - full_path, strerror(errno)); - result = -1; +static int create_backup_quarantine(int parent, + backup_cleanup_quarantine_t *quarantine) { + memset(quarantine, 0, sizeof(*quarantine)); + quarantine->fd = -1; + for (unsigned attempt = 0; attempt < 4; attempt++) { + char uuid[LIGHTNVR_UUID_STRING_SIZE]; + if (lightnvr_uuid_generate_v4(uuid) != 0) return -1; + snprintf(quarantine->name, sizeof(quarantine->name), ".cleanup-%s", uuid); + if (mkdirat(parent, quarantine->name, 0700) == 0) break; + quarantine->name[0] = '\0'; + if (errno != EEXIST || attempt == 3) return -1; + } + if (fstatat(parent, quarantine->name, &quarantine->identity, + AT_SYMLINK_NOFOLLOW) != 0) return -1; + quarantine->identity_known = true; + if (!private_backup_directory(&quarantine->identity)) return -1; + quarantine->fd = openat(parent, quarantine->name, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (quarantine->fd < 0) return -1; + struct stat opened; + if (fstat(quarantine->fd, &opened) != 0 || + !private_backup_directory(&opened) || + !same_backup_inode(&opened, &quarantine->identity)) return -1; + return 0; +} + +static int move_backup_noreplace(int from, int to, const char *name) { +#if defined(__linux__) && defined(SYS_renameat2) + /* Linux UAPI RENAME_NOREPLACE = 1. Use the syscall directly: older libc + * headers/libraries may not export renameat2 even when the kernel does. */ + return (int)syscall(SYS_renameat2, from, name, to, name, 1U); +#else + (void)from; (void)to; (void)name; + errno = ENOSYS; + return -1; +#endif +} + +static bool same_backup_snapshot(const struct stat *selected, const struct stat *current) { + return S_ISREG(current->st_mode) && same_backup_inode(selected, current) && + selected->st_size == current->st_size && + selected->st_mtim.tv_sec == current->st_mtim.tv_sec && + selected->st_mtim.tv_nsec == current->st_mtim.tv_nsec; +} + +/* The caller owns candidate and keeps this original inode pinned until return. + * Never open the published name again and call a replacement the selected file. */ +static int remove_pinned_backup_entry(int parent, const char *backup_dir, + const char *name, int candidate, + const struct stat *selected, bool missing_ok) { + struct stat pinned; + if (fstat(candidate, &pinned) != 0 || !same_backup_snapshot(selected, &pinned)) return -1; + backup_cleanup_quarantine_t quarantine; + if (create_backup_quarantine(parent, &quarantine) != 0) { + (void)finish_backup_quarantine(parent, backup_dir, &quarantine, false, name); + return -1; + } + int result = 0; + bool retained = false; + if (move_backup_noreplace(parent, quarantine.fd, name) != 0) { + result = missing_ok && errno == ENOENT ? 0 : -1; + } else { + struct stat moved; + bool unchanged = fstatat(quarantine.fd, name, &moved, AT_SYMLINK_NOFOLLOW) == 0 && + same_backup_snapshot(selected, &moved); + /* Only this process's UID can enter the verified private directory. + * This does not serialize writers that already hold the file open. */ + if (unchanged && unlinkat(quarantine.fd, name, 0) == 0) { + log_info("Removed inspected database backup: %s/%s", backup_dir, name); + } else { + result = -1; + log_warn("Backup changed or could not be removed; restoring %s/%s", backup_dir, name); + if (move_backup_noreplace(quarantine.fd, parent, name) != 0) { + retained = true; + log_warn("Cannot restore quarantined backup without overwriting: %s", strerror(errno)); } - continue; - } - if (!S_ISREG(st.st_mode)) { - continue; } + } + if (finish_backup_quarantine(parent, backup_dir, &quarantine, retained, name) != 0) result = -1; + return result; +} + +static int cleanup_stale_backup_entry(int parent, const char *backup_dir, + const char *name) { +#ifndef O_PATH + (void)parent; + log_warn("Cannot safely pin stale backup %s/%s on this platform", backup_dir, name); + return -1; +#else + /* O_PATH pins even an unreadable regular inode without following a symlink + * or opening a FIFO/device. Keep it open until removal/restore completes. */ + int candidate = openat(parent, name, O_PATH | O_NOFOLLOW | O_CLOEXEC); + if (candidate < 0) return errno == ENOENT ? 0 : -1; + struct stat inspected; + if (fstat(candidate, &inspected) != 0) { close(candidate); return -1; } + int result = S_ISREG(inspected.st_mode) + ? remove_pinned_backup_entry(parent, backup_dir, name, candidate, &inspected, true) : 0; + close(candidate); + return result; +#endif +} - if (unlink(full_path) != 0) { - log_warn("Failed to remove stale temporary backup %s: %s", - full_path, strerror(errno)); +static int cleanup_stale_backup_artifacts(const char *backup_dir) { + DIR *dir = opendir(backup_dir); + if (!dir) return errno == ENOENT ? 0 : -1; + int result = 0; + int parent = dirfd(dir); + if (parent < 0) { closedir(dir); return -1; } + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + if (is_temporary_backup_artifact(entry->d_name) && + cleanup_stale_backup_entry(parent, backup_dir, entry->d_name) != 0) { + log_warn("Incomplete stale backup cleanup: %s/%s", backup_dir, entry->d_name); result = -1; - } else { - log_info("Removed stale temporary database backup: %s", full_path); + /* A mismatch may have restored this name. Directory iteration + * after mutation can revisit it; never delete it in this pass. */ + break; } } - closedir(dir); return result; } @@ -345,90 +486,102 @@ static int refresh_latest_backup(const char *timestamped_backup_path) { return 0; } -static int compare_backup_paths(const void *lhs, const void *rhs) { - const char *const *left = (const char *const *)lhs; - const char *const *right = (const char *const *)rhs; - return strcmp(*left, *right); +typedef struct { + char *name; + int fd; + struct stat selected; +} selected_backup_t; + +static int compare_selected_backups(const void *lhs, const void *rhs) { + const selected_backup_t *left = lhs, *right = rhs; + return strcmp(left->name, right->name); } static int prune_timestamped_backups(const char *backup_dir, int retention_count) { +#ifndef O_PATH + (void)retention_count; + log_warn("Cannot safely pin published backups for retention: %s", backup_dir); + return -1; +#else DIR *dir = opendir(backup_dir); if (!dir) { log_error("Failed to open backup directory for pruning: %s (%s)", backup_dir, strerror(errno)); return -1; } + int parent = dirfd(dir); + if (parent < 0) { closedir(dir); return -1; } - char **backup_paths = NULL; - size_t backup_count = 0; - size_t backup_capacity = 0; + selected_backup_t *backups = NULL; + size_t backup_count = 0, backup_capacity = 0; int result = 0; - struct dirent *entry; - - while ((entry = readdir(dir)) != NULL) { - if (entry->d_name[0] == '.') { - continue; - } - - /* Retention counts only published backups. Partial databases and - * their sidecars are cleaned separately and must never displace a - * valid recovery point. */ - if (!has_suffix(entry->d_name, ".sqlite3")) { - continue; - } - - char full_path[PATH_MAX]; - if (snprintf(full_path, sizeof(full_path), "%s/%s", backup_dir, entry->d_name) >= (int)sizeof(full_path)) { - log_warn("Skipping overlong backup path in %s", backup_dir); - continue; + /* All regular candidates remain pinned until selection/deletion finishes. + * This temporarily costs O(number of eligible backups) descriptors. Actual + * resource/allocation/enumeration failure aborts before ANY deletion; do + * not silently truncate the inventory or invent a retention-count limit. */ + for (;;) { + errno = 0; + struct dirent *entry = readdir(dir); + if (!entry) { + if (errno != 0) result = -1; + break; } + if (entry->d_name[0] == '.' || !has_suffix(entry->d_name, ".sqlite3")) continue; + int candidate = openat(parent, entry->d_name, O_PATH | O_NOFOLLOW | O_CLOEXEC); + if (candidate < 0) { result = -1; break; } + struct stat selected; + if (fstat(candidate, &selected) != 0) { close(candidate); result = -1; break; } + if (!S_ISREG(selected.st_mode)) { close(candidate); continue; } - struct stat st; - if (stat(full_path, &st) != 0 || !S_ISREG(st.st_mode)) { - continue; + if (backup_count == backup_capacity) { + if (backup_capacity > SIZE_MAX / 2) { + close(candidate); result = -1; break; + } + size_t capacity = backup_capacity ? backup_capacity * 2 : 8; + if (capacity > SIZE_MAX / sizeof(*backups)) { + close(candidate); result = -1; break; + } + selected_backup_t *grown = realloc(backups, capacity * sizeof(*backups)); + if (!grown) { close(candidate); result = -1; break; } + backups = grown; + backup_capacity = capacity; } + char *name = strdup(entry->d_name); + if (!name) { close(candidate); result = -1; break; } + backups[backup_count++] = (selected_backup_t){name, candidate, selected}; + } - if (backup_count == backup_capacity) { - size_t new_capacity = backup_capacity == 0 ? 8 : backup_capacity * 2; - char **new_paths = (char **)realloc((void *)backup_paths, new_capacity * sizeof(*backup_paths)); - if (!new_paths) { - log_error("Out of memory while pruning backups"); + if (result == 0 && backup_count > 1) { + qsort(backups, backup_count, sizeof(*backups), compare_selected_backups); + /* A mutating directory may enumerate a name twice. Reject an ambiguous + * inventory before deletion; distinct hard-linked names still count. */ + for (size_t i = 1; i < backup_count; ++i) { + if (strcmp(backups[i - 1].name, backups[i].name) == 0) { result = -1; break; } - backup_paths = new_paths; - backup_capacity = new_capacity; } - - backup_paths[backup_count] = strdup(full_path); - if (!backup_paths[backup_count]) { - log_error("Out of memory while recording backup path"); - result = -1; - break; - } - backup_count++; } - - closedir(dir); - - if (result == 0 && backup_paths != NULL && backup_count > (size_t)retention_count) { - qsort((void *)backup_paths, backup_count, sizeof(*backup_paths), compare_backup_paths); + if (result == 0 && backup_count > (size_t)retention_count) { size_t remove_count = backup_count - (size_t)retention_count; - for (size_t i = 0; i < remove_count; i++) { - if (unlink(backup_paths[i]) != 0) { - log_warn("Failed to prune old backup %s: %s", backup_paths[i], strerror(errno)); + for (size_t i = 0; i < remove_count; ++i) { + if (remove_pinned_backup_entry(parent, backup_dir, backups[i].name, + backups[i].fd, &backups[i].selected, false) != 0) { result = -1; - } else { - log_info("Pruned old database backup: %s", backup_paths[i]); + break; /* Do not continue after uncertainty or restoration. */ } + close(backups[i].fd); + backups[i].fd = -1; } } - - for (size_t i = 0; i < backup_count; i++) { - free(backup_paths[i]); + for (size_t i = 0; i < backup_count; ++i) { + if (backups[i].fd >= 0) close(backups[i].fd); + free(backups[i].name); } - free((void *)backup_paths); - + free(backups); + if (closedir(dir) != 0) result = -1; + if (result != 0) log_warn("Incomplete published backup retention: %s", backup_dir); return result; +#endif } static int run_post_backup_script(const char *backup_path, const char *backup_dir) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6f5d89199..798e29b0e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,5 +1,7 @@ # Tests CMakeLists.txt +include(cmake/CiSuites.cmake) + # Get the ENABLE_SOD option from parent if(NOT DEFINED ENABLE_SOD) option(ENABLE_SOD "Enable SOD library for object detection" ON) @@ -136,6 +138,7 @@ set_target_properties(test_db_backup # Add database backup test to CTest add_test(NAME test_db_backup COMMAND test_db_backup) +lightnvr_ci_test(test_db_backup CI_UNIT) # Add stream detection test add_executable(test_stream_detection test_stream_detection.c) diff --git a/tests/cmake/CiSuites.cmake b/tests/cmake/CiSuites.cmake new file mode 100644 index 000000000..811d1cfc0 --- /dev/null +++ b/tests/cmake/CiSuites.cmake @@ -0,0 +1,30 @@ +# CI suite membership lives beside each test registration. For example: +# add_layer2_test(test_example CI_UNIT CI_SANITIZER) +# Both the aggregate build target and CTest label come from that declaration. +add_custom_target(ci-unit-tests) +add_custom_target(ci-sanitizer-tests) + +function(lightnvr_ci_test TEST_TARGET) + cmake_parse_arguments(ARG "CI_UNIT;CI_SANITIZER" "" "TESTS" ${ARGN}) + if(ARG_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "Unknown CI test options: ${ARG_UNPARSED_ARGUMENTS}") + endif() + if(NOT TARGET ${TEST_TARGET}) + message(FATAL_ERROR "Missing CI test target: ${TEST_TARGET}") + endif() + if(NOT ARG_TESTS) + set(ARG_TESTS ${TEST_TARGET}) + endif() + foreach(SUITE IN ITEMS UNIT SANITIZER) + if(ARG_CI_${SUITE}) + string(TOLOWER "${SUITE}" SUITE_LABEL) + add_dependencies(ci-${SUITE_LABEL}-tests ${TEST_TARGET}) + foreach(TEST_NAME IN LISTS ARG_TESTS) + if(NOT TEST ${TEST_NAME}) + message(FATAL_ERROR "Missing CI test registration: ${TEST_NAME}") + endif() + set_property(TEST ${TEST_NAME} APPEND PROPERTY LABELS ci-${SUITE_LABEL}) + endforeach() + endif() + endforeach() +endfunction() diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 684667ba7..848bc4ede 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -20,6 +20,7 @@ macro(add_layer1_test TEST_NAME) target_link_libraries(${TEST_NAME} unity_lib) set_target_properties(${TEST_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME}) + lightnvr_ci_test(${TEST_NAME} ${ARGN}) endmacro() macro(add_layer2_test TEST_NAME) @@ -53,8 +54,11 @@ macro(add_layer2_test TEST_NAME) ${CMAKE_CURRENT_SOURCE_DIR}/s3_fixture.py $) add_test(NAME test_storage_archive_operator COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_archive_operator.py) + lightnvr_ci_test(${TEST_NAME} ${ARGN} + TESTS test_storage_archive_s3 test_storage_archive_operator) else() add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME}) + lightnvr_ci_test(${TEST_NAME} ${ARGN}) endif() endmacro() @@ -65,14 +69,14 @@ target_sources(test_hls_cache_cleanup PRIVATE # Variant for Layer 2 tests that require CURL macro(add_layer2_test_with_curl TEST_NAME) - add_layer2_test(${TEST_NAME}) + add_layer2_test(${TEST_NAME} ${ARGN}) target_link_libraries(${TEST_NAME} ${CURL_LIBRARIES}) endmacro() # Convenience wrappers for Layer 2 tests with common external deps. macro(add_layer2_test_with_ffmpeg TEST_NAME) - add_layer2_test(${TEST_NAME}) + add_layer2_test(${TEST_NAME} ${ARGN}) target_link_libraries(${TEST_NAME} ${FFMPEG_LIBRARIES}) endmacro() @@ -108,6 +112,7 @@ macro(add_layer3_test TEST_NAME) endif() set_target_properties(${TEST_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME}) + lightnvr_ci_test(${TEST_NAME} ${ARGN}) endmacro() # ==================================================================== @@ -116,42 +121,42 @@ endmacro() # disk_pressure_level_str() and evaluate_disk_pressure_level() are # both static inline in storage_manager.h, so no .c files are linked. # ==================================================================== -add_layer1_test(test_storage_pressure) +add_layer1_test(test_storage_pressure CI_UNIT CI_SANITIZER) # ==================================================================== # Layer 2: test_storage_retention_sqlite # Integration test — runs real SQL queries via db_core / db_recordings. # ==================================================================== -add_layer2_test(test_storage_retention_sqlite) +add_layer2_test(test_storage_retention_sqlite CI_UNIT CI_SANITIZER) # ==================================================================== # Layer 1: Pure logic tests — unity_lib + project headers only # (Only tests whose functions are static inline in headers) # ==================================================================== -add_layer1_test(test_storage_pressure_extended) -add_layer1_test(test_detection_result_structures) -add_layer1_test(test_stream_frame_rate) +add_layer1_test(test_storage_pressure_extended CI_UNIT CI_SANITIZER) +add_layer1_test(test_detection_result_structures CI_UNIT CI_SANITIZER) +add_layer1_test(test_stream_frame_rate CI_UNIT CI_SANITIZER) target_sources(test_stream_frame_rate PRIVATE ${CMAKE_SOURCE_DIR}/src/video/stream_frame_rate.c) # ==================================================================== # Layer 2: lightnvr_lib tests — compiled functions + optional SQLite # ==================================================================== -add_layer2_test(test_config) +add_layer2_test(test_config CI_UNIT CI_SANITIZER) add_layer2_test(test_db_core_initialization) -add_layer2_test(test_sqlite_migration_boundaries) +add_layer2_test(test_sqlite_migration_boundaries CI_UNIT CI_SANITIZER) set_tests_properties(test_sqlite_migration_boundaries PROPERTIES TIMEOUT 20) -add_layer2_test(test_logger) -add_layer2_test(test_strings) +add_layer2_test(test_logger CI_UNIT CI_SANITIZER) +add_layer2_test(test_strings CI_UNIT CI_SANITIZER) add_layer2_test(test_interruptible_sleep) add_layer2_test(test_stream_metrics) -add_layer2_test(test_memory) +add_layer2_test(test_memory CI_UNIT CI_SANITIZER) add_layer2_test(test_path_utils) -add_layer2_test(test_request_response) -add_layer2_test(test_shutdown_coordinator) +add_layer2_test(test_request_response CI_UNIT CI_SANITIZER) +add_layer2_test(test_shutdown_coordinator CI_UNIT CI_SANITIZER) add_layer2_test(test_go2rtc_lifecycle) -add_layer2_test(test_detection_config) -add_layer2_test_with_curl(test_detection_model_motion) +add_layer2_test(test_detection_config CI_UNIT CI_SANITIZER) +add_layer2_test_with_curl(test_detection_model_motion CI_UNIT CI_SANITIZER) add_layer2_test_with_curl(test_detection_system_onvif) add_layer3_test(test_onvif_poll_recovery) target_link_options(test_onvif_poll_recovery PRIVATE @@ -163,7 +168,7 @@ target_compile_definitions(test_onvif_lpr_event PRIVATE TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" ) add_layer2_test_with_ffmpeg(test_api_detection) -add_layer2_test_with_ffmpeg(test_mp4_failed_open_memory) +add_layer2_test_with_ffmpeg(test_mp4_failed_open_memory CI_UNIT) add_layer2_test_with_ffmpeg(test_ffmpeg_rgb24_padding) if(TARGET lightnvr-ffmpeg-guard) add_executable(test_ffmpeg_guard_probe ffmpeg_guard_probe.c) @@ -182,8 +187,8 @@ if(TARGET lightnvr-ffmpeg-guard) "$" "$") endif() -add_layer2_test_with_curl(test_url_utils) -add_layer2_test(test_db_streams) +add_layer2_test_with_curl(test_url_utils CI_UNIT) +add_layer2_test(test_db_streams CI_UNIT CI_SANITIZER) add_layer2_test(test_db_detection_engines) add_layer2_test(test_db_lpr_reads) add_layer2_test(test_api_handlers_lpr) @@ -198,8 +203,8 @@ add_layer2_test(test_api_handlers_fleet) add_layer2_test(test_api_handlers_workspaces) add_layer2_test(test_db_live_saved_layouts) add_layer2_test(test_api_handlers_live_layouts) -add_layer2_test(test_db_camera_favorites) -add_layer2_test(test_api_handlers_camera_favorites) +add_layer2_test(test_db_camera_favorites CI_UNIT) +add_layer2_test(test_api_handlers_camera_favorites CI_UNIT) add_layer2_test(test_db_operator_floor_plans) add_layer2_test(test_api_handlers_operator_floor_plans) add_layer2_test(test_api_handlers_investigations) @@ -207,10 +212,10 @@ add_layer2_test(test_db_investigation_bookmarks) add_layer2_test(test_api_handlers_investigation_bookmarks) add_layer2_test(test_db_camera_collections) add_layer2_test(test_api_handlers_camera_collections) -add_layer2_test(test_db_recordings_extended) +add_layer2_test(test_db_recordings_extended CI_UNIT CI_SANITIZER) add_layer2_test(test_api_handlers_recordings_list) add_layer2_test(test_recording_transcode) -add_layer2_test(test_api_handlers_recordings_playback) +add_layer2_test(test_api_handlers_recordings_playback CI_UNIT) target_link_options(test_api_handlers_recordings_playback PRIVATE "-Wl,--wrap=storage_source_resolve" "-Wl,--wrap=storage_source_remote" @@ -220,22 +225,22 @@ target_link_options(test_api_handlers_recordings_playback PRIVATE "-Wl,--wrap=request_recording_transcode_cache" "-Wl,--wrap=http_serve_file" ) -add_layer2_test(test_storage_manager_retention) -add_layer2_test(test_storage_target_pressure_cleanup) -add_layer2_test(test_storage_migration) +add_layer2_test(test_storage_manager_retention CI_UNIT) +add_layer2_test(test_storage_target_pressure_cleanup CI_UNIT) +add_layer2_test(test_storage_migration CI_UNIT) add_layer2_test(test_storage_deletion_ledger) # Layer 2: deletion ledger scoping, pruning, indexes if(ENABLE_S3_ARCHIVE) - add_layer2_test(test_storage_archive) + add_layer2_test(test_storage_archive CI_UNIT) target_link_options(test_storage_archive PRIVATE "-Wl,--wrap=uv_timer_start" "-Wl,--wrap=db_open_readonly_connection") endif() -add_layer2_test(test_db_detections) -add_layer2_test(test_db_zones) -add_layer2_test(test_db_events) -add_layer2_test(test_db_auth) -add_layer2_test(test_authorization) -add_layer2_test(test_audit_log) -add_layer2_test(test_audit_summary) +add_layer2_test(test_db_detections CI_UNIT CI_SANITIZER) +add_layer2_test(test_db_zones CI_UNIT CI_SANITIZER) +add_layer2_test(test_db_events CI_UNIT CI_SANITIZER) +add_layer2_test(test_db_auth CI_UNIT CI_SANITIZER) +add_layer2_test(test_authorization CI_UNIT) +add_layer2_test(test_audit_log CI_SANITIZER) +add_layer2_test(test_audit_summary CI_SANITIZER) add_layer2_test(test_event_envelope) add_layer2_test(test_event_bus) add_layer2_test(test_event_detection_adapter) @@ -247,20 +252,32 @@ add_layer2_test(test_api_handlers_event_routes) add_layer2_test(test_event_router) add_layer2_test(test_db_event_destinations) add_layer2_test(test_api_handlers_event_destinations) -add_layer2_test(test_db_storage_targets) -add_layer2_test(test_db_storage_policies) -add_layer2_test(test_api_handlers_storage_targets) -add_layer2_test(test_api_handlers_storage_policies) -add_layer2_test(test_db_transactions) -add_layer2_test(test_db_maintenance) -add_layer2_test(test_db_query_builder) +add_layer2_test(test_db_storage_targets CI_UNIT) +add_layer2_test(test_db_storage_policies CI_UNIT) +add_layer2_test(test_api_handlers_storage_targets CI_UNIT) +add_layer2_test(test_api_handlers_storage_policies CI_UNIT) +add_layer2_test(test_db_transactions CI_UNIT CI_SANITIZER) +add_layer2_test(test_db_maintenance CI_UNIT CI_SANITIZER) +add_layer2_test(test_db_backup_cleanup CI_UNIT CI_SANITIZER) +target_link_options(test_db_backup_cleanup PRIVATE -Wl,--wrap=lstat -Wl,--wrap=fstatat + -Wl,--wrap=openat -Wl,--wrap=close -Wl,--wrap=fstat -Wl,--wrap=mkdirat + -Wl,--wrap=syscall -Wl,--wrap=unlinkat + -Wl,--wrap=readdir -Wl,--wrap=lightnvr_uuid_generate_v4) +set_tests_properties(test_db_backup_cleanup PROPERTIES TIMEOUT 30) +add_layer3_test(test_db_backup_retention_identity CI_UNIT CI_SANITIZER) +target_link_options(test_db_backup_retention_identity PRIVATE -Wl,--wrap=stat -Wl,--wrap=fstat + -Wl,--wrap=openat -Wl,--wrap=close -Wl,--wrap=opendir -Wl,--wrap=closedir + -Wl,--wrap=readdir -Wl,--wrap=realloc -Wl,--wrap=strdup + -Wl,--wrap=syscall -Wl,--wrap=unlinkat) +set_tests_properties(test_db_backup_retention_identity PROPERTIES TIMEOUT 30) +add_layer2_test(test_db_query_builder CI_UNIT CI_SANITIZER) add_layer2_test(test_db_system_settings) -add_layer2_test(test_logger_json) -add_layer2_test(test_batch_delete_progress) -add_layer2_test(test_db_recordings_sync) -add_layer2_test(test_httpd_utils) -add_layer2_test(test_zone_filter) -add_layer2_test(test_onvif_soap_fault) +add_layer2_test(test_logger_json CI_UNIT CI_SANITIZER) +add_layer2_test(test_batch_delete_progress CI_UNIT CI_SANITIZER) +add_layer2_test(test_db_recordings_sync CI_UNIT CI_SANITIZER) +add_layer2_test(test_httpd_utils CI_UNIT CI_SANITIZER) +add_layer2_test(test_zone_filter CI_UNIT CI_SANITIZER) +add_layer2_test(test_onvif_soap_fault CI_UNIT) add_layer2_test(test_onvif_discovery_response) add_layer2_test(test_go2rtc_base_path) add_layer2_test(test_yaml_validate) @@ -342,26 +359,26 @@ endif() # ==================================================================== # Layer 3: Integration tests — lightnvr_lib + FFmpeg # ==================================================================== -add_layer3_test(test_faac_encoder) -add_layer3_test(test_audio_transcoder) -add_layer3_test(test_mp4_forced_reconnect) +add_layer3_test(test_faac_encoder CI_UNIT CI_SANITIZER) +add_layer3_test(test_audio_transcoder CI_UNIT CI_SANITIZER) +add_layer3_test(test_mp4_forced_reconnect CI_UNIT) target_link_options(test_mp4_forced_reconnect PRIVATE "-Wl,--wrap=record_segment" "-Wl,--wrap=av_usleep" "-Wl,--wrap=get_stream_config_by_name" "-Wl,--wrap=storage_should_pause_recording" "-Wl,--wrap=prepare_placed_mp4_recording_path") -add_layer3_test(test_mp4_recovery_watchdog) +add_layer3_test(test_mp4_recovery_watchdog CI_UNIT) target_link_options(test_mp4_recovery_watchdog PRIVATE "-Wl,--wrap=sleep" "-Wl,--wrap=mp4_writer_start_recording_thread" "-Wl,--wrap=mp4_writer_stop_recording_thread" "-Wl,--wrap=mp4_writer_is_recording" "-Wl,--wrap=go2rtc_integration_is_using_go2rtc_for_recording" "-Wl,--wrap=_log_message_ctx") set_tests_properties(test_mp4_forced_reconnect test_mp4_recovery_watchdog PROPERTIES TIMEOUT 10) -add_layer3_test(test_mp4_audio_recovery) +add_layer3_test(test_mp4_audio_recovery CI_UNIT) target_link_options(test_mp4_audio_recovery PRIVATE "-Wl,--wrap=av_read_frame" "-Wl,--wrap=cleanup_audio_transcoder") -add_layer3_test(test_stream_manager) -add_layer3_test(test_stream_state) -add_layer3_test(test_packet_buffer) -add_layer3_test(test_hls_cleanup) +add_layer3_test(test_stream_manager CI_UNIT CI_SANITIZER) +add_layer3_test(test_stream_state CI_UNIT CI_SANITIZER) +add_layer3_test(test_packet_buffer CI_UNIT CI_SANITIZER) +add_layer3_test(test_hls_cleanup CI_UNIT) target_compile_definitions(test_hls_cleanup PRIVATE TEST_RECORDING_PATH="${CMAKE_SOURCE_DIR}/tests/integration/fixtures/recording.mp4" ) @@ -374,33 +391,33 @@ target_link_options(test_hls_reconnect_backoff PRIVATE "-Wl,--wrap=av_usleep") set_tests_properties(test_hls_reconnect_backoff PROPERTIES TIMEOUT 10) add_layer3_test(test_mp4_writer_watchdog) add_layer3_test(test_mp4_segment_timestamps) -add_layer3_test(test_mp4_segment_boundaries) -add_layer3_test(test_recording_video_params) +add_layer3_test(test_mp4_segment_boundaries CI_UNIT CI_SANITIZER) +add_layer3_test(test_recording_video_params CI_UNIT CI_SANITIZER) target_link_options(test_recording_video_params PRIVATE "-Wl,--wrap=update_stream_video_params") add_layer3_test(test_mp4_faststart) target_compile_definitions(test_mp4_faststart PRIVATE RECORDING_FIXTURE="${CMAKE_SOURCE_DIR}/tests/integration/fixtures/recording.mp4") -add_layer3_test(test_audio_voice_enhancement) +add_layer3_test(test_audio_voice_enhancement CI_UNIT CI_SANITIZER) if(ENABLE_AUDIO_FILTERS) # Fault injection for the voice-enhancement graph (issue #652). - add_layer3_test(test_audio_voice_filter_faults) + add_layer3_test(test_audio_voice_filter_faults CI_UNIT CI_SANITIZER) target_link_options(test_audio_voice_filter_faults PRIVATE "-Wl,--wrap=avfilter_inout_alloc" "-Wl,--wrap=av_strdup" "-Wl,--wrap=avfilter_graph_config" "-Wl,--wrap=av_buffersrc_add_frame_flags" "-Wl,--wrap=av_buffersink_get_frame" "-Wl,--wrap=av_audio_fifo_write" ) endif() -add_layer3_test(test_timestamp_manager) +add_layer3_test(test_timestamp_manager CI_UNIT CI_SANITIZER) add_layer3_test(test_api_handlers_system) add_layer3_test(test_api_read_isolation) set_tests_properties(test_api_read_isolation PROPERTIES TIMEOUT 15) add_layer1_test(test_external_motion_trigger) # Layer 1: external_motion_trigger state-machine (PR #356) -add_layer2_test(test_motion_trigger_parse) # Layer 2: motion trigger body parsing (#466) -add_layer2_test(test_motion_max_area) # Layer 2: motion detector max-area sanity cap +add_layer2_test(test_motion_trigger_parse CI_UNIT) # Layer 2: motion trigger body parsing (#466) +add_layer2_test(test_motion_max_area CI_UNIT) # Layer 2: motion detector max-area sanity cap add_layer3_test(test_cross_stream_motion_trigger) add_layer3_test(test_annotation_writer_registry) # Layer 3: continuous-writer probe behind annotation mode (#547) -add_layer3_test(test_detection_current_frame) +add_layer3_test(test_detection_current_frame CI_UNIT CI_SANITIZER) target_compile_definitions(test_detection_current_frame PRIVATE TEST_FIXTURE_DIR="${CMAKE_SOURCE_DIR}/tests/fixtures/detection" ) @@ -411,7 +428,7 @@ target_link_options(test_detection_current_frame PRIVATE "-Wl,--wrap=filter_detections_by_stream_objects" ) set_tests_properties(test_detection_current_frame PROPERTIES TIMEOUT 15) -add_layer3_test(test_detection_io_recovery) +add_layer3_test(test_detection_io_recovery CI_UNIT CI_SANITIZER) target_compile_definitions(test_detection_io_recovery PRIVATE TEST_RECORDING_PATH="${CMAKE_SOURCE_DIR}/tests/integration/fixtures/recording.mp4" ) @@ -421,7 +438,7 @@ target_link_options(test_detection_io_recovery PRIVATE "-Wl,--wrap=_log_message_ctx" ) set_tests_properties(test_detection_io_recovery PROPERTIES TIMEOUT 30) -add_layer3_test(test_udt_recording) +add_layer3_test(test_udt_recording CI_UNIT CI_SANITIZER) target_compile_definitions(test_udt_recording PRIVATE TEST_RECORDING_PATH="${CMAKE_SOURCE_DIR}/tests/integration/fixtures/recording.mp4" ) diff --git a/tests/unit/cmake/system_health/startup.cmake b/tests/unit/cmake/system_health/startup.cmake index decdf19dc..ad9c3df56 100644 --- a/tests/unit/cmake/system_health/startup.cmake +++ b/tests/unit/cmake/system_health/startup.cmake @@ -1,4 +1,4 @@ -add_layer3_test(test_system_health_startup) +add_layer3_test(test_system_health_startup CI_UNIT) target_link_options(test_system_health_startup PRIVATE -Wl,--wrap=pthread_cond_wait -Wl,--wrap=pthread_mutex_lock -Wl,--wrap=pthread_create -Wl,--wrap=pthread_join) set_tests_properties(test_system_health_startup PROPERTIES TIMEOUT 30) diff --git a/tests/unit/test_db_backup_cleanup.c b/tests/unit/test_db_backup_cleanup.c new file mode 100644 index 000000000..3e24eb6a6 --- /dev/null +++ b/tests/unit/test_db_backup_cleanup.c @@ -0,0 +1,412 @@ +#define _GNU_SOURCE +#define _POSIX_C_SOURCE 200809L +/* Actual private cleanup helper, with public DB definitions linked from here. */ +#include "../../src/database/db_core.c" +#include +#include "unity.h" + +static char root[128], backup_dir[256], moved_dir[256], artifact[320], sentinel[256]; +static bool armed, replaced, leaf_swap, regular_swap, fixture_config_loaded; +static sqlite3 *fixture_db; +static config_t saved_config; +static struct stat original_identity; +static bool held_inode_verified, fail_candidate_open, fail_private_open, fail_mkdir; +static bool fail_move, fail_delete, restore_collision, change_after_move; +static int restore_error, move_error, private_stat_fault, private_fd; +static unsigned restore_calls, mkdir_calls; +static bool fail_uuid, collide_mkdir, repeat_restored, repeated, substitute_private_directory; +struct dirent *__real_readdir(DIR *); +struct dirent *__wrap_readdir(DIR *dir) { + if (repeat_restored && restore_calls && !repeated) { + static struct dirent duplicate; + memset(&duplicate, 0, sizeof(duplicate)); + strcpy(duplicate.d_name, "20261031T010000Z.sqlite3.tmp"); + repeated = true; + return &duplicate; + } + return __real_readdir(dir); +} +int __real_lightnvr_uuid_generate_v4(char *); +int __wrap_lightnvr_uuid_generate_v4(char *uuid) { + return fail_uuid ? -1 : __real_lightnvr_uuid_generate_v4(uuid); +} +static int owned_fds[32]; +static unsigned owned_fd_count; +static const char artifact_name[] = "20261031T010000Z.sqlite3.tmp"; +static void put_file(const char *path, const char *bytes) { + FILE *file = fopen(path, "w"); TEST_ASSERT_NOT_NULL(file); + TEST_ASSERT_EQUAL_UINT(strlen(bytes), fwrite(bytes, 1, strlen(bytes), file)); + TEST_ASSERT_EQUAL_INT(0, fclose(file)); +} +static void replace_after_check(void) { + armed = false; replaced = true; + if (leaf_swap) { + TEST_ASSERT_EQUAL_INT(0, unlink(artifact)); + if (regular_swap) put_file(artifact, "new regular file must survive"); + else TEST_ASSERT_EQUAL_INT(0, symlink(sentinel, artifact)); + } else { + TEST_ASSERT_EQUAL_INT(0, rename(backup_dir, moved_dir)); + TEST_ASSERT_EQUAL_INT(0, mkdir(backup_dir, 0700)); + put_file(artifact, "replacement must survive"); + } +} +int __real_openat(int, const char *, int, ...); +int __wrap_openat(int fd, const char *name, int flags, ...) { + mode_t mode = 0; + if (flags & O_CREAT) { va_list args; va_start(args, flags); mode = va_arg(args, int); va_end(args); } + bool candidate = strcmp(name, artifact_name) == 0; + bool private = strncmp(name, ".cleanup-", 9) == 0; + if ((candidate && fail_candidate_open) || (private && fail_private_open)) { errno = EACCES; return -1; } + int result = __real_openat(fd, name, flags, mode); + if (result >= 0) { + TEST_ASSERT_LESS_THAN_UINT(32, owned_fd_count); + owned_fds[owned_fd_count++] = result; + if (private) private_fd = result; + } + return result; +} +int __real_close(int); +int __wrap_close(int fd) { + int result = __real_close(fd); + for (unsigned i = 0; i < owned_fd_count; i++) if (owned_fds[i] == fd) { + owned_fds[i] = owned_fds[--owned_fd_count]; break; + } + return result; +} +int __real_fstat(int, struct stat *); +int __wrap_fstat(int fd, struct stat *info) { + int result = __real_fstat(fd, info); + if (result == 0 && armed && info->st_dev == original_identity.st_dev && + info->st_ino == original_identity.st_ino && S_ISREG(info->st_mode)) { + held_inode_verified = true; + replace_after_check(); + struct stat pinned; + TEST_ASSERT_EQUAL_INT(0, __real_fstat(fd, &pinned)); + TEST_ASSERT_EQUAL_UINT64(original_identity.st_ino, pinned.st_ino); + } + if (result == 0 && fd == private_fd && S_ISDIR(info->st_mode)) { + if (private_stat_fault == 1) info->st_uid = geteuid() + 1; + if (private_stat_fault == 2) info->st_mode |= 0070; + if (private_stat_fault == 3) info->st_ino++; + } + return result; +} +int __real_mkdirat(int, const char *, mode_t); +int __wrap_mkdirat(int fd, const char *name, mode_t mode) { + if (strncmp(name, ".cleanup-", 9) == 0) { + mkdir_calls++; + if (fail_mkdir || collide_mkdir) { errno = collide_mkdir ? EEXIST : EACCES; return -1; } + } + int result = __real_mkdirat(fd, name, mode); + if (result == 0 && substitute_private_directory && strncmp(name, ".cleanup-", 9) == 0) { + char prepared[256]; snprintf(prepared, sizeof(prepared), "%s/prepared-private", root); + TEST_ASSERT_EQUAL_INT(0, renameat(fd, name, fd, "saved-created-directory")); + TEST_ASSERT_EQUAL_INT(0, renameat(AT_FDCWD, prepared, fd, name)); + } + return result; +} +long __real_syscall(long, ...); +long __wrap_syscall(long number, ...) { + if (number != SYS_renameat2) { + TEST_FAIL_MESSAGE("Unexpected syscall in bounded renameat2 fault seam"); + errno = ENOSYS; return -1; + } + va_list args; va_start(args, number); + int oldfd = va_arg(args, int); + const char *oldname = va_arg(args, const char *); + int newfd = va_arg(args, int); + const char *newname = va_arg(args, const char *); + unsigned int flags = va_arg(args, unsigned int); + va_end(args); + TEST_ASSERT_EQUAL_UINT(RENAME_NOREPLACE, flags); + if (newfd == private_fd) { + if (fail_move || move_error) { errno = move_error ? move_error : EIO; return -1; } + long result = __real_syscall(number, oldfd, oldname, newfd, newname, flags); + if (result == 0) { + if (restore_collision) put_file(artifact, "concurrent destination must survive"); + if (change_after_move) { + int fd = __real_openat(newfd, newname, O_WRONLY | O_APPEND); + TEST_ASSERT_GREATER_OR_EQUAL_INT(0, fd); + TEST_ASSERT_EQUAL_INT(8, write(fd, " changed", 8)); + TEST_ASSERT_EQUAL_INT(0, close(fd)); + } + } + return result; + } + restore_calls++; + if (restore_error) { errno = restore_error; return -1; } + return __real_syscall(number, oldfd, oldname, newfd, newname, flags); +} +int __real_unlinkat(int, const char *, int); +int __wrap_unlinkat(int fd, const char *name, int flags) { + if (fail_delete && flags == 0 && strcmp(name, artifact_name) == 0) { errno = EACCES; return -1; } + return __real_unlinkat(fd, name, flags); +} +int __real_lstat(const char *, struct stat *); +int __wrap_lstat(const char *path, struct stat *info) { + int result = __real_lstat(path, info); + if (armed && result == 0 && strcmp(path, artifact) == 0) replace_after_check(); + return result; +} +int __real_fstatat(int, const char *, struct stat *, int); +int __wrap_fstatat(int fd, const char *name, struct stat *info, int flags) { + int result = __real_fstatat(fd, name, info, flags); + if (armed && result == 0 && strcmp(name, artifact_name) == 0) replace_after_check(); + return result; +} +static void assert_bytes(const char *path, const char *expected) { + FILE *file = fopen(path, "r"); TEST_ASSERT_NOT_NULL(file); + char actual[128] = {0}; size_t bytes = fread(actual, 1, sizeof(actual)-1, file); + TEST_ASSERT_EQUAL_INT(0, fclose(file)); + TEST_ASSERT_EQUAL_UINT(strlen(expected), bytes); TEST_ASSERT_EQUAL_STRING(expected, actual); +} +static void remove_owned_tree(const char *directory) { + DIR *dir = opendir(directory); if (!dir) return; + struct dirent *entry; + while ((entry = readdir(dir))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + char path[512]; snprintf(path, sizeof(path), "%s/%s", directory, entry->d_name); + struct stat info; + if (lstat(path, &info) == 0 && S_ISDIR(info.st_mode)) remove_owned_tree(path); + else unlink(path); + } + closedir(dir); rmdir(directory); +} +void setUp(void) { + saved_config = g_config; fixture_db = NULL; fixture_config_loaded = false; + strcpy(root, "/tmp/lightnvr-db-backup-cleanup-XXXXXX"); TEST_ASSERT_NOT_NULL(mkdtemp(root)); + snprintf(backup_dir, sizeof(backup_dir), "%s/backup", root); + snprintf(moved_dir, sizeof(moved_dir), "%s/inspected", root); + snprintf(artifact, sizeof(artifact), "%s/%s", backup_dir, artifact_name); + snprintf(sentinel, sizeof(sentinel), "%s/sentinel", root); + TEST_ASSERT_EQUAL_INT(0, mkdir(backup_dir, 0700)); + put_file(artifact, "stale partial backup"); put_file(sentinel, "sentinel unchanged"); + armed = replaced = leaf_swap = regular_swap = false; + held_inode_verified = fail_candidate_open = fail_private_open = fail_mkdir = false; + fail_move = fail_delete = restore_collision = change_after_move = false; + restore_error = move_error = private_stat_fault = 0; private_fd = -1; + restore_calls = owned_fd_count = mkdir_calls = 0; + fail_uuid = collide_mkdir = repeat_restored = repeated = substitute_private_directory = false; + TEST_ASSERT_EQUAL_INT(0, stat(artifact, &original_identity)); +} +void tearDown(void) { + armed = repeat_restored = false; + if (get_db_handle()) shutdown_database(); + if (fixture_db) { sqlite3_close_v2(fixture_db); fixture_db = NULL; } + if (fixture_config_loaded) free(g_config.streams); + g_config = saved_config; + remove_owned_tree(root); + TEST_ASSERT_EQUAL_INT(-1, access(root, F_OK)); + TEST_ASSERT_EQUAL_UINT(0, owned_fd_count); +} +static void test_normal_stale_cleanup_preserves_completed_unrelated_and_symlinks(void) { + char complete[320], other[320], link[320], directory[320]; + snprintf(complete, sizeof(complete), "%s/complete.sqlite3", backup_dir); + snprintf(other, sizeof(other), "%s/operator-notes.tmp", backup_dir); + snprintf(link, sizeof(link), "%s/link.sqlite3.tmp", backup_dir); + snprintf(directory, sizeof(directory), "%s/directory.sqlite3.tmp", backup_dir); + put_file(complete, "complete"); put_file(other, "notes"); + TEST_ASSERT_EQUAL_INT(0, symlink(sentinel, link)); + TEST_ASSERT_EQUAL_INT(0, mkdir(directory, 0700)); + TEST_ASSERT_EQUAL_INT(0, cleanup_stale_backup_artifacts(backup_dir)); + TEST_ASSERT_EQUAL_INT(-1, access(artifact, F_OK)); + assert_bytes(complete, "complete"); assert_bytes(other, "notes"); + struct stat info; TEST_ASSERT_EQUAL_INT(0, lstat(link, &info)); TEST_ASSERT_TRUE(S_ISLNK(info.st_mode)); + TEST_ASSERT_EQUAL_INT(0, lstat(directory, &info)); TEST_ASSERT_TRUE(S_ISDIR(info.st_mode)); + assert_bytes(sentinel, "sentinel unchanged"); +} +static void test_parent_replacement_cannot_redirect_stale_cleanup(void) { + armed = true; + TEST_ASSERT_EQUAL_INT(0, cleanup_stale_backup_artifacts(backup_dir)); + TEST_ASSERT_TRUE(replaced); + assert_bytes(artifact, "replacement must survive"); + char old_artifact[320]; snprintf(old_artifact, sizeof(old_artifact), "%s/%s", moved_dir, artifact_name); + TEST_ASSERT_EQUAL_INT(-1, access(old_artifact, F_OK)); + assert_bytes(sentinel, "sentinel unchanged"); +} +static void test_leaf_symlink_replacement_does_not_remove_unvalidated_entry(void) { + armed = leaf_swap = true; + (void)cleanup_stale_backup_artifacts(backup_dir); + TEST_ASSERT_TRUE(replaced); + assert_bytes(sentinel, "sentinel unchanged"); + struct stat info; + TEST_ASSERT_EQUAL_INT(0, lstat(artifact, &info)); + TEST_ASSERT_TRUE(S_ISLNK(info.st_mode)); +} +static void test_missing_directory_is_noop(void) { + char missing[256]; snprintf(missing, sizeof(missing), "%s/missing", root); + TEST_ASSERT_EQUAL_INT(0, cleanup_stale_backup_artifacts(missing)); +} +static void test_leaf_regular_replacement_preserves_new_contents(void) { + armed = leaf_swap = regular_swap = true; + (void)cleanup_stale_backup_artifacts(backup_dir); + TEST_ASSERT_TRUE(replaced); + assert_bytes(artifact, "new regular file must survive"); +} +static void test_non_directory_returns_error(void) { + TEST_ASSERT_EQUAL_INT(-1, cleanup_stale_backup_artifacts(sentinel)); + assert_bytes(sentinel, "sentinel unchanged"); +} +static void test_readonly_open_backup_restore_preserve_database_contents(void) { + char source[256], copy[256], restored[256], associated_backups[300]; + snprintf(source, sizeof(source), "%s/database.sqlite", root); + snprintf(copy, sizeof(copy), "%s/copy.sqlite", root); + snprintf(restored, sizeof(restored), "%s/restored.sqlite", root); + snprintf(associated_backups, sizeof(associated_backups), "%s.backups", source); + TEST_ASSERT_EQUAL_INT(0, rename(backup_dir, associated_backups)); + safe_strcpy(backup_dir, associated_backups, sizeof(backup_dir), 0); + snprintf(artifact, sizeof(artifact), "%s/%s", backup_dir, artifact_name); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_open(source, &fixture_db)); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_exec(fixture_db, + "CREATE TABLE sample(value TEXT); INSERT INTO sample VALUES('preserved');", NULL, NULL, NULL)); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_close(fixture_db)); fixture_db = NULL; + memset(&g_config, 0, sizeof(g_config)); + fixture_config_loaded = true; + load_default_config(&g_config); + TEST_ASSERT_EQUAL_INT(0, init_database_ex(source, DB_INIT_READ_ONLY)); + TEST_ASSERT_EQUAL_INT(1, sqlite3_db_readonly(get_db_handle(), "main")); + assert_bytes(artifact, "stale partial backup"); + shutdown_database(); + TEST_ASSERT_EQUAL_INT(0, backup_database(source, copy, false)); + TEST_ASSERT_EQUAL_INT(0, restore_database_from_backup(copy, restored)); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_open_v2(restored, &fixture_db, SQLITE_OPEN_READONLY, NULL)); + sqlite3_stmt *statement = NULL; + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_prepare_v2(fixture_db, "SELECT value FROM sample", -1, &statement, NULL)); + int stepped = sqlite3_step(statement); + bool preserved = stepped == SQLITE_ROW && strcmp((const char *)sqlite3_column_text(statement, 0), "preserved") == 0; + sqlite3_finalize(statement); + TEST_ASSERT_TRUE(preserved); +} +static unsigned quarantine_count(char *retained, size_t length) { + DIR *dir = opendir(backup_dir); TEST_ASSERT_NOT_NULL(dir); + unsigned count = 0; struct dirent *entry; + while ((entry = readdir(dir))) if (strncmp(entry->d_name, ".cleanup-", 9) == 0) { + count++; + if (retained) snprintf(retained, length, "%s/%s/%s", backup_dir, entry->d_name, artifact_name); + } + closedir(dir); return count; +} +static void assert_clean_failure(void) { + TEST_ASSERT_EQUAL_INT(-1, cleanup_stale_backup_artifacts(backup_dir)); + assert_bytes(artifact, "stale partial backup"); + TEST_ASSERT_EQUAL_UINT(0, quarantine_count(NULL, 0)); +} +static void test_candidate_open_failure_preserves_artifact(void) { fail_candidate_open = true; assert_clean_failure(); } +static void test_private_directory_open_failure_preserves_artifact(void) { fail_private_open = true; assert_clean_failure(); } +static void test_private_directory_create_failure_preserves_artifact(void) { fail_mkdir = true; assert_clean_failure(); } +static void test_move_failure_preserves_artifact(void) { fail_move = true; assert_clean_failure(); } +static void test_unlink_failure_restores_without_overwrite(void) { + fail_delete = true; assert_clean_failure(); TEST_ASSERT_EQUAL_UINT(1, restore_calls); +} +static void test_private_directory_wrong_owner_is_rejected(void) { private_stat_fault = 1; assert_clean_failure(); } +static void test_private_directory_wrong_mode_is_rejected(void) { private_stat_fault = 2; assert_clean_failure(); } +static void test_private_directory_wrong_inode_is_rejected(void) { private_stat_fault = 3; assert_clean_failure(); } +static void retained_restore(int error, bool collision) { + armed = leaf_swap = regular_swap = true; + restore_error = error; restore_collision = collision; + TEST_ASSERT_EQUAL_INT(-1, cleanup_stale_backup_artifacts(backup_dir)); + TEST_ASSERT_TRUE(held_inode_verified); + TEST_ASSERT_EQUAL_UINT(1, restore_calls); + char retained[640]; TEST_ASSERT_EQUAL_UINT(1, quarantine_count(retained, sizeof(retained))); + assert_bytes(retained, "new regular file must survive"); + if (collision) assert_bytes(artifact, "concurrent destination must survive"); + else TEST_ASSERT_EQUAL_INT(-1, access(artifact, F_OK)); +} +static void test_restore_collision_preserves_both_objects(void) { retained_restore(0, true); } +static void test_unsupported_restore_retains_quarantine(void) { retained_restore(ENOSYS, false); } +static void test_restore_error_retains_quarantine(void) { retained_restore(EIO, false); } +static void test_changed_size_or_mtime_is_restored(void) { + change_after_move = true; + TEST_ASSERT_EQUAL_INT(-1, cleanup_stale_backup_artifacts(backup_dir)); + assert_bytes(artifact, "stale partial backup changed"); + TEST_ASSERT_EQUAL_UINT(1, restore_calls); TEST_ASSERT_EQUAL_UINT(0, quarantine_count(NULL, 0)); +} +static void test_crash_leftover_is_not_recursively_deleted(void) { + char private[320], retained[400]; + snprintf(private, sizeof(private), "%s/.cleanup-leftover", backup_dir); + snprintf(retained, sizeof(retained), "%s/%s", private, artifact_name); + TEST_ASSERT_EQUAL_INT(0, mkdir(private, 0700)); put_file(retained, "retained crash artifact"); + TEST_ASSERT_EQUAL_INT(0, cleanup_stale_backup_artifacts(backup_dir)); + assert_bytes(retained, "retained crash artifact"); TEST_ASSERT_EQUAL_UINT(1, quarantine_count(NULL, 0)); +} +static void test_fifo_artifact_is_skipped_without_blocking(void) { + char fifo[320]; snprintf(fifo, sizeof(fifo), "%s/fifo.sqlite3.tmp", backup_dir); + TEST_ASSERT_EQUAL_INT(0, mkfifo(fifo, 0600)); + TEST_ASSERT_EQUAL_INT(0, cleanup_stale_backup_artifacts(backup_dir)); + struct stat info; TEST_ASSERT_EQUAL_INT(0, lstat(fifo, &info)); TEST_ASSERT_TRUE(S_ISFIFO(info.st_mode)); +} +static void test_candidate_descriptor_pins_inode_until_replacement_is_preserved(void) { + armed = leaf_swap = regular_swap = true; + (void)cleanup_stale_backup_artifacts(backup_dir); + TEST_ASSERT_TRUE(held_inode_verified); + assert_bytes(artifact, "new regular file must survive"); +} +static void test_restored_changed_entry_is_not_revisited_in_same_pass(void) { + armed = leaf_swap = regular_swap = repeat_restored = true; + int result = cleanup_stale_backup_artifacts(backup_dir); + repeat_restored = false; + TEST_ASSERT_EQUAL_INT(-1, result); + assert_bytes(artifact, "new regular file must survive"); + TEST_ASSERT_EQUAL_UINT(1, restore_calls); +} +static void test_uuid_failure_preserves_artifact(void) { + fail_uuid = true; assert_clean_failure(); TEST_ASSERT_EQUAL_UINT(0, mkdir_calls); +} +static void test_directory_name_collisions_have_bounded_retries(void) { + collide_mkdir = true; assert_clean_failure(); TEST_ASSERT_EQUAL_UINT(4, mkdir_calls); +} +static void test_unsupported_filesystem_restore_retains_quarantine(void) { retained_restore(EINVAL, false); } +static void test_setgid_parent_still_allows_private_cleanup(void) { + TEST_ASSERT_EQUAL_INT(0, chmod(backup_dir, 02700)); + TEST_ASSERT_EQUAL_INT(0, cleanup_stale_backup_artifacts(backup_dir)); + TEST_ASSERT_EQUAL_INT(-1, access(artifact, F_OK)); + TEST_ASSERT_EQUAL_UINT(0, quarantine_count(NULL, 0)); +} +static void test_populated_private_directory_substitution_never_overwrites_data(void) { + char prepared[256], unrelated[320], retained[640]; + snprintf(prepared, sizeof(prepared), "%s/prepared-private", root); + snprintf(unrelated, sizeof(unrelated), "%s/%s", prepared, artifact_name); + TEST_ASSERT_EQUAL_INT(0, mkdir(prepared, 0700)); + put_file(unrelated, "unrelated private data"); + substitute_private_directory = true; + TEST_ASSERT_EQUAL_INT(-1, cleanup_stale_backup_artifacts(backup_dir)); + assert_bytes(artifact, "stale partial backup"); + TEST_ASSERT_EQUAL_UINT(1, quarantine_count(retained, sizeof(retained))); + assert_bytes(retained, "unrelated private data"); +} +static void test_unsupported_initial_move_preserves_original_and_cleans_empty_directory(void) { + move_error = ENOSYS; assert_clean_failure(); TEST_ASSERT_EQUAL_UINT(0, restore_calls); +} +int main(void) { + UNITY_BEGIN(); + RUN_TEST(test_normal_stale_cleanup_preserves_completed_unrelated_and_symlinks); + RUN_TEST(test_parent_replacement_cannot_redirect_stale_cleanup); + RUN_TEST(test_leaf_symlink_replacement_does_not_remove_unvalidated_entry); + RUN_TEST(test_leaf_regular_replacement_preserves_new_contents); + RUN_TEST(test_missing_directory_is_noop); + RUN_TEST(test_non_directory_returns_error); + RUN_TEST(test_readonly_open_backup_restore_preserve_database_contents); + RUN_TEST(test_candidate_open_failure_preserves_artifact); + RUN_TEST(test_private_directory_open_failure_preserves_artifact); + RUN_TEST(test_private_directory_create_failure_preserves_artifact); + RUN_TEST(test_move_failure_preserves_artifact); + RUN_TEST(test_unlink_failure_restores_without_overwrite); + RUN_TEST(test_private_directory_wrong_owner_is_rejected); + RUN_TEST(test_private_directory_wrong_mode_is_rejected); + RUN_TEST(test_private_directory_wrong_inode_is_rejected); + RUN_TEST(test_restore_collision_preserves_both_objects); + RUN_TEST(test_unsupported_restore_retains_quarantine); + RUN_TEST(test_restore_error_retains_quarantine); + RUN_TEST(test_changed_size_or_mtime_is_restored); + RUN_TEST(test_crash_leftover_is_not_recursively_deleted); + RUN_TEST(test_fifo_artifact_is_skipped_without_blocking); + RUN_TEST(test_candidate_descriptor_pins_inode_until_replacement_is_preserved); + RUN_TEST(test_restored_changed_entry_is_not_revisited_in_same_pass); + RUN_TEST(test_uuid_failure_preserves_artifact); + RUN_TEST(test_directory_name_collisions_have_bounded_retries); + RUN_TEST(test_unsupported_filesystem_restore_retains_quarantine); + RUN_TEST(test_setgid_parent_still_allows_private_cleanup); + RUN_TEST(test_populated_private_directory_substitution_never_overwrites_data); + RUN_TEST(test_unsupported_initial_move_preserves_original_and_cleans_empty_directory); + return UNITY_END(); +} diff --git a/tests/unit/test_db_backup_retention_identity.c b/tests/unit/test_db_backup_retention_identity.c new file mode 100644 index 000000000..facb175ed --- /dev/null +++ b/tests/unit/test_db_backup_retention_identity.c @@ -0,0 +1,458 @@ +#define _GNU_SOURCE +/* Exercise the private retention helper, without changing production visibility + * or opening an application database. The production implementation is intact. */ +#include "../../src/database/db_core.c" + +#include +#include "unity.h" + +static char root[128], backups[256], moved[256], old_path[384], newer_path[384]; +static char saved_old[256], target[256]; +static unsigned inspected_files, swaps; +static int swap_kind; +static bool fixture_error, active; +static struct stat old_identity, newer_identity; +static unsigned seen_mask, candidate_stats, candidate_opens, fault_hits, pins_at_swap; +static int owned_fds[256], owned_roles[256], owned_count, private_fd; +static DIR *held_directory; +static bool repeat_name, repeated_name, repeat_ready; +static struct dirent repeat_entry; +static int fail_open_after, fail_stat_after, move_error; +static bool fail_realloc, fail_strdup, fail_read, fail_delete, restore_collision, change_after_move; + +static unsigned role_of(int fd) { + for (int i = 0; i < owned_count; ++i) if (owned_fds[i] == fd) return (unsigned)owned_roles[i]; + return 0; +} +static unsigned pin_count(void) { + unsigned n = 0; + for (int i = 0; i < owned_count; ++i) if (owned_roles[i] == 1) ++n; + return n; +} + +static int write_bytes(const char *path, const char *value) { + FILE *file = fopen(path, "wb"); + if (!file) return -1; + size_t length = strlen(value); + int result = fwrite(value, 1, length, file) == length ? 0 : -1; + if (fclose(file) != 0) result = -1; + return result; +} + +static void replace_after_inspection(void) { + int kind = swap_kind; + swap_kind = 0; + swaps++; + pins_at_swap = pin_count(); + if (kind == 1) { + if (rename(backups, moved) != 0 || mkdir(backups, 0700) != 0 || + write_bytes(old_path, "replacement-parent-old") != 0 || + write_bytes(newer_path, "replacement-parent-newer") != 0) fixture_error = true; + } else { + if (rename(old_path, saved_old) != 0) { fixture_error = true; return; } + if (kind == 2) { + if (write_bytes(old_path, "replacement-leaf") != 0) fixture_error = true; + } else if (symlink(target, old_path) != 0) { + fixture_error = true; + } + } +} + +static void inspected(const struct stat *info) { + candidate_stats++; + if (!swap_kind) return; + unsigned bit = (info->st_dev == old_identity.st_dev && info->st_ino == old_identity.st_ino) ? 1u : + (info->st_dev == newer_identity.st_dev && info->st_ino == newer_identity.st_ino) ? 2u : 0u; + if (bit && !(seen_mask & bit)) { + seen_mask |= bit; inspected_files++; + if (seen_mask == 3) replace_after_inspection(); + } +} +int __real_stat(const char *, struct stat *); +int __wrap_stat(const char *path, struct stat *info) { + int result = __real_stat(path, info); + if (result == 0 && active && (!strcmp(path, old_path) || !strcmp(path, newer_path))) inspected(info); + return result; +} +int __real_openat(int, const char *, int, ...); +int __wrap_openat(int fd, const char *name, int flags, ...) { + mode_t mode = 0; + if (flags & O_CREAT) { va_list a; va_start(a, flags); mode = va_arg(a, int); va_end(a); } + bool candidate = active && has_suffix(name, ".sqlite3") && !(flags & O_DIRECTORY); + if (candidate && fail_open_after >= 0 && (int)candidate_opens == fail_open_after) { + fault_hits++; errno = EMFILE; return -1; + } + int result = __real_openat(fd, name, flags, mode); + if (result >= 0 && active) { + if (owned_count >= 256) { fixture_error = true; close(result); errno = EMFILE; return -1; } + owned_fds[owned_count] = result; + owned_roles[owned_count++] = candidate ? 1 : 2; + if (candidate) candidate_opens++; + if (flags & O_DIRECTORY) private_fd = result; + } + return result; +} +int __real_close(int); +int __wrap_close(int fd) { + int result = __real_close(fd); + for (int i = 0; i < owned_count; ++i) if (owned_fds[i] == fd) { + owned_fds[i] = owned_fds[--owned_count]; owned_roles[i] = owned_roles[owned_count]; break; + } + if (fd == private_fd) private_fd = -1; + return result; +} +int __real_fstat(int, struct stat *); +int __wrap_fstat(int fd, struct stat *info) { + if (active && role_of(fd) == 1 && fail_stat_after >= 0 && (int)candidate_stats == fail_stat_after) { + fault_hits++; errno = EIO; return -1; + } + int result = __real_fstat(fd, info); + if (result == 0 && active && role_of(fd) == 1) inspected(info); + return result; +} +DIR *__real_opendir(const char *); +DIR *__wrap_opendir(const char *path) { + DIR *d = __real_opendir(path); + if (active && d && !strcmp(path, backups)) held_directory = d; + return d; +} +int __real_closedir(DIR *); +int __wrap_closedir(DIR *d) { + if (d == held_directory) held_directory = NULL; + return __real_closedir(d); +} +struct dirent *__real_readdir(DIR *); +struct dirent *__wrap_readdir(DIR *d) { + if (active && fail_read && candidate_stats) { fault_hits++; errno = EIO; return NULL; } + struct dirent *entry = __real_readdir(d); + if (active && repeat_name) { + if (entry && !strcmp(entry->d_name, "20240102T000000Z.sqlite3")) { + repeat_entry = *entry; repeat_ready = true; + } else if (!entry && repeat_ready && !repeated_name) { + repeated_name = true; return &repeat_entry; + } + } + return entry; +} +void *__real_realloc(void *, size_t); +void *__wrap_realloc(void *p, size_t n) { + if (active && fail_realloc) { fail_realloc = false; fault_hits++; errno = ENOMEM; return NULL; } + return __real_realloc(p, n); +} +char *__real_strdup(const char *); +char *__wrap_strdup(const char *p) { + if (active && fail_strdup) { fail_strdup = false; fault_hits++; errno = ENOMEM; return NULL; } + return __real_strdup(p); +} +long __real_syscall(long, ...); +long __wrap_syscall(long number, ...) { + if (number != SYS_renameat2) { fixture_error = true; errno = ENOSYS; return -1; } + va_list a; va_start(a, number); + int from = va_arg(a, int); const char *name = va_arg(a, const char *); + int to = va_arg(a, int); const char *dest = va_arg(a, const char *); + unsigned flags = va_arg(a, unsigned); va_end(a); + if (flags != 1U) { fixture_error = true; errno = EINVAL; return -1; } + if (active && to == private_fd && move_error) { fault_hits++; errno = move_error; return -1; } + long result = __real_syscall(number, from, name, to, dest, flags); + if (active && result == 0 && to == private_fd) { + if (restore_collision && write_bytes(old_path, "competing-path") != 0) fixture_error = true; + if (change_after_move) { + int file = __real_openat(to, dest, O_WRONLY | O_APPEND | O_CLOEXEC); + if (file < 0) fixture_error = true; + else { if (write(file, "-changed", 8) != 8) fixture_error = true; __real_close(file); } + } + } + return result; +} +int __real_unlinkat(int, const char *, int); +int __wrap_unlinkat(int fd, const char *name, int flags) { + if (active && fd == private_fd && flags == 0 && fail_delete) { + fault_hits++; errno = EIO; return -1; + } + return __real_unlinkat(fd, name, flags); +} +static int prune(int count) { + active = true; + int result = prune_timestamped_backups(backups, count); + active = false; + return result; +} + +static bool exists(const char *path) { + struct stat info; + return lstat(path, &info) == 0; +} + +static void assert_bytes(const char *path, const char *expected) { + FILE *file = fopen(path, "rb"); + TEST_ASSERT_NOT_NULL_MESSAGE(file, path); + char bytes[128] = {0}; + size_t count = fread(bytes, 1, sizeof(bytes), file); + int close_result = fclose(file); + TEST_ASSERT_EQUAL_INT(0, close_result); + TEST_ASSERT_EQUAL_size_t(strlen(expected), count); + TEST_ASSERT_EQUAL_MEMORY(expected, bytes, count); +} + +/* This tree is created by this process under mkdtemp; never follow symlinks. */ +static int remove_owned_tree(const char *path) { + DIR *dir = opendir(path); + if (!dir) return -1; + int result = 0; + struct dirent *entry; + while ((entry = readdir(dir))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + char child[768]; + int length = snprintf(child, sizeof(child), "%s/%s", path, entry->d_name); + if (length < 0 || (size_t)length >= sizeof(child)) { result = -1; continue; } + struct stat info; + if (lstat(child, &info) != 0) { result = -1; continue; } + if (S_ISDIR(info.st_mode)) { + if (remove_owned_tree(child) != 0) result = -1; + } else if (unlink(child) != 0) result = -1; + } + if (closedir(dir) != 0) result = -1; + if (rmdir(path) != 0) result = -1; + return result; +} + +void setUp(void) { + swap_kind = 0; + swaps = inspected_files = seen_mask = candidate_stats = candidate_opens = fault_hits = pins_at_swap = 0; + owned_count = 0; private_fd = -1; held_directory = NULL; active = false; + fail_open_after = fail_stat_after = -1; move_error = 0; + fail_realloc = fail_strdup = fail_read = fail_delete = restore_collision = change_after_move = false; + repeat_name = repeated_name = repeat_ready = false; + fixture_error = false; + strcpy(root, "/tmp/lightnvr-retention-identity-XXXXXX"); + TEST_ASSERT_NOT_NULL(mkdtemp(root)); + snprintf(backups, sizeof(backups), "%s/backups", root); + snprintf(moved, sizeof(moved), "%s/moved-backups", root); + snprintf(saved_old, sizeof(saved_old), "%s/inspected-old", root); + snprintf(target, sizeof(target), "%s/symlink-target", root); + snprintf(old_path, sizeof(old_path), "%s/20240101T000000Z.sqlite3", backups); + snprintf(newer_path, sizeof(newer_path), "%s/20240102T000000Z.sqlite3", backups); + TEST_ASSERT_EQUAL_INT(0, mkdir(backups, 0700)); + TEST_ASSERT_EQUAL_INT(0, write_bytes(old_path, "inspected-old")); + TEST_ASSERT_EQUAL_INT(0, write_bytes(newer_path, "inspected-newer")); + TEST_ASSERT_EQUAL_INT(0, write_bytes(target, "outside-target")); + TEST_ASSERT_EQUAL_INT(0, lstat(old_path, &old_identity)); + TEST_ASSERT_EQUAL_INT(0, lstat(newer_path, &newer_identity)); +} + +void tearDown(void) { + swap_kind = 0; active = false; + int leaked = owned_count; + while (owned_count) __real_close(owned_fds[--owned_count]); + bool dir_leaked = held_directory != NULL; + if (held_directory) { __real_closedir(held_directory); held_directory = NULL; } + int cleanup = remove_owned_tree(root); + TEST_ASSERT_FALSE(fixture_error); + TEST_ASSERT_EQUAL_INT(0, leaked); + TEST_ASSERT_FALSE(dir_leaked); + TEST_ASSERT_EQUAL_INT(0, cleanup); +} + +void test_retention_keeps_newest_and_excludes_unpublished_artifacts(void) { + char temporary[384], note[384]; + snprintf(temporary, sizeof(temporary), "%s/20230101T000000Z.sqlite3.partial", backups); + snprintf(note, sizeof(note), "%s/operator-note.txt", backups); + TEST_ASSERT_EQUAL_INT(0, write_bytes(temporary, "unfinished")); + TEST_ASSERT_EQUAL_INT(0, write_bytes(note, "note")); + TEST_ASSERT_EQUAL_INT(0, prune(1)); + TEST_ASSERT_FALSE(exists(old_path)); + assert_bytes(newer_path, "inspected-newer"); + assert_bytes(temporary, "unfinished"); + assert_bytes(note, "note"); +} + +void test_retention_within_limit_preserves_all_bytes(void) { + TEST_ASSERT_EQUAL_INT(0, prune(2)); + assert_bytes(old_path, "inspected-old"); + assert_bytes(newer_path, "inspected-newer"); +} + +void test_parent_replacement_cannot_redirect_retention_unlink(void) { + swap_kind = 1; + int result = prune(1); + TEST_ASSERT_FALSE(fixture_error); + TEST_ASSERT_EQUAL_UINT(1, swaps); + assert_bytes(old_path, "replacement-parent-old"); + assert_bytes(newer_path, "replacement-parent-newer"); + char inspected[384]; + snprintf(inspected, sizeof(inspected), "%s/20240101T000000Z.sqlite3", moved); + TEST_ASSERT_FALSE(exists(inspected)); + snprintf(inspected, sizeof(inspected), "%s/20240102T000000Z.sqlite3", moved); + assert_bytes(inspected, "inspected-newer"); + TEST_ASSERT_EQUAL_INT(0, result); +} + +void test_changed_regular_leaf_is_retained_after_inspection(void) { + swap_kind = 2; + int result = prune(1); + TEST_ASSERT_FALSE(fixture_error); + TEST_ASSERT_EQUAL_UINT(1, swaps); + assert_bytes(old_path, "replacement-leaf"); + assert_bytes(saved_old, "inspected-old"); + assert_bytes(newer_path, "inspected-newer"); + TEST_ASSERT_NOT_EQUAL(0, result); +} + +void test_changed_symlink_leaf_and_target_are_retained(void) { + swap_kind = 3; + int result = prune(1); + TEST_ASSERT_FALSE(fixture_error); + TEST_ASSERT_EQUAL_UINT(1, swaps); + struct stat info; + TEST_ASSERT_EQUAL_INT(0, lstat(old_path, &info)); + TEST_ASSERT_TRUE(S_ISLNK(info.st_mode)); + assert_bytes(target, "outside-target"); + assert_bytes(saved_old, "inspected-old"); + TEST_ASSERT_NOT_EQUAL(0, result); +} + +static void assert_both_originals(void) { + assert_bytes(old_path, "inspected-old"); + assert_bytes(newer_path, "inspected-newer"); +} +static void test_collection_emfile_preserves_all_and_closes_pins(void) { + fail_open_after = 1; + TEST_ASSERT_NOT_EQUAL(0, prune(1)); + TEST_ASSERT_EQUAL_UINT(1, fault_hits); + TEST_ASSERT_EQUAL_UINT(1, candidate_opens); + assert_both_originals(); +} +static void test_collection_stat_failure_preserves_all(void) { + fail_stat_after = 1; + TEST_ASSERT_NOT_EQUAL(0, prune(1)); + TEST_ASSERT_EQUAL_UINT(1, fault_hits); + assert_both_originals(); +} +static void test_collection_realloc_failure_preserves_all(void) { + fail_realloc = true; + TEST_ASSERT_NOT_EQUAL(0, prune(1)); + TEST_ASSERT_EQUAL_UINT(1, fault_hits); + assert_both_originals(); +} +static void test_collection_strdup_failure_preserves_all(void) { + fail_strdup = true; + TEST_ASSERT_NOT_EQUAL(0, prune(1)); + TEST_ASSERT_EQUAL_UINT(1, fault_hits); + assert_both_originals(); +} +static void test_readdir_error_does_not_turn_partial_collection_into_retention(void) { + fail_read = true; + TEST_ASSERT_NOT_EQUAL(0, prune(0)); + TEST_ASSERT_EQUAL_UINT(1, fault_hits); + assert_both_originals(); +} +static void test_unsupported_move_preserves_published_backup(void) { + move_error = ENOSYS; + TEST_ASSERT_NOT_EQUAL(0, prune(1)); + TEST_ASSERT_EQUAL_UINT(1, fault_hits); + assert_both_originals(); +} +static void test_unlink_failure_restores_without_overwrite(void) { + fail_delete = true; + TEST_ASSERT_NOT_EQUAL(0, prune(1)); + TEST_ASSERT_EQUAL_UINT(1, fault_hits); + assert_both_originals(); +} +static void test_restore_collision_retains_quarantined_original(void) { + fail_delete = true; restore_collision = true; + TEST_ASSERT_NOT_EQUAL(0, prune(1)); + TEST_ASSERT_EQUAL_UINT(1, fault_hits); + assert_bytes(old_path, "competing-path"); + assert_bytes(newer_path, "inspected-newer"); + DIR *d = opendir(backups); TEST_ASSERT_NOT_NULL(d); + char retained[768] = {0}; unsigned found = 0; struct dirent *e; + while ((e = readdir(d))) if (!strncmp(e->d_name, ".cleanup-", 9)) { + snprintf(retained, sizeof(retained), "%s/%s/20240101T000000Z.sqlite3", backups, e->d_name); found++; + } + TEST_ASSERT_EQUAL_INT(0, closedir(d)); + TEST_ASSERT_EQUAL_UINT(1, found); + assert_bytes(retained, "inspected-old"); +} +static void test_changed_after_move_is_restored_and_pass_stops(void) { + change_after_move = true; + TEST_ASSERT_NOT_EQUAL(0, prune(0)); + assert_bytes(old_path, "inspected-old-changed"); + assert_bytes(newer_path, "inspected-newer"); +} +static void test_both_selection_inodes_remain_pinned_during_replacement(void) { + swap_kind = 2; + TEST_ASSERT_NOT_EQUAL(0, prune(1)); + TEST_ASSERT_EQUAL_UINT(1, swaps); + TEST_ASSERT_EQUAL_UINT(2, pins_at_swap); + assert_bytes(old_path, "replacement-leaf"); + assert_bytes(saved_old, "inspected-old"); +} +static void test_more_than_sixty_four_backups_preserve_configured_order(void) { + for (unsigned i = 0; i < 70; ++i) { + char path[384]; snprintf(path, sizeof(path), "%s/20230101T%06uZ.sqlite3", backups, i); + TEST_ASSERT_EQUAL_INT(0, write_bytes(path, "older")); + } + TEST_ASSERT_EQUAL_INT(0, prune(2)); + assert_both_originals(); + for (unsigned i = 0; i < 70; ++i) { + char path[384]; snprintf(path, sizeof(path), "%s/20230101T%06uZ.sqlite3", backups, i); + TEST_ASSERT_FALSE(exists(path)); + } +} +static void test_published_suffix_symlink_and_fifo_are_not_counted_or_removed(void) { + char link[384], fifo[384]; + snprintf(link, sizeof(link), "%s/19000101T000000Z.sqlite3", backups); + snprintf(fifo, sizeof(fifo), "%s/19000102T000000Z.sqlite3", backups); + TEST_ASSERT_EQUAL_INT(0, symlink(target, link)); + TEST_ASSERT_EQUAL_INT(0, mkfifo(fifo, 0600)); + TEST_ASSERT_EQUAL_INT(0, prune(2)); + assert_both_originals(); + struct stat st; + TEST_ASSERT_EQUAL_INT(0, lstat(link, &st)); TEST_ASSERT_TRUE(S_ISLNK(st.st_mode)); + TEST_ASSERT_EQUAL_INT(0, lstat(fifo, &st)); TEST_ASSERT_TRUE(S_ISFIFO(st.st_mode)); + assert_bytes(target, "outside-target"); +} + +static void test_repeated_selected_name_aborts_before_any_deletion(void) { + repeat_name = true; + int result = prune(1); + TEST_ASSERT_TRUE(repeated_name); + assert_both_originals(); + TEST_ASSERT_NOT_EQUAL(0, result); +} +static void test_distinct_hardlinked_names_keep_filename_count_semantics(void) { + char additional[384]; + snprintf(additional, sizeof(additional), "%s/20240103T000000Z.sqlite3", backups); + TEST_ASSERT_EQUAL_INT(0, link(newer_path, additional)); + TEST_ASSERT_EQUAL_INT(0, prune(1)); + TEST_ASSERT_FALSE(exists(old_path)); + TEST_ASSERT_FALSE(exists(newer_path)); + assert_bytes(additional, "inspected-newer"); +} + +int main(void) { + init_logger(); + UNITY_BEGIN(); + RUN_TEST(test_retention_keeps_newest_and_excludes_unpublished_artifacts); + RUN_TEST(test_retention_within_limit_preserves_all_bytes); + RUN_TEST(test_parent_replacement_cannot_redirect_retention_unlink); + RUN_TEST(test_changed_regular_leaf_is_retained_after_inspection); + RUN_TEST(test_changed_symlink_leaf_and_target_are_retained); + RUN_TEST(test_collection_emfile_preserves_all_and_closes_pins); + RUN_TEST(test_collection_stat_failure_preserves_all); + RUN_TEST(test_collection_realloc_failure_preserves_all); + RUN_TEST(test_collection_strdup_failure_preserves_all); + RUN_TEST(test_readdir_error_does_not_turn_partial_collection_into_retention); + RUN_TEST(test_unsupported_move_preserves_published_backup); + RUN_TEST(test_unlink_failure_restores_without_overwrite); + RUN_TEST(test_restore_collision_retains_quarantined_original); + RUN_TEST(test_changed_after_move_is_restored_and_pass_stops); + RUN_TEST(test_both_selection_inodes_remain_pinned_during_replacement); + RUN_TEST(test_more_than_sixty_four_backups_preserve_configured_order); + RUN_TEST(test_published_suffix_symlink_and_fifo_are_not_counted_or_removed); + RUN_TEST(test_repeated_selected_name_aborts_before_any_deletion); + RUN_TEST(test_distinct_hardlinked_names_keep_filename_count_semantics); + int result = UNITY_END(); + shutdown_logger(); + return result; +} diff --git a/web/tests/e2e/bulkTagsReconciliation.spec.js b/web/tests/e2e/bulkTagsReconciliation.browser.spec.js similarity index 99% rename from web/tests/e2e/bulkTagsReconciliation.spec.js rename to web/tests/e2e/bulkTagsReconciliation.browser.spec.js index 0440b8b46..94b030fd4 100644 --- a/web/tests/e2e/bulkTagsReconciliation.spec.js +++ b/web/tests/e2e/bulkTagsReconciliation.browser.spec.js @@ -1,5 +1,5 @@ /** Real component effects with delayed API and parent recording snapshots. */ -// Run with: npm --prefix web run test:e2e -- --testPathPatterns=bulkTagsReconciliation +// Run with: npm --prefix web run test:e2e -- --testPathPatterns='\.browser\.spec\.js$' const browserTest = it; async function openBrowser({ delayedCatalog = false diff --git a/web/tests/e2e/zoneEditorCheckbox.spec.js b/web/tests/e2e/zoneEditorCheckbox.browser.spec.js similarity index 99% rename from web/tests/e2e/zoneEditorCheckbox.spec.js rename to web/tests/e2e/zoneEditorCheckbox.browser.spec.js index 26104885f..ebfb8d134 100644 --- a/web/tests/e2e/zoneEditorCheckbox.spec.js +++ b/web/tests/e2e/zoneEditorCheckbox.browser.spec.js @@ -17,7 +17,7 @@ const fixtureZones = [{ }] }]; const t = key => 'translated:' + key; -// Run with: npm --prefix web run test:e2e -- --testPathPatterns=zoneEditorCheckbox +// Run with: npm --prefix web run test:e2e -- --testPathPatterns='\.browser\.spec\.js$' const browserTest = it; async function openZoneBrowser() { const {