qconnect: Rust backstop validates per-track origins server-side
v2_qconnect_send_command_with_admission trusted a single frontend origin and never inspected track_ids. Add track_origins[] to the request and validate_track_origins_for_admission: re-evaluate every track via the core gate, block if any is non-Qobuz or the list is empty. The per-track check is only enforced when the caller actually ships per-track origins (bulk queue loads); single-track play-next/play-later carry no track_origins and stay gated by the command-level origin alone, so an empty list never over-blocks them. The existing command-level check and admission_blocked emit are kept. Frontend loadQconnectQueue now computes per-track origins from its tracks arg via resolveQconnectTrackOrigin and ships them to the sender. Defers the optional DB cross-check (LibraryState / has_local_track_by_qobuz_id defense-in-depth) as a follow-up; this commit is the per-track-origins validator + wiring + test only. cc
This commit is contained in:
4 files changed
+67
-5
No files matched your search
@@ -5,7 +5,7 @@
|
||||
//! to `QconnectServiceState` after a `runtime.check_requirements` gate.
|
||||
|
||||
use qconnect_app::{
|
||||
evaluate_remote_queue_admission, resolve_handoff_intent, QConnectQueueState,
|
||||
evaluate_remote_queue_admission, resolve_handoff_intent, AdmissionDecision, QConnectQueueState,
|
||||
QConnectRendererState, QconnectStartupMode, QueueCommandType, RendererReport,
|
||||
RendererReportType,
|
||||
};
|
||||
@@ -182,6 +182,26 @@ pub async fn v2_qconnect_evaluate_queue_admission(
|
||||
})
|
||||
}
|
||||
|
||||
/// Server-side backstop: re-evaluate EVERY track's origin, independent of the
|
||||
/// command-level `origin`. A bare `track_id` (a `u64`) cannot prove "Qobuz vs
|
||||
/// local/Plex" on its own, so the frontend ships per-track origins and the gate
|
||||
/// re-validates each one here. An empty list is blocked: we cannot prove the
|
||||
/// queue is all-Qobuz, so we refuse rather than trust the command-level origin.
|
||||
pub(super) fn validate_track_origins_for_admission(
|
||||
origins: &[QconnectTrackOrigin],
|
||||
) -> AdmissionDecision {
|
||||
if origins.is_empty() {
|
||||
return AdmissionDecision::block("empty_track_origins_blocked");
|
||||
}
|
||||
for origin in origins {
|
||||
let decision = evaluate_remote_queue_admission(origin.into_core_origin());
|
||||
if !decision.accepted {
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
AdmissionDecision::allow("all_track_origins_qobuz")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn v2_qconnect_send_command_with_admission(
|
||||
request: QconnectSendCommandWithAdmissionRequest,
|
||||
@@ -196,8 +216,24 @@ pub async fn v2_qconnect_send_command_with_admission(
|
||||
|
||||
if request.command_type.requires_remote_queue_admission() {
|
||||
let core_origin = request.origin.into_core_origin();
|
||||
let decision = evaluate_remote_queue_admission(core_origin);
|
||||
if !decision.accepted {
|
||||
let command_decision = evaluate_remote_queue_admission(core_origin);
|
||||
|
||||
// Per-track backstop: only enforced when the caller actually ships
|
||||
// per-track origins (bulk queue loads). Single-track play-next/play-later
|
||||
// legitimately carry no `track_origins` and gate on the command-level
|
||||
// origin alone, so an empty list here must NOT over-block them.
|
||||
let per_track_decision = if request.track_origins.is_empty() {
|
||||
command_decision.clone()
|
||||
} else {
|
||||
validate_track_origins_for_admission(&request.track_origins)
|
||||
};
|
||||
|
||||
if !command_decision.accepted || !per_track_decision.accepted {
|
||||
let decision = if !per_track_decision.accepted {
|
||||
per_track_decision
|
||||
} else {
|
||||
command_decision
|
||||
};
|
||||
log::warn!(
|
||||
"[QConnect] send_command_with_admission: BLOCKED reason={}",
|
||||
decision.reason
|
||||
|
||||
@@ -98,6 +98,17 @@ fn maps_qconnect_track_origin_to_core_origin_and_handoff() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_command_when_any_track_origin_is_non_qobuz() {
|
||||
use super::commands::validate_track_origins_for_admission;
|
||||
use super::QconnectTrackOrigin::*;
|
||||
assert!(validate_track_origins_for_admission(&[QobuzOnline, QobuzOfflineCache]).accepted);
|
||||
assert!(!validate_track_origins_for_admission(&[QobuzOnline, LocalLibrary]).accepted);
|
||||
assert!(!validate_track_origins_for_admission(&[Plex]).accepted);
|
||||
assert!(!validate_track_origins_for_admission(&[ExternalUnknown]).accepted);
|
||||
assert!(!validate_track_origins_for_admission(&[]).accepted); // empty -> blocked
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshes_local_renderer_id_from_exact_device_uuid_match() {
|
||||
let local_device_uuid = super::transport::resolve_qconnect_device_uuid();
|
||||
|
||||
@@ -182,6 +182,12 @@ pub struct QconnectAdmissionResult {
|
||||
pub struct QconnectSendCommandWithAdmissionRequest {
|
||||
pub command_type: QconnectOutboundCommandType,
|
||||
pub origin: QconnectTrackOrigin,
|
||||
/// Per-track origins for the IDs in `payload.track_ids`. The server gate
|
||||
/// re-validates EACH, independent of the command-level `origin`. Defaults
|
||||
/// to empty for callers that legitimately carry no track payload (e.g.
|
||||
/// single-track play-next/play-later that gate on the command-level origin).
|
||||
#[serde(default)]
|
||||
pub track_origins: Vec<QconnectTrackOrigin>,
|
||||
#[serde(default)]
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
@@ -202,12 +202,14 @@ async function resolveQconnectPlayNextInsertAfterFromSnapshots(): Promise<Qconne
|
||||
async function sendQconnectQueueCommandWithAdmission(
|
||||
commandType: QconnectQueueCommandType,
|
||||
origin: QconnectTrackOrigin,
|
||||
payload: Record<string, unknown>
|
||||
payload: Record<string, unknown>,
|
||||
trackOrigins: QconnectTrackOrigin[] = []
|
||||
): Promise<void> {
|
||||
await invoke('v2_qconnect_send_command_with_admission', {
|
||||
request: {
|
||||
command_type: commandType,
|
||||
origin,
|
||||
track_origins: trackOrigins,
|
||||
payload
|
||||
}
|
||||
});
|
||||
@@ -610,8 +612,15 @@ export async function loadQconnectQueue(
|
||||
preview_track_ids: trackIds.slice(0, 8),
|
||||
payload
|
||||
});
|
||||
// Per-track origins for the Rust backstop (fix #3): when backend-shaped
|
||||
// tracks are supplied, ship each track's resolved origin so the server gate
|
||||
// can re-validate every id, not just the command-level origin. Bare id-only
|
||||
// callers send none and stay gated by the command-level origin alone.
|
||||
const trackOrigins = tracks
|
||||
? tracks.map((tr) => resolveQconnectTrackOrigin(tr, tr.is_local ?? false))
|
||||
: [];
|
||||
console.log('[QConnect/LoadQueue] sending queue_load_tracks');
|
||||
await sendQconnectQueueCommandWithAdmission('queue_load_tracks', origin, payload);
|
||||
await sendQconnectQueueCommandWithAdmission('queue_load_tracks', origin, payload, trackOrigins);
|
||||
console.log('[QConnect/LoadQueue] SUCCESS');
|
||||
|
||||
// Sync local autoplay preference to QConnect server after queue load
|
||||
|
||||
Reference in new issue
Block a user