Files
zoneminder/tests/zm_rtsp_deprecation.cpp
T
Isaac ConnorandClaude Opus 5 8d77f0f637 feat: deprecate the Remote/RTSP capture method in favour of Ffmpeg
ZoneMinder carries its own RTSP/RTP implementation - zm_rtsp.cpp,
zm_rtp_ctrl.cpp, zm_rtp_source.cpp, zm_rtp_data.cpp, zm_rtp.cpp and
zm_remote_camera_rtsp.cpp, about 66KB in total. It is hand-written parsing of
untrusted network input, which is the least rewarding kind of code to own, and
Ffmpeg already does the same job for more cameras and is maintained upstream.

This marks it deprecated. It does not remove anything and does not change how
any existing monitor captures.

The capture log now names the replacement instead of only saying the method is
going away:

  Monitor 7 (Driveway): the Remote/RTSP capture method is deprecated and will
  be removed. Change this monitor to Type 'Ffmpeg' with Source Path
  rtsp://10.0.0.5:554/live

Monitor::RtspUrlFromRemote builds that URL from the Host, Port, Path, User and
Pass the monitor already stores. It is pure so it can be tested without a
camera, and it is the piece a conversion migration will need when the code is
finally deleted.

The URL is passed through remove_authentication() before being logged, the
same helper zm_ffmpeg_camera.cpp uses. A deprecation notice that wrote camera
passwords into /var/log/zm would be a bad trade.

Credentials are percent-encoded. A password containing @ or : otherwise splits
the authority in the wrong place and yields a URL pointing at a different host,
which fails in a way that is hard to read.

In the editor the protocol is now labelled "RTSP (deprecated)" and selecting it
explains what to change to. Only the label moved: htmlSelect still emits
value="rtsp", so updateMethods and the stored value are untouched.

No auto-converting migration. Deprecating and removing are separate steps, and
silently rewriting a working monitor to Ffmpeg could break a camera that Ffmpeg
handles worse - which is the reason to warn a release ahead of removing.

Tested: 16 assertions over the URL builder, run standalone because ctest still
cannot build against the Catch2 v2 installed here - empty credentials producing
no dangling userinfo, user without password, @ : / ? # and space in passwords,
a username needing escaping, missing leading slash, empty path, absent port,
query strings surviving, and that the masked form drops the password while
keeping the host. Full build clean, Perl 15 files / 249 assertions, ESLint 0
problems, tests/js 154 assertions, php -l clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-11 20:13:02 -05:00

99 lines
3.9 KiB
C++

/*
* This file is part of the ZoneMinder Project. See AUTHORS file for Copyright information
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "zm_catch2.h"
#include "zm_monitor.h"
#include "zm_utils.h"
#include <algorithm>
// The Remote/RTSP capture method is deprecated in favour of Ffmpeg. The
// warning it logs names the Source Path the operator should switch to, so that
// URL has to be correct and has to be safe to print.
TEST_CASE("Remote to Ffmpeg RTSP URL") {
SECTION("the ordinary case") {
REQUIRE(Monitor::RtspUrlFromRemote("10.0.0.5", "554", "/live/main", "admin", "secret")
== "rtsp://admin:secret@10.0.0.5:554/live/main");
}
SECTION("no credentials means no userinfo, not an empty one") {
// "rtsp://@host" is accepted by some parsers and rejected by others.
REQUIRE(Monitor::RtspUrlFromRemote("10.0.0.5", "554", "/live", "", "")
== "rtsp://10.0.0.5:554/live");
}
SECTION("a user with no password keeps the user") {
REQUIRE(Monitor::RtspUrlFromRemote("10.0.0.5", "554", "/live", "admin", "")
== "rtsp://admin@10.0.0.5:554/live");
}
SECTION("a password containing @ or : cannot split the authority") {
// This is the case that makes naive concatenation produce a URL pointing at
// the wrong host entirely.
const std::string url =
Monitor::RtspUrlFromRemote("10.0.0.5", "554", "/live", "admin", "p@ss:w0rd");
REQUIRE(url == "rtsp://admin:p%40ss%3Aw0rd@10.0.0.5:554/live");
// Exactly one @ separates userinfo from host.
REQUIRE(std::count(url.begin(), url.end(), '@') == 1);
REQUIRE(url.find("@10.0.0.5") != std::string::npos);
}
SECTION("other awkward password characters are escaped") {
REQUIRE(Monitor::RtspUrlFromRemote("h", "", "/p", "u", "a/b?c#d")
== "rtsp://u:a%2Fb%3Fc%23d@h/p");
REQUIRE(Monitor::RtspUrlFromRemote("h", "", "/p", "u", "a b")
== "rtsp://u:a%20b@h/p");
}
SECTION("a username needing escaping is escaped too") {
REQUIRE(Monitor::RtspUrlFromRemote("h", "", "/p", "dom\\user", "pw")
== "rtsp://dom%5Cuser:pw@h/p");
}
SECTION("a path without a leading slash gains one") {
REQUIRE(Monitor::RtspUrlFromRemote("h", "554", "live/main", "", "")
== "rtsp://h:554/live/main");
}
SECTION("an empty path still yields a slash") {
REQUIRE(Monitor::RtspUrlFromRemote("h", "554", "", "", "") == "rtsp://h:554/");
}
SECTION("an absent port is omitted rather than left dangling") {
REQUIRE(Monitor::RtspUrlFromRemote("h", "", "/p", "", "") == "rtsp://h/p");
}
SECTION("a query string in the path survives") {
// Plenty of cameras carry ?channel=1&subtype=0; the path is copied through.
REQUIRE(Monitor::RtspUrlFromRemote("h", "554", "/cam?channel=1&subtype=0", "", "")
== "rtsp://h:554/cam?channel=1&subtype=0");
}
}
TEST_CASE("The deprecation warning does not leak the password") {
// The warning is logged, so it must go through remove_authentication first.
const std::string url =
Monitor::RtspUrlFromRemote("10.0.0.5", "554", "/live", "admin", "hunter2");
REQUIRE(url.find("hunter2") != std::string::npos);
const std::string masked = remove_authentication(url);
REQUIRE(masked.find("hunter2") == std::string::npos);
REQUIRE(masked.find("10.0.0.5") != std::string::npos);
}