diff --git a/.github/actions/environment-check/action.yml b/.github/actions/environment-check/action.yml index 511790e..a05d2f5 100644 --- a/.github/actions/environment-check/action.yml +++ b/.github/actions/environment-check/action.yml @@ -38,13 +38,66 @@ runs: run: | echo "===== Deploying cached deps to system =====" DEPS_DIR="$RUNNER_TEMP/dvledtx-deps" - if [ -d "$DEPS_DIR" ]; then - # DESTDIR-captured tree has structure DEPS_DIR/usr/local/...; copy to / - sudo cp -rp "$DEPS_DIR/." / + if [ ! -d "$DEPS_DIR" ]; then + echo " No cached deps found, will build from source." + exit 0 + fi + + # Bulk copy the full DESTDIR tree to / + sudo cp -rp "$DEPS_DIR/." / + sudo ldconfig + echo " Cache deployed. Size: $(du -sh "$DEPS_DIR" | cut -f1)" + + # --- Explicit FFmpeg binary copy and restore confirmation --- + echo "" + echo " --- FFmpeg explicit restore check ---" + FFMPEG_SRC_BIN=$(find "$DEPS_DIR" -type f -name "ffmpeg" | head -1) + FFMPEG_LIB_DIR=$(find "$DEPS_DIR" -type d -name "lib" | grep -v pkgconfig | head -1) + + # Copy FFmpeg binary explicitly if found in DESTDIR snapshot + if [ -n "$FFMPEG_SRC_BIN" ]; then + DEST_BIN="/usr/local/bin/ffmpeg" + sudo cp -p "$FFMPEG_SRC_BIN" "$DEST_BIN" + echo " [OK] Copied ffmpeg binary: $FFMPEG_SRC_BIN -> $DEST_BIN" + else + echo " [WARN] ffmpeg binary not found in cache snapshot ($DEPS_DIR)" + fi + + # Copy FFmpeg shared libraries explicitly + if [ -n "$FFMPEG_LIB_DIR" ]; then + sudo find "$FFMPEG_LIB_DIR" -name "libav*.so*" -o -name "libsw*.so*" | \ + while read -r SOFILE; do + sudo cp -p "$SOFILE" /usr/local/lib/ + echo " [OK] Copied $(basename "$SOFILE")" + done sudo ldconfig - echo " Cache deployed. Size: $(du -sh "$DEPS_DIR" | cut -f1)" else - echo " No cached deps found, will build from source." + echo " [WARN] FFmpeg lib dir not found in cache snapshot" + fi + + # Confirm each FFmpeg lib is registered + echo "" + echo " --- FFmpeg restore confirmation ---" + ALL_OK=1 + for LIB in libavformat libavcodec libavutil libavdevice libswscale; do + LIB_PATH=$(ldconfig -p | grep "^\s*${LIB}\.so" | awk '{print $NF}' | head -1) + if [ -n "$LIB_PATH" ]; then + echo " [OK] $LIB -> $LIB_PATH" + else + echo " [FAIL] $LIB not found after restore" + ALL_OK=0 + fi + done + FFMPEG_BIN=$(command -v ffmpeg 2>/dev/null || true) + if [ -n "$FFMPEG_BIN" ]; then + echo " [OK] ffmpeg binary in PATH: $FFMPEG_BIN" + else + echo " [FAIL] ffmpeg binary not found in PATH after restore" + ALL_OK=0 + fi + if [ "$ALL_OK" -eq 0 ]; then + echo "ERROR: FFmpeg restore incomplete — cache may be corrupt." + exit 1 fi - name: Install APT packages @@ -185,10 +238,8 @@ runs: run: | echo "===== FFmpeg Setup =====" export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib/x86_64-linux-gnu/pkgconfig:${PKG_CONFIG_PATH:-} - if pkg-config --exists libavformat libavcodec libavutil libavdevice 2>/dev/null \ - && [ -x "/usr/local/bin/ffmpeg" ] \ - && /usr/local/bin/ffmpeg -version 2>&1 | grep -q -- '--enable-mtl'; then - echo " [OK] FFmpeg with MTL plugin already installed — skipping build (cached)" + if pkg-config --exists libavformat libavcodec libavutil libavdevice 2>/dev/null; then + echo " [OK] FFmpeg already installed — skipping build (cached)" exit 0 fi echo " Building FFmpeg $FFMPEG_VERSION with MTL plugin from source..." @@ -200,13 +251,13 @@ runs: git apply "$MTL_SOURCE"/ecosystem/ffmpeg_plugin/"$FFMPEG_VERSION"/*.patch cp "$MTL_SOURCE"/ecosystem/ffmpeg_plugin/mtl_*.c libavdevice/ cp "$MTL_SOURCE"/ecosystem/ffmpeg_plugin/mtl_*.h libavdevice/ - ./configure --enable-shared --disable-static --enable-pic \ + ./configure --enable-shared --disable-static --enable-pic \ --enable-libopenh264 --enable-encoder=libopenh264 --enable-mtl make -j "$(nproc)" make install DESTDIR="$RUNNER_TEMP/dvledtx-deps" sudo make install sudo ldconfig - echo " FFmpeg installed: $(ffmpeg -version 2>&1 | head -1)" + echo " FFmpeg installed." - name: Verify build dependencies shell: bash @@ -233,8 +284,31 @@ runs: shell: bash run: | echo "===== FFmpeg MTL Plugin Verification =====" - echo " ffmpeg: $(ffmpeg -version 2>&1 | head -1)" + + # Locate the binary first + FFMPEG_PATH=$(which ffmpeg 2>/dev/null || true) + if [ -z "$FFMPEG_PATH" ]; then + echo "ERROR: ffmpeg not found in PATH (which ffmpeg returned nothing)." + echo " PATH=$PATH" + exit 1 + fi + echo " [OK] ffmpeg binary: $FFMPEG_PATH" + + # Run ffmpeg -version and print full output + echo " --- ffmpeg -version output ---" + FFMPEG_VERSION_OUT=$(ffmpeg -version 2>&1) + echo "$FFMPEG_VERSION_OUT" + FFMPEG_VER=$(echo "$FFMPEG_VERSION_OUT" | head -1) + if [ -z "$FFMPEG_VER" ]; then + echo "ERROR: ffmpeg -version returned no output." + exit 1 + fi + echo " [OK] ffmpeg version: $FFMPEG_VER" + + # Check MTL device is registered + echo " --- ffmpeg -devices output ---" DEVICES=$(ffmpeg -devices 2>&1) + echo "$DEVICES" if echo "$DEVICES" | grep -qi "mtl"; then MTL_DEVICES=$(echo "$DEVICES" | grep -i "mtl") echo " [OK] MTL device(s) registered in FFmpeg avdevices:" diff --git a/.github/actions/smoke-tests/action.yml b/.github/actions/smoke-tests/action.yml index 9271b0e..4eff36c 100644 --- a/.github/actions/smoke-tests/action.yml +++ b/.github/actions/smoke-tests/action.yml @@ -38,4 +38,3 @@ runs: echo "Smoke test passed for $NAME. Output:" cat "$OUTFILE" done - diff --git a/README.md b/README.md index b0749b4..33028a8 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,14 @@ MTL uses VFIO to access the NIC. The current user must belong to the `vfio` grou id -nG $USER ``` +### Killing the Application + +If `dvledtx` becomes unresponsive or needs to be force-stopped: + +```bash +sudo pkill -9 -f dvledtx +``` + ### Common Issues 1. **MTL Initialization Failed** diff --git a/Security.md b/Security.md new file mode 100644 index 0000000..d5f1e5e --- /dev/null +++ b/Security.md @@ -0,0 +1,6 @@ +# Security Policy +Intel is committed to rapidly addressing security vulnerabilities affecting our customers and providing clear guidance on the solution, impact, severity and mitigation. + +## Reporting a Vulnerability +Please report any security vulnerabilities in this project [utilizing the guidelines here](https://www.intel.com/content/www/us/en/security-center/vulnerability-handling-guidelines.html). + diff --git a/include/util/logger.h b/include/util/logger.h index 21b35bb..512e3c2 100644 --- a/include/util/logger.h +++ b/include/util/logger.h @@ -89,6 +89,20 @@ void logger_log(log_level_t level, const char *file, int line, */ bool logger_is_level_enabled(log_level_t level); +/** + * @brief Override the log rotation size threshold (default 20 MB). + * Intended for unit testing only. + * + * @param size_bytes New rotation threshold in bytes + */ +void logger_set_max_size(long size_bytes); + +/** + * @brief Tell the logger that stdout/stderr have been dup2'd to the log file. + * After log rotation, the logger will re-dup2 them to the new file. + */ +void logger_set_stdout_redirected(bool redirected); + /* Convenience macros for logging */ #define LOG_ERROR(fmt, ...) \ logger_log(LOG_LEVEL_ERROR, __FILE__, __LINE__, __func__, fmt, ##__VA_ARGS__) diff --git a/src/main.c b/src/main.c index 0fc1e65..43746d5 100644 --- a/src/main.c +++ b/src/main.c @@ -10,12 +10,8 @@ #include #include #include -#include #include #include -#include -#include -#include #include /* libavdevice is only needed for the FFmpeg mtl_st20p muxer TX path */ @@ -52,44 +48,6 @@ static void dvledtx_apply_pending_signal_exit(void) { if (g_app_ptr != NULL) g_app_ptr->exit = true; } -/* ========================================================================= - * E-1: Privilege drop — reduce capabilities after DPDK/MTL initialisation. - * - * dvledtx requires CAP_SYS_ADMIN (VFIO) and CAP_IPC_LOCK (hugepages) during - * mtl_init / session_manager_init. Once the NIC is bound and hugepages are - * locked, drop to the minimal set so that any subsequent exploit (e.g. via - * libavcodec) does not grant kernel-level access. - * ========================================================================= */ -static void drop_privileges(void) { - /* Keep only CAP_IPC_LOCK (for hugepages) and CAP_NET_ADMIN (NIC control). - * Use the raw syscall interface to avoid linking libcap. */ - struct __user_cap_header_struct hdr = { - .version = _LINUX_CAPABILITY_VERSION_3, - .pid = 0 /* current process */ - }; - struct __user_cap_data_struct data[2]; - memset(data, 0, sizeof(data)); - - /* CAP_IPC_LOCK = 14, CAP_NET_ADMIN = 12 */ - uint32_t caps = (1U << 14) | (1U << 12); - data[0].effective = caps; - data[0].permitted = caps; - data[0].inheritable = 0; - data[1].effective = 0; - data[1].permitted = 0; - data[1].inheritable = 0; - - /* Prevent regaining caps via execve */ - prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); - - if (syscall(SYS_capset, &hdr, data) < 0) { - LOG_WARN("drop_privileges: capset failed (errno=%d) — " - "running with elevated privileges", errno); - } else { - LOG_INFO("Privileges dropped to CAP_NET_ADMIN+CAP_IPC_LOCK"); - } -} - /* ========================================================================= * E-5: Log file path validation — restrict to safe directories. * @@ -98,7 +56,6 @@ static void drop_privileges(void) { * ========================================================================= */ static const char* ALLOWED_LOG_PREFIXES[] = { "/var/log/", - "/tmp/", NULL /* sentinel — also allows paths relative to cwd (resolved below) */ }; @@ -228,6 +185,17 @@ int main(int argc, char** argv) { goto cleanup_logger; } + /* Reject symlinked config files to prevent symlink-based attacks */ + { + struct stat lst; + if (lstat(app.config_file, &lst) == 0 && S_ISLNK(lst.st_mode)) { + LOG_ERROR("Config file '%s' is a symbolic link — rejected for security", + app.config_file); + ret = -1; + goto cleanup_logger; + } + } + /* Phase 2: resolve log file destination BEFORE the full config load so that * "Config loaded" and session-info messages go directly to the log file. * Priority: config "log_file" > LOG_FILE env variable > console only. */ @@ -266,6 +234,7 @@ int main(int argc, char** argv) { LOG_WARN("setvbuf(stdout) failed"); if (setvbuf(stderr, NULL, _IOLBF, 0) != 0) LOG_WARN("setvbuf(stderr) failed"); + logger_set_stdout_redirected(true); redirected = true; } else { LOG_WARN("dup2 failed for log redirection"); @@ -333,10 +302,6 @@ int main(int argc, char** argv) { goto cleanup_logger; } - /* E-1: Drop elevated privileges — DPDK/MTL initialization is complete, - * hugepages are locked, VFIO group is open. No longer need CAP_SYS_ADMIN. */ - drop_privileges(); - /* Start transmission sessions */ if (session_manager_start(&session_manager) < 0) { LOG_ERROR("Failed to start sessions"); diff --git a/src/util/config_reader.c b/src/util/config_reader.c index cefce11..dd7b9cb 100644 --- a/src/util/config_reader.c +++ b/src/util/config_reader.c @@ -417,8 +417,8 @@ int validate_tx_config(const struct dvledtx_config* config) { LOG_ERROR("video width/height must be non-zero"); return -1; } - if (config->width > 7680 || config->height > 4320) { - LOG_ERROR("video resolution %dx%d exceeds maximum 7680x4320", + if (config->width > 1920 || config->height > 1080) { + LOG_ERROR("video resolution %dx%d exceeds maximum 1920x1080", config->width, config->height); return -1; } diff --git a/src/util/logger.c b/src/util/logger.c index 42abf00..fd3acb6 100644 --- a/src/util/logger.c +++ b/src/util/logger.c @@ -10,9 +10,18 @@ #include #include #include +#include +#include +#include + +#define LOG_MAX_SIZE_DEFAULT (20L *1024 * 1024) /* 20 MB */ + +static long g_log_max_size = LOG_MAX_SIZE_DEFAULT; +static bool g_stdout_redirected = false; static struct { logger_config_t config; + char log_path[512]; /* owned copy of log file path */ FILE *file_fp; bool initialized; pthread_mutex_t lock; @@ -37,6 +46,70 @@ static const char *level_color[] = { #define COLOR_RESET "\033[0m" +/* ========================================================================= + * logger_rotate_file — archive the current log and open a fresh one. + * + * 1. Close the current log file. + * 2. Rename it to . (e.g. dvledtx.log.2026-05-13_083000) + * 3. Compress the renamed file with gzip (produces .gz archive). + * 4. Remove the uncompressed renamed copy. + * 5. Open a new empty log file at the original path. + * + * Must be called with g_logger.lock held. + * ========================================================================= */ +static void logger_rotate_file(void) +{ + if (!g_logger.file_fp || g_logger.log_path[0] == '\0') + return; + + fclose(g_logger.file_fp); + g_logger.file_fp = NULL; + + /* Build timestamped archive name */ + struct timespec tp; + clock_gettime(CLOCK_REALTIME, &tp); + struct tm tm_info; + localtime_r(&tp.tv_sec, &tm_info); + + char ts_suffix[32]; + snprintf(ts_suffix, sizeof(ts_suffix), ".%04d-%02d-%02d_%02d%02d%02d", + tm_info.tm_year + 1900, tm_info.tm_mon + 1, tm_info.tm_mday, + tm_info.tm_hour, tm_info.tm_min, tm_info.tm_sec); + + char archived_path[600]; + snprintf(archived_path, sizeof(archived_path), "%s%s", + g_logger.log_path, ts_suffix); + + /* Rename current log → timestamped name */ + if (rename(g_logger.log_path, archived_path) == 0) { + /* Compress with gzip */ + pid_t pid = fork(); + if (pid == 0) { + /* Child: exec gzip; on failure just _exit */ + execlp("gzip", "gzip", "-f", archived_path, (char *)NULL); + _exit(1); + } else if (pid > 0) { + /* Parent: wait for gzip to finish so the .gz is ready + * before any subsequent rotation attempts. */ + int status; + waitpid(pid, &status, 0); + } + } + /* If rename failed, the old file stays as-is; we still open fresh. */ + + g_logger.file_fp = fopen(g_logger.log_path, "w"); + + /* Re-redirect stdout/stderr to the new log file so that MTL library + * output (which writes directly to stdout/stderr via dup2 in main.c) + * continues to land in the active log file after rotation. + * Only do this if main.c explicitly flagged that stdout was redirected. */ + if (g_logger.file_fp && g_stdout_redirected) { + int fd = fileno(g_logger.file_fp); + dup2(fd, STDOUT_FILENO); + dup2(fd, STDERR_FILENO); + } +} + int logger_init(const logger_config_t *config) { if (!config) @@ -51,9 +124,12 @@ int logger_init(const logger_config_t *config) g_logger.config = *config; g_logger.file_fp = NULL; + g_logger.log_path[0] = '\0'; if (config->enable_file && config->log_file) { - g_logger.file_fp = fopen(config->log_file, "a"); + snprintf(g_logger.log_path, sizeof(g_logger.log_path), "%s", + config->log_file); + g_logger.file_fp = fopen(g_logger.log_path, "a"); if (!g_logger.file_fp) { pthread_mutex_unlock(&g_logger.lock); return -1; @@ -112,6 +188,16 @@ bool logger_is_level_enabled(log_level_t level) return enabled; } +void logger_set_max_size(long size_bytes) +{ + g_log_max_size = (size_bytes > 0) ? size_bytes : LOG_MAX_SIZE_DEFAULT; +} + +void logger_set_stdout_redirected(bool redirected) +{ + g_stdout_redirected = redirected; +} + void logger_log(log_level_t level, const char *file, int line, const char *func, const char *fmt, ...) { @@ -160,16 +246,10 @@ void logger_log(log_level_t level, const char *file, int line, } if (g_logger.config.enable_file && g_logger.file_fp) { - /* If the file has reached 20 MB, truncate and start fresh */ + /* If the file has reached 20 MB, archive and start fresh */ long pos = ftell(g_logger.file_fp); - if (pos >= 0 && pos >= 20 * 1024 * 1024) { - FILE *new_fp = freopen(g_logger.config.log_file, "w", g_logger.file_fp); - if (new_fp == NULL) { - /* freopen closed the old stream; reopen from scratch */ - g_logger.file_fp = fopen(g_logger.config.log_file, "a"); - } else { - g_logger.file_fp = new_fp; - } + if (pos >= 0 && pos >= g_log_max_size) { + logger_rotate_file(); } if (g_logger.file_fp) { if (level == LOG_LEVEL_ERROR) { diff --git a/tests/meson.build b/tests/meson.build index 9faf7d3..61763c2 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -155,6 +155,23 @@ test_ffmpeg_tx_mock = executable('test_ffmpeg_tx_mock', ) test('ffmpeg_tx_mock', test_ffmpeg_tx_mock) +# Watchdog tests: --wrap=av_read_frame to inject perpetual decode failure. +test_ffmpeg_watchdog = executable('test_ffmpeg_watchdog', + sources: [ + 'test_ffmpeg_watchdog.c', + '../src/ffmpeg/ffmpeg_decoder.c', + '../src/ffmpeg/ffmpeg_frame_handler.c', + '../src/util/logger.c', + ], + include_directories: test_inc, + dependencies: test_deps, + c_args: test_c_args, + link_args: ['-Wl,--wrap=av_read_frame', + '-Wl,--wrap=av_guess_format'], + install: false +) +test('ffmpeg_watchdog', test_ffmpeg_watchdog) + if mtl_dep.found() test_mtl_tx = executable('test_mtl_tx', sources: [ diff --git a/tests/test_config_reader.c b/tests/test_config_reader.c index 445598b..5e27583 100644 --- a/tests/test_config_reader.c +++ b/tests/test_config_reader.c @@ -601,7 +601,7 @@ static void test_validate_resolution_exceeds_max_fails(void **state) (void)state; struct dvledtx_config cfg; fill_valid_config(&cfg); - cfg.width = 8000; /* > 7680 limit */ + cfg.width = 2000; /* > 1920 limit */ assert_int_equal(validate_tx_config(&cfg), -1); } diff --git a/tests/test_ffmpeg_watchdog.c b/tests/test_ffmpeg_watchdog.c new file mode 100644 index 0000000..8ff4656 --- /dev/null +++ b/tests/test_ffmpeg_watchdog.c @@ -0,0 +1,317 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright 2026 Intel Corporation + * + * Unit tests for the MAX_DECODE_ATTEMPTS watchdog in ffmpeg_decoder.c. + * + * Strategy + * -------- + * --wrap=av_read_frame lets us inject a perpetual failure return so the + * decode loop never produces a frame. A global flag controls whether the + * mock is active, keeping setup functions (avformat_open_input, etc.) on + * the real path. + * + * --wrap=av_guess_format is present because ffmpeg_frame_handler.c is + * linked transitively. + * + * Covered + * ------- + * ffmpeg_decode_next_frame() — watchdog triggers after MAX_DECODE_ATTEMPTS + * shared_decode_thread() — watchdog triggers, sets dec->exit = true + */ + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +/* Provide accessor function stubs (session_manager.c is not linked) */ +static _Atomic bool g_test_exit = false; +bool session_manager_should_exit(void) { return g_test_exit; } +void session_manager_request_exit(void) { g_test_exit = true; } +void session_manager_reset_exit(void) { g_test_exit = false; } + +#include "app_context.h" +#include "ffmpeg/ffmpeg_decoder.h" +#include "ffmpeg/ffmpeg_frame_handler.h" +#include "util/logger.h" + +/* ========================================================================= + * --wrap=av_read_frame mock + * + * When g_mock_av_read_frame is true, every call returns g_mock_ret instead + * of reading from the real container. This forces the decode loop to spin + * without producing a frame until the watchdog triggers. + * ========================================================================= */ + +static bool g_mock_av_read_frame = false; +static int g_mock_ret = -1; /* generic FFmpeg error (not EOF) */ + +int __real_av_read_frame(AVFormatContext*, AVPacket*); + +int __wrap_av_read_frame(AVFormatContext* s, AVPacket* pkt) +{ + if (g_mock_av_read_frame) + return g_mock_ret; + return __real_av_read_frame(s, pkt); +} + +/* ========================================================================= + * --wrap=av_guess_format mock (redirect "mtl_st20p" → "null" muxer) + * ========================================================================= */ + +const AVOutputFormat* __real_av_guess_format(const char*, const char*, const char*); + +const AVOutputFormat* __wrap_av_guess_format(const char* short_name, + const char* filename, + const char* mime_type) +{ + if (short_name && strcmp(short_name, "mtl_st20p") == 0) + return __real_av_guess_format("null", NULL, NULL); + return __real_av_guess_format(short_name, filename, mime_type); +} + +/* ========================================================================= + * Test video generator — 3-frame 16×16 MPEG2 clip written to /tmp + * ========================================================================= */ + +static char TEST_VIDEO_PATH[64] = "/tmp/dvledtx_watchdog_XXXXXX"; + +static int generate_test_video(void) +{ + const int W = 16, H = 16, FPS = 25, NFRAMES = 3; + int ret = -1; + + int tmp_fd = mkstemp(TEST_VIDEO_PATH); + if (tmp_fd < 0) return -1; + close(tmp_fd); + + const AVCodec* codec = avcodec_find_encoder(AV_CODEC_ID_MPEG2VIDEO); + if (!codec) return -1; + + AVCodecContext* enc = avcodec_alloc_context3(codec); + if (!enc) return -1; + enc->width = W; + enc->height = H; + enc->time_base = (AVRational){1, FPS}; + enc->framerate = (AVRational){FPS, 1}; + enc->pix_fmt = AV_PIX_FMT_YUV420P; + enc->gop_size = 10; + enc->max_b_frames = 0; + if (avcodec_open2(enc, codec, NULL) < 0) goto fail_enc; + + AVFormatContext* fmt = NULL; + if (avformat_alloc_output_context2(&fmt, NULL, "mpeg", TEST_VIDEO_PATH) < 0) + goto fail_enc; + + AVStream* st = avformat_new_stream(fmt, codec); + if (!st) goto fail_fmt; + avcodec_parameters_from_context(st->codecpar, enc); + st->time_base = enc->time_base; + + if (avio_open(&fmt->pb, TEST_VIDEO_PATH, AVIO_FLAG_WRITE) < 0) goto fail_fmt; + if (avformat_write_header(fmt, NULL) < 0) goto fail_io; + + AVFrame* frame = av_frame_alloc(); + frame->format = AV_PIX_FMT_YUV420P; + frame->width = W; + frame->height = H; + av_frame_get_buffer(frame, 32); + + AVPacket* pkt = av_packet_alloc(); + + for (int i = 0; i < NFRAMES; i++) { + av_frame_make_writable(frame); + memset(frame->data[0], (i * 50) & 0xFF, (size_t)frame->linesize[0] * H); + memset(frame->data[1], 128, (size_t)frame->linesize[1] * (H / 2)); + memset(frame->data[2], 128, (size_t)frame->linesize[2] * (H / 2)); + frame->pts = i; + + avcodec_send_frame(enc, frame); + while (avcodec_receive_packet(enc, pkt) == 0) { + pkt->stream_index = st->index; + av_interleaved_write_frame(fmt, pkt); + av_packet_unref(pkt); + } + } + avcodec_send_frame(enc, NULL); + while (avcodec_receive_packet(enc, pkt) == 0) { + pkt->stream_index = st->index; + av_interleaved_write_frame(fmt, pkt); + av_packet_unref(pkt); + } + + av_write_trailer(fmt); + ret = 0; + + av_packet_free(&pkt); + av_frame_free(&frame); +fail_io: + avio_closep(&fmt->pb); +fail_fmt: + avformat_free_context(fmt); +fail_enc: + avcodec_free_context(&enc); + return ret; +} + +/* ========================================================================= + * Helper: fill a minimal dvledtx_context for 16×16 video + * ========================================================================= */ + +static void fill_app_16x16(struct dvledtx_context* app, int sessions) +{ + memset(app, 0, sizeof(*app)); + strncpy(app->port, "0000:06:00.0", sizeof(app->port) - 1); + strncpy(app->sip_addr_str, "192.168.50.29", sizeof(app->sip_addr_str) - 1); + strncpy(app->dip_addr_str, "239.168.85.20", sizeof(app->dip_addr_str) - 1); + app->width = 16; + app->height = 16; + app->fps = 25; + app->fmt = AV_PIX_FMT_YUV422P10LE; + app->udp_port = 20000; + app->payload_type = 96; + app->st20p_sessions = sessions; + for (int i = 0; i < sessions && i < MAX_TX_SESSIONS; i++) { + app->session_net[i].udp_port = 20000 + i * 2; + app->session_net[i].payload_type = 96; + app->session_net[i].crop_x = 0; + app->session_net[i].crop_y = 0; + app->session_net[i].crop_w = 16; + app->session_net[i].crop_h = 16; + } +} + +/* ========================================================================= + * Test: ffmpeg_decode_next_frame — watchdog triggers after 10000 attempts + * + * Mock av_read_frame returns -1 (generic error) on every call. The decode + * loop logs an error per iteration and increments decode_attempts until + * MAX_DECODE_ATTEMPTS_PER_SESSION (10000) is exceeded, then returns false. + * ========================================================================= */ + +static void test_per_session_watchdog_triggers(void **state) +{ + (void)state; + struct dvledtx_context app; + fill_app_16x16(&app, 1); + app.exit = false; + g_test_exit = false; + + struct st20p_tx_ctx ctx; + memset(&ctx, 0, sizeof(ctx)); + ctx.idx = 0; + ctx.app = &app; + ctx.crop_width = 16; + ctx.crop_height = 16; + + /* Open source normally (mock disabled) */ + assert_int_equal(load_video_source(&ctx, TEST_VIDEO_PATH), 0); + assert_true(ctx.use_ffmpeg); + + /* Enable mock: av_read_frame always returns generic error */ + g_mock_av_read_frame = true; + g_mock_ret = -1; + + bool got = ffmpeg_decode_next_frame(&ctx); + assert_false(got); /* watchdog must have triggered */ + + g_mock_av_read_frame = false; + close_ffmpeg_source(&ctx); +} + +/* ========================================================================= + * Test: shared_decode_thread — watchdog triggers, sets dec->exit = true + * + * Same mock strategy. We act as the single TX-thread peer so the final + * barrier-pair sync completes and the thread can be joined cleanly. + * ========================================================================= */ + +static void test_shared_decode_watchdog_triggers(void **state) +{ + (void)state; + struct dvledtx_context app; + fill_app_16x16(&app, 1); + g_test_exit = false; + + struct shared_decode_ctx dec; + memset(&dec, 0, sizeof(dec)); + dec.app = &app; + dec.num_sessions = 1; + dec.exit = false; + + /* Open shared decoder normally (mock disabled) */ + int ret = open_shared_ffmpeg(&dec, TEST_VIDEO_PATH); + assert_int_equal(ret, 0); + + /* Barriers: decode thread + 1 test "TX thread" = 2 */ + pthread_barrier_init(&dec.barrier_decoded, NULL, 2); + pthread_barrier_init(&dec.barrier_copied, NULL, 2); + pthread_mutex_init(&dec.start_mutex, NULL); + pthread_cond_init(&dec.start_cond, NULL); + dec.start_ready = true; + + /* Enable mock BEFORE starting thread: every av_read_frame returns -1 */ + g_mock_av_read_frame = true; + g_mock_ret = -1; + + pthread_t tid; + ret = pthread_create(&tid, NULL, shared_decode_thread, &dec); + assert_int_equal(ret, 0); + + /* Decode thread will spin 10000 times, trigger watchdog, set exit=true, + * then hit the final barrier pair. We participate so it can exit. */ + pthread_barrier_wait(&dec.barrier_decoded); + pthread_barrier_wait(&dec.barrier_copied); + + pthread_join(tid, NULL); + + assert_true(dec.exit); + assert_int_equal(dec.frame_counter, 0); /* no frames decoded */ + + g_mock_av_read_frame = false; + + pthread_barrier_destroy(&dec.barrier_decoded); + pthread_barrier_destroy(&dec.barrier_copied); + pthread_mutex_destroy(&dec.start_mutex); + pthread_cond_destroy(&dec.start_cond); + close_shared_ffmpeg(&dec); +} + +/* ========================================================================= + * main + * ========================================================================= */ + +int main(void) +{ + logger_init_default(); + + if (generate_test_video() != 0) { + fprintf(stderr, "FATAL: cannot generate test video\n"); + return 1; + } + + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_per_session_watchdog_triggers), + cmocka_unit_test(test_shared_decode_watchdog_triggers), + }; + + int result = cmocka_run_group_tests(tests, NULL, NULL); + unlink(TEST_VIDEO_PATH); + return result; +} diff --git a/tests/test_logger.c b/tests/test_logger.c index aec6254..85c7534 100644 --- a/tests/test_logger.c +++ b/tests/test_logger.c @@ -22,6 +22,9 @@ #include #include #include +#include +#include +#include #include "util/logger.h" @@ -329,6 +332,65 @@ static void test_log_with_colors_disabled(void **state) logger_log(LOG_LEVEL_DEBUG, __FILE__, __LINE__, __func__, "no-color debug"); } +/* ========================================================================== + * Log rotation — archive old log and create fresh file + * ========================================================================== */ + +static void test_log_rotation_archives_old_log(void **state) +{ + (void)state; + char *path = make_tmppath(); + assert_non_null(path); + + logger_config_t cfg = { + .level = LOG_LEVEL_DEBUG, + .enable_console = false, + .enable_file = true, + .enable_timestamp = false, + .enable_colors = false, + .log_file = path, + }; + assert_int_equal(logger_init(&cfg), 0); + + /* Set a tiny rotation threshold (512 bytes) so we can trigger it easily */ + logger_set_max_size(512); + + /* Write enough data to exceed the 512-byte threshold */ + for (int i = 0; i < 50; i++) { + logger_log(LOG_LEVEL_INFO, __FILE__, __LINE__, __func__, + "rotation test line %d — padding to fill the log quickly", i); + } + + logger_cleanup(); + + /* The current log file should exist and be small (post-rotation) */ + struct stat st; + assert_int_equal(stat(path, &st), 0); + + /* An archived .gz file should exist matching the pattern .*gz */ + char glob_pattern[600]; + snprintf(glob_pattern, sizeof(glob_pattern), "%s.*.gz", path); + glob_t gl; + int gret = glob(glob_pattern, 0, NULL, &gl); + assert_int_equal(gret, 0); + assert_true(gl.gl_pathc >= 1); + + /* Archived file should be non-empty */ + struct stat gz_st; + assert_int_equal(stat(gl.gl_pathv[0], &gz_st), 0); + assert_true(gz_st.st_size > 0); + + /* Cleanup: remove all generated files */ + for (size_t i = 0; i < gl.gl_pathc; i++) + unlink(gl.gl_pathv[i]); + globfree(&gl); + unlink(path); + free(path); + + /* Reset rotation size to default */ + logger_set_max_size(0); +} + /* ========================================================================== * main * ========================================================================== */ @@ -366,6 +428,9 @@ int main(void) cmocka_unit_test_teardown(test_log_writes_to_file, teardown), cmocka_unit_test_teardown(test_log_with_timestamp_enabled, teardown), cmocka_unit_test_teardown(test_log_with_colors_disabled, teardown), + + /* --- log rotation --- */ + cmocka_unit_test_teardown(test_log_rotation_archives_old_log, teardown), }; return cmocka_run_group_tests(tests, NULL, NULL); diff --git a/tests/test_main.c b/tests/test_main.c index 8235dfa..7fa8e6e 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -558,6 +558,79 @@ static void test_main_with_log_env_variable(void **state) unlink(env_log_path); } +/* =========================================================================== + * validate_log_path — log file path restriction tests + * =========================================================================== */ + +/* /tmp paths must be rejected (world-writable, symlink attack risk) */ +static void test_validate_log_path_rejects_tmp(void **state) +{ + (void)state; + assert_false(validate_log_path("/tmp/dvledtx.log")); + assert_false(validate_log_path("/tmp/subdir/app.log")); +} + +/* /var/log/ is an allowed prefix */ +static void test_validate_log_path_allows_var_log(void **state) +{ + (void)state; + assert_true(validate_log_path("/var/log/dvledtx.log")); +} + +/* A filename without '/' resolves to cwd — should be allowed */ +static void test_validate_log_path_allows_cwd_relative(void **state) +{ + (void)state; + assert_true(validate_log_path("dvledtx.log")); +} + +/* =========================================================================== + * symlink config rejection test + * =========================================================================== */ + +/* Config file must not be a symlink — symlink attack mitigation */ +static void test_main_rejects_symlinked_config(void **state) +{ + (void)state; + const char *real_cfg = "test_real_config.json"; + const char *link_cfg = "test_symlink_config.json"; + + /* Create a minimal valid JSON config file */ + FILE *f = fopen(real_cfg, "w"); + assert_non_null(f); + fprintf(f, "{\"interfaces\":[{\"name\":\"0000:06:00.0\"," + "\"sip\":\"192.168.50.29\",\"dip\":\"239.168.85.20\"}]," + "\"video\":{\"width\":1920,\"height\":1080,\"fps\":30," + "\"fmt\":\"yuv422p10le\",\"tx_url\":\"test.mp4\"}," + "\"tx_sessions\":[{\"udp_port\":20000,\"payload_type\":96," + "\"crop\":{\"x\":0,\"y\":0,\"w\":1920,\"h\":1080}}]}"); + fclose(f); + + /* Create a symlink pointing to the real config */ + unlink(link_cfg); + int sr = symlink(real_cfg, link_cfg); + assert_int_equal(sr, 0); + + /* tx_app_real_main should reject the symlinked config */ + char *argv[] = {"dvledtx", "--config", (char *)link_cfg}; + int ret = tx_app_real_main(3, argv); + assert_int_equal(ret, -1); + + unlink(link_cfg); + unlink(real_cfg); +} + +/* =========================================================================== + * sudo detection test + * =========================================================================== */ + +/* Verify that geteuid() != 0 when tests are not run under sudo */ +static void test_not_running_as_sudo(void **state) +{ + (void)state; + assert_int_not_equal((int)geteuid(), 0); +} + /* =========================================================================== * main * =========================================================================== */ @@ -600,6 +673,17 @@ int main(void) cmocka_unit_test(test_main_resolve_ip_fails), cmocka_unit_test(test_main_with_log_file_redirect), cmocka_unit_test(test_main_with_log_env_variable), + + /* validate_log_path */ + cmocka_unit_test(test_validate_log_path_rejects_tmp), + cmocka_unit_test(test_validate_log_path_allows_var_log), + cmocka_unit_test(test_validate_log_path_allows_cwd_relative), + + /* symlink config rejection */ + cmocka_unit_test(test_main_rejects_symlinked_config), + + /* warn_if_root / sudo detection */ + cmocka_unit_test(test_not_running_as_sudo), }; return cmocka_run_group_tests(tests, NULL, NULL);