heldar_kernel/
camera_url.rs1use crate::models::Camera;
4
5fn hik_channel(stream: &str) -> &'static str {
7 if stream == "sub" {
8 "102"
9 } else {
10 "101"
11 }
12}
13
14pub(crate) fn encode_userinfo(s: &str) -> String {
16 let mut out = String::with_capacity(s.len());
17 for b in s.bytes() {
18 match b {
19 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
21 out.push(b as char)
22 }
23 _ => out.push_str(&format!("%{:02X}", b)),
24 }
25 }
26 out
27}
28
29pub fn stream_url(cam: &Camera, stream: &str) -> Option<String> {
32 let explicit = if stream == "sub" {
33 cam.sub_stream_url.as_deref()
34 } else {
35 cam.main_stream_url.as_deref()
36 };
37 if let Some(u) = explicit {
38 if !u.trim().is_empty() {
39 return Some(u.trim().to_string());
40 }
41 }
42
43 let host = cam.address.as_deref()?.trim();
44 if host.is_empty() {
45 return None;
46 }
47 let port = cam.rtsp_port;
48
49 let creds = match (cam.username.as_deref(), cam.password.as_deref()) {
50 (Some(u), Some(p)) if !u.is_empty() => {
51 format!("{}:{}@", encode_userinfo(u), encode_userinfo(p))
52 }
53 (Some(u), _) if !u.is_empty() => format!("{}@", encode_userinfo(u)),
54 _ => String::new(),
55 };
56
57 let path = match cam.vendor.as_str() {
58 "hikvision" => format!("/Streaming/Channels/{}", hik_channel(stream)),
59 "dahua" => format!(
60 "/cam/realmonitor?channel=1&subtype={}",
61 if stream == "sub" { "1" } else { "0" }
62 ),
63 _ => return None,
65 };
66
67 Some(format!("rtsp://{creds}{host}:{port}{path}"))
68}
69
70pub fn record_url(cam: &Camera) -> Option<String> {
72 stream_url(cam, &cam.record_stream)
73}
74
75const ALLOWED_SCHEMES: &[&str] = &["rtsp", "rtsps", "http", "https"];
78
79pub fn validate_stream_url(url: &str) -> Result<(), String> {
81 let url = url.trim();
82 let Some((scheme, _)) = url.split_once("://") else {
83 return Err(format!(
84 "invalid stream URL `{}` (no scheme://)",
85 mask_url(url)
86 ));
87 };
88 let scheme = scheme.to_ascii_lowercase();
89 if !ALLOWED_SCHEMES.contains(&scheme.as_str()) {
90 return Err(format!(
91 "stream URL scheme `{scheme}` not allowed; use one of {ALLOWED_SCHEMES:?}"
92 ));
93 }
94 Ok(())
95}
96
97pub fn mask_url(url: &str) -> String {
99 let Some(scheme_end) = url.find("://") else {
100 return url.to_string();
101 };
102 let after = scheme_end + 3;
103 let authority_end = url[after..]
106 .find('/')
107 .map(|i| after + i)
108 .unwrap_or(url.len());
109 if let Some(at_rel) = url[after..authority_end].rfind('@') {
110 let at = after + at_rel;
111 format!("{}***@{}", &url[..after], &url[at + 1..])
112 } else {
113 url.to_string()
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::models::Camera;
121 use chrono::Utc;
122 use serde_json::json;
123 use sqlx::types::Json;
124
125 fn base() -> Camera {
126 Camera {
127 id: "cam1".into(),
128 site_id: None,
129 name: "Cam 1".into(),
130 vendor: "hikvision".into(),
131 model: None,
132 address: Some("192.168.0.2".into()),
133 rtsp_port: 554,
134 username: Some("admin".into()),
135 password: Some("p@ss/w:rd".into()),
136 main_stream_url: None,
137 sub_stream_url: None,
138 record_stream: "main".into(),
139 codec: None,
140 resolution_main: None,
141 resolution_sub: None,
142 fps_main: None,
143 fps_sub: None,
144 capabilities: Json(json!({})),
145 record_enabled: true,
146 segment_seconds: 60,
147 retention_hours: 24,
148 enabled: true,
149 created_at: Utc::now(),
150 updated_at: Utc::now(),
151 }
152 }
153
154 #[test]
155 fn hikvision_main_url_percent_encodes_credentials() {
156 let c = base();
157 assert_eq!(
158 stream_url(&c, "main").unwrap(),
159 "rtsp://admin:p%40ss%2Fw%3Ard@192.168.0.2:554/Streaming/Channels/101"
160 );
161 }
162
163 #[test]
164 fn hikvision_sub_uses_channel_102() {
165 assert!(stream_url(&base(), "sub")
166 .unwrap()
167 .ends_with("/Streaming/Channels/102"));
168 }
169
170 #[test]
171 fn explicit_override_takes_precedence() {
172 let mut c = base();
173 c.main_stream_url = Some("rtsp://example/stream".into());
174 assert_eq!(stream_url(&c, "main").unwrap(), "rtsp://example/stream");
175 }
176
177 #[test]
178 fn generic_vendor_without_url_is_none() {
179 let mut c = base();
180 c.vendor = "generic".into();
181 c.main_stream_url = None;
182 assert!(stream_url(&c, "main").is_none());
183 }
184
185 #[test]
186 fn mask_url_hides_credentials() {
187 assert_eq!(
188 mask_url("rtsp://admin:secret@10.0.0.1:554/Streaming/Channels/101"),
189 "rtsp://***@10.0.0.1:554/Streaming/Channels/101"
190 );
191 assert_eq!(mask_url("rtsp://10.0.0.1:554/x"), "rtsp://10.0.0.1:554/x");
192 }
193
194 #[test]
195 fn mask_url_handles_at_in_password() {
196 assert_eq!(
198 mask_url("rtsp://user:p@ss@10.0.0.1:554/x"),
199 "rtsp://***@10.0.0.1:554/x"
200 );
201 }
202
203 #[test]
204 fn validate_stream_url_rejects_dangerous_schemes() {
205 assert!(validate_stream_url("rtsp://10.0.0.1:554/x").is_ok());
206 assert!(validate_stream_url("https://cam/stream").is_ok());
207 assert!(validate_stream_url("file:///etc/passwd").is_err());
208 assert!(validate_stream_url("gopher://x").is_err());
209 assert!(validate_stream_url("not-a-url").is_err());
210 }
211}