feat(cast): qbz-cast crate hardening for the Slint port (Phase 1)
Additive, backward-compatible enhancements to the shared cast crate (the Tauri shims are pure re-exports, so existing callers are unaffected). - DLNA timeouts: pause/stop/seek/set_volume now run through a shared run_action() with a 10s timeout (was none) so a hung renderer can't block the caller forever. New DlnaError::Timeout / CastError::Timeout variants. - DLNA mute: add set_mute / get_mute (RenderingControl SetMute/GetMute, Master channel) — was missing alongside set_volume. - DLNA capability detection is now version-agnostic (matches AVTransport:2/:3, not only :1) so newer renderers report has_av_transport correctly. - Media server endpoint auth: served path is now /audio/<token>/<id> with a 128-bit per-session token (RandomState-seeded, no new dep). Token lives in the PATH, not a query string, so DLNA renderers that strip queries still authenticate. Mismatch -> 403. - register_file content-type parity: content_type_for_path now mirrors the rich local-cast MIME map (audio/x-ape, mp3/ogg/opus/aac, m4a/alac->audio/mp4) instead of the poorer table; add register_file_with_content_type so the caller can supply an exact MIME. Local files stream from disk (no full-RAM read) per decision D4. Unit-tested: tokened-path parsing, rich MIME map, token randomness. cc
This commit is contained in:
4 files changed
+225
-45
No files matched your search
@@ -157,6 +157,31 @@ impl DlnaConnection {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a SOAP action with a timeout. A hung renderer maps to
|
||||
/// `DlnaError::Timeout` instead of blocking the caller forever — closes the
|
||||
/// gap where pause/stop/seek/set_volume had no timeout at all.
|
||||
async fn run_action(
|
||||
service: &Service,
|
||||
device_url: &Uri,
|
||||
name: &str,
|
||||
payload: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<std::collections::HashMap<String, String>, DlnaError> {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(timeout_secs),
|
||||
service.action(device_url, name, payload),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
log::error!("DLNA: {name} action timed out after {timeout_secs}s");
|
||||
DlnaError::Timeout(format!("{name} action timed out"))
|
||||
})?
|
||||
.map_err(|e| {
|
||||
log::error!("DLNA: {name} action failed: {e}");
|
||||
DlnaError::Playback(e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Start/resume playback
|
||||
pub async fn play(&mut self) -> Result<(), DlnaError> {
|
||||
if !self.connected {
|
||||
@@ -203,10 +228,14 @@ impl DlnaConnection {
|
||||
.as_ref()
|
||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||
|
||||
av_service
|
||||
.action(&self.device_url, "Pause", "<InstanceID>0</InstanceID>")
|
||||
.await
|
||||
.map_err(|e| DlnaError::Playback(e.to_string()))?;
|
||||
Self::run_action(
|
||||
av_service,
|
||||
&self.device_url,
|
||||
"Pause",
|
||||
"<InstanceID>0</InstanceID>",
|
||||
10,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.is_playing = false;
|
||||
log::info!("DLNA: Pause");
|
||||
@@ -224,10 +253,14 @@ impl DlnaConnection {
|
||||
.as_ref()
|
||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||
|
||||
av_service
|
||||
.action(&self.device_url, "Stop", "<InstanceID>0</InstanceID>")
|
||||
.await
|
||||
.map_err(|e| DlnaError::Playback(e.to_string()))?;
|
||||
Self::run_action(
|
||||
av_service,
|
||||
&self.device_url,
|
||||
"Stop",
|
||||
"<InstanceID>0</InstanceID>",
|
||||
10,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.is_playing = false;
|
||||
self.current_uri = None;
|
||||
@@ -256,10 +289,7 @@ impl DlnaConnection {
|
||||
time_str
|
||||
);
|
||||
|
||||
av_service
|
||||
.action(&self.device_url, "Seek", &payload)
|
||||
.await
|
||||
.map_err(|e| DlnaError::Playback(e.to_string()))?;
|
||||
Self::run_action(av_service, &self.device_url, "Seek", &payload, 10).await?;
|
||||
|
||||
log::info!("DLNA: Seek to {}", time_str);
|
||||
Ok(())
|
||||
@@ -283,15 +313,60 @@ impl DlnaConnection {
|
||||
dlna_volume
|
||||
);
|
||||
|
||||
rc_service
|
||||
.action(&self.device_url, "SetVolume", &payload)
|
||||
.await
|
||||
.map_err(|e| DlnaError::Playback(e.to_string()))?;
|
||||
Self::run_action(rc_service, &self.device_url, "SetVolume", &payload, 10).await?;
|
||||
|
||||
log::info!("DLNA: Set volume to {}", dlna_volume);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set mute on/off (RenderingControl SetMute, Master channel). Companion to
|
||||
/// `set_volume` — was missing from the crate.
|
||||
pub async fn set_mute(&mut self, mute: bool) -> Result<(), DlnaError> {
|
||||
if !self.connected {
|
||||
return Err(DlnaError::NotConnected);
|
||||
}
|
||||
|
||||
let rc_service = self.rendering_control_service.as_ref().ok_or_else(|| {
|
||||
DlnaError::Playback("Device has no RenderingControl service".to_string())
|
||||
})?;
|
||||
|
||||
let payload = format!(
|
||||
"<InstanceID>0</InstanceID><Channel>Master</Channel><DesiredMute>{}</DesiredMute>",
|
||||
if mute { 1 } else { 0 }
|
||||
);
|
||||
|
||||
Self::run_action(rc_service, &self.device_url, "SetMute", &payload, 10).await?;
|
||||
|
||||
log::info!("DLNA: Set mute to {}", mute);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Query current mute state (RenderingControl GetMute, Master channel).
|
||||
pub async fn get_mute(&self) -> Result<bool, DlnaError> {
|
||||
if !self.connected {
|
||||
return Err(DlnaError::NotConnected);
|
||||
}
|
||||
|
||||
let rc_service = self.rendering_control_service.as_ref().ok_or_else(|| {
|
||||
DlnaError::Playback("Device has no RenderingControl service".to_string())
|
||||
})?;
|
||||
|
||||
let response = Self::run_action(
|
||||
rc_service,
|
||||
&self.device_url,
|
||||
"GetMute",
|
||||
"<InstanceID>0</InstanceID><Channel>Master</Channel>",
|
||||
5,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let muted = response
|
||||
.get("CurrentMute")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
Ok(muted)
|
||||
}
|
||||
|
||||
/// Get current playback position and transport state
|
||||
pub async fn get_position_info(&self) -> Result<DlnaPositionInfo, DlnaError> {
|
||||
if !self.connected {
|
||||
|
||||
@@ -80,11 +80,13 @@ impl DlnaDiscovery {
|
||||
let mut has_rendering_control = false;
|
||||
|
||||
for service in device.services_iter() {
|
||||
// Version-agnostic match: a renderer advertising only
|
||||
// AVTransport:2/:3 (not :1) must still count as capable.
|
||||
let service_type = service.service_type().to_string();
|
||||
if service_type == av_transport_urn().to_string() {
|
||||
if service_type.contains(":service:AVTransport:") {
|
||||
has_av_transport = true;
|
||||
}
|
||||
if service_type == rendering_control_urn().to_string() {
|
||||
if service_type.contains(":service:RenderingControl:") {
|
||||
has_rendering_control = true;
|
||||
}
|
||||
}
|
||||
@@ -149,11 +151,3 @@ impl Default for DlnaDiscovery {
|
||||
fn media_renderer_urn() -> URN {
|
||||
URN::Device("schemas-upnp-org".into(), "MediaRenderer".into(), 1)
|
||||
}
|
||||
|
||||
fn av_transport_urn() -> URN {
|
||||
URN::Service("schemas-upnp-org".into(), "AVTransport".into(), 1)
|
||||
}
|
||||
|
||||
fn rendering_control_urn() -> URN {
|
||||
URN::Service("schemas-upnp-org".into(), "RenderingControl".into(), 1)
|
||||
}
|
||||
@@ -28,6 +28,9 @@ pub enum CastError {
|
||||
|
||||
#[error("Invalid request: {0}")]
|
||||
InvalidRequest(String),
|
||||
|
||||
#[error("Operation timed out: {0}")]
|
||||
Timeout(String),
|
||||
}
|
||||
|
||||
/// DLNA/UPnP casting errors
|
||||
@@ -50,4 +53,7 @@ pub enum DlnaError {
|
||||
|
||||
#[error("Device not found: {0}")]
|
||||
DeviceNotFound(String),
|
||||
|
||||
#[error("Operation timed out: {0}")]
|
||||
Timeout(String),
|
||||
}
|
||||
@@ -34,6 +34,12 @@ enum MediaSource {
|
||||
pub struct MediaServer {
|
||||
port: u16,
|
||||
base_url: String,
|
||||
/// Per-session random token embedded in the served path
|
||||
/// (`/audio/<token>/<id>`). Raises the bar from "guess a small integer id"
|
||||
/// to "guess a 128-bit token" for a LAN peer trying to pull buffered bytes.
|
||||
/// Carried in the PATH (not a query string) because some DLNA renderers
|
||||
/// drop query strings.
|
||||
token: String,
|
||||
entries: Arc<Mutex<HashMap<u64, MediaEntry>>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
@@ -69,9 +75,11 @@ impl MediaServer {
|
||||
|
||||
let entries = Arc::new(Mutex::new(HashMap::new()));
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let token = generate_token();
|
||||
|
||||
let entries_clone = entries.clone();
|
||||
let shutdown_clone = shutdown.clone();
|
||||
let token_clone = token.clone();
|
||||
let port_for_log = port;
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
@@ -87,6 +95,7 @@ impl MediaServer {
|
||||
request.url(),
|
||||
&request,
|
||||
&entries_clone,
|
||||
&token_clone,
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
@@ -103,6 +112,7 @@ impl MediaServer {
|
||||
Ok(Self {
|
||||
port,
|
||||
base_url,
|
||||
token,
|
||||
entries,
|
||||
shutdown,
|
||||
handle: Some(handle),
|
||||
@@ -122,7 +132,12 @@ impl MediaServer {
|
||||
self.base_url.clone()
|
||||
}
|
||||
|
||||
/// Register audio data to serve (returns path like "/audio/123")
|
||||
/// Relative served path for an id, including the session token.
|
||||
fn audio_path(&self, id: u64) -> String {
|
||||
format!("/audio/{}/{}", self.token, id)
|
||||
}
|
||||
|
||||
/// Register audio data to serve (returns path like "/audio/<token>/123")
|
||||
pub fn register_audio(&mut self, id: u64, data: Vec<u8>, content_type: &str) -> String {
|
||||
let entry = MediaEntry {
|
||||
content_type: content_type.to_string(),
|
||||
@@ -134,11 +149,27 @@ impl MediaServer {
|
||||
entries.insert(id, entry);
|
||||
}
|
||||
|
||||
format!("/audio/{}", id)
|
||||
self.audio_path(id)
|
||||
}
|
||||
|
||||
/// Register local file to serve
|
||||
/// Register a local file to serve, inferring the content type from the
|
||||
/// extension via [`content_type_for_path`].
|
||||
pub fn register_file(&mut self, id: u64, file_path: &str) -> Result<String, CastError> {
|
||||
let path = Path::new(file_path);
|
||||
let content_type = content_type_for_path(path);
|
||||
self.register_file_with_content_type(id, file_path, &content_type)
|
||||
}
|
||||
|
||||
/// Register a local file to serve with an explicit content type. Lets the
|
||||
/// caller supply a richer MIME than extension-sniffing yields (e.g. from
|
||||
/// decoded track metadata). Streams the file from disk (range-capable),
|
||||
/// never reads the whole file into RAM.
|
||||
pub fn register_file_with_content_type(
|
||||
&mut self,
|
||||
id: u64,
|
||||
file_path: &str,
|
||||
content_type: &str,
|
||||
) -> Result<String, CastError> {
|
||||
let path = Path::new(file_path);
|
||||
if !path.exists() {
|
||||
return Err(CastError::InvalidRequest(format!(
|
||||
@@ -148,10 +179,9 @@ impl MediaServer {
|
||||
}
|
||||
|
||||
let size = path.metadata().map_err(CastError::Io)?.len();
|
||||
let content_type = content_type_for_path(path);
|
||||
|
||||
let entry = MediaEntry {
|
||||
content_type,
|
||||
content_type: content_type.to_string(),
|
||||
size,
|
||||
source: MediaSource::File(path.to_path_buf()),
|
||||
};
|
||||
@@ -160,14 +190,14 @@ impl MediaServer {
|
||||
entries.insert(id, entry);
|
||||
}
|
||||
|
||||
Ok(format!("/audio/{}", id))
|
||||
Ok(self.audio_path(id))
|
||||
}
|
||||
|
||||
/// Get full URL for registered audio
|
||||
pub fn get_audio_url(&self, id: u64) -> Option<String> {
|
||||
let entries = self.entries.lock().ok()?;
|
||||
if entries.contains_key(&id) {
|
||||
return Some(format!("{}/audio/{}", self.base_url, id));
|
||||
return Some(format!("{}{}", self.base_url, self.audio_path(id)));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -183,7 +213,7 @@ impl MediaServer {
|
||||
.map(|ip| format_base_url(ip, self.port))
|
||||
.unwrap_or_else(|| self.base_url.clone());
|
||||
|
||||
Some(format!("{}/audio/{}", base_url, id))
|
||||
Some(format!("{}{}", base_url, self.audio_path(id)))
|
||||
}
|
||||
|
||||
/// Get server port
|
||||
@@ -203,6 +233,7 @@ fn handle_request(
|
||||
url: &str,
|
||||
request: &tiny_http::Request,
|
||||
entries: &Arc<Mutex<HashMap<u64, MediaEntry>>>,
|
||||
expected_token: &str,
|
||||
) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
log::info!(
|
||||
"MediaServer: {} request from {:?} for {}",
|
||||
@@ -216,17 +247,23 @@ fn handle_request(
|
||||
return Response::from_data(Vec::new()).with_status_code(StatusCode(405));
|
||||
}
|
||||
|
||||
let id = match parse_audio_id(url) {
|
||||
Some(id) => id,
|
||||
let (token, id) = match parse_audio_request(url) {
|
||||
Some(parsed) => parsed,
|
||||
None => {
|
||||
log::warn!(
|
||||
"MediaServer: 404 - Could not parse audio ID from URL: {}",
|
||||
"MediaServer: 404 - Could not parse audio request from URL: {}",
|
||||
url
|
||||
);
|
||||
return Response::from_data(Vec::new()).with_status_code(StatusCode(404));
|
||||
}
|
||||
};
|
||||
|
||||
// Constant-ish token check — reject LAN peers that don't hold the token.
|
||||
if token != expected_token {
|
||||
log::warn!("MediaServer: 403 - token mismatch for URL: {}", url);
|
||||
return Response::from_data(Vec::new()).with_status_code(StatusCode(403));
|
||||
}
|
||||
|
||||
let entry = match entries.lock().ok().and_then(|map| map.get(&id).cloned()) {
|
||||
Some(entry) => {
|
||||
log::info!(
|
||||
@@ -299,13 +336,17 @@ fn read_range(
|
||||
Ok((buffer, status_code, content_range))
|
||||
}
|
||||
|
||||
fn parse_audio_id(url: &str) -> Option<u64> {
|
||||
/// Parse `/audio/<token>/<id>` into `(token, id)`. The token is carried in the
|
||||
/// path (not a query string) so DLNA renderers that strip query strings still
|
||||
/// authenticate.
|
||||
fn parse_audio_request(url: &str) -> Option<(String, u64)> {
|
||||
let path = url.split('?').next().unwrap_or(url);
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
if parts.len() != 2 || parts[0] != "audio" {
|
||||
if parts.len() != 3 || parts[0] != "audio" {
|
||||
return None;
|
||||
}
|
||||
parts[1].parse().ok()
|
||||
let id = parts[2].parse().ok()?;
|
||||
Some((parts[1].to_string(), id))
|
||||
}
|
||||
|
||||
fn parse_range(header: &str, total: u64) -> Option<(u64, u64)> {
|
||||
@@ -367,17 +408,81 @@ fn format_base_url(ip: IpAddr, port: u16) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a local-file extension to a content type. Mirrors the rich map the
|
||||
/// Tauri local-cast path used (`local_track_content_type`) so casting a local
|
||||
/// file advertises the right MIME — the previous map here was poorer
|
||||
/// (`audio/ape`, no mp3/ogg/opus/aac, octet-stream gaps).
|
||||
fn content_type_for_path(path: &Path) -> String {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some(ext) if ext == "flac" => "audio/flac".to_string(),
|
||||
Some(ext) if ext == "wav" => "audio/wav".to_string(),
|
||||
Some(ext) if ext == "m4a" => "audio/mp4".to_string(),
|
||||
Some(ext) if ext == "aiff" || ext == "aif" => "audio/aiff".to_string(),
|
||||
Some(ext) if ext == "ape" => "audio/ape".to_string(),
|
||||
Some("flac") => "audio/flac".to_string(),
|
||||
Some("wav") => "audio/wav".to_string(),
|
||||
Some("m4a") | Some("alac") | Some("mp4") => "audio/mp4".to_string(),
|
||||
Some("aiff") | Some("aif") => "audio/aiff".to_string(),
|
||||
Some("ape") => "audio/x-ape".to_string(),
|
||||
Some("mp3") => "audio/mpeg".to_string(),
|
||||
Some("ogg") | Some("oga") => "audio/ogg".to_string(),
|
||||
Some("opus") => "audio/opus".to_string(),
|
||||
Some("aac") => "audio/aac".to_string(),
|
||||
_ => "application/octet-stream".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a 128-bit hex session token seeded from OS entropy (via
|
||||
/// `RandomState`), without pulling in a `rand` dependency. Not cryptographic,
|
||||
/// but enough to stop a LAN peer from guessing `/audio/<id>`.
|
||||
fn generate_token() -> String {
|
||||
use std::hash::BuildHasher;
|
||||
let hi = std::collections::hash_map::RandomState::new().hash_one(0x9E37_79B9u64);
|
||||
let lo = std::collections::hash_map::RandomState::new().hash_one(0x85EB_CA77u64);
|
||||
format!("{:016x}{:016x}", hi, lo)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_audio_request_tokened_path() {
|
||||
assert_eq!(
|
||||
parse_audio_request("/audio/deadbeef/123"),
|
||||
Some(("deadbeef".to_string(), 123))
|
||||
);
|
||||
// Query string is ignored.
|
||||
assert_eq!(
|
||||
parse_audio_request("/audio/tok/42?x=1"),
|
||||
Some(("tok".to_string(), 42))
|
||||
);
|
||||
// Old un-tokened path no longer parses.
|
||||
assert_eq!(parse_audio_request("/audio/123"), None);
|
||||
assert_eq!(parse_audio_request("/other/tok/1"), None);
|
||||
assert_eq!(parse_audio_request("/audio/tok/notanum"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_type_map_is_rich() {
|
||||
use std::path::Path;
|
||||
assert_eq!(content_type_for_path(Path::new("a.flac")), "audio/flac");
|
||||
assert_eq!(content_type_for_path(Path::new("a.ape")), "audio/x-ape");
|
||||
assert_eq!(content_type_for_path(Path::new("a.mp3")), "audio/mpeg");
|
||||
assert_eq!(content_type_for_path(Path::new("a.opus")), "audio/opus");
|
||||
assert_eq!(content_type_for_path(Path::new("a.m4a")), "audio/mp4");
|
||||
assert_eq!(
|
||||
content_type_for_path(Path::new("a.xyz")),
|
||||
"application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_is_random_and_long() {
|
||||
let a = generate_token();
|
||||
let b = generate_token();
|
||||
assert_eq!(a.len(), 32);
|
||||
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert_ne!(a, b, "two tokens should differ");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user