From 5d9d8682e3c180727a9de42c8e7ef2d972b0b356 Mon Sep 17 00:00:00 2001 From: Krista House Date: Mon, 24 Aug 2026 11:55:45 -0400 Subject: [PATCH 1/5] fix(dgw): adopt recording file-type helpers Centralize recording extension handling in streaming through RecordingFileType so validation, stream routing, and terminal input selection are derived from a single mapping. Define concrete MIME values for WebM, TRP, Asciicast, and SLOG pull artifacts, and update PullRecordingFile OpenAPI annotations so generated spec output remains aligned with source declarations. Issue: DGW-406 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- devolutions-gateway/openapi/gateway-api.yaml | 17 +++++- devolutions-gateway/src/api/jrec.rs | 45 ++++++++++++-- devolutions-gateway/src/streaming.rs | 64 ++++++++++++-------- devolutions-gateway/src/token.rs | 25 +++++++- 4 files changed, 118 insertions(+), 33 deletions(-) diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index 851fdd6af..607c8904f 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -315,6 +315,22 @@ paths: '200': description: Recording file content: + video/webm: + schema: + type: string + format: binary + application/x-asciicast: + schema: + type: string + format: binary + application/vnd.devolutions.trp: + schema: + type: string + format: binary + application/x-ndjson: + schema: + type: string + format: binary application/octet-stream: schema: type: string @@ -2280,4 +2296,3 @@ components: scheme: bearer bearerFormat: JWT description: Token allowing usage of the standalone web application - diff --git a/devolutions-gateway/src/api/jrec.rs b/devolutions-gateway/src/api/jrec.rs index b010f13a6..95f60ee8b 100644 --- a/devolutions-gateway/src/api/jrec.rs +++ b/devolutions-gateway/src/api/jrec.rs @@ -563,7 +563,18 @@ pub(crate) async fn pull_recording_session( ("filename" = String, Path, description = "Name of recording file to retrieve"), ), responses( - (status = 200, description = "Recording file", body = Vec), + ( + status = 200, + description = "Recording file", + body = Vec, + content_type = [ + "video/webm", + "application/x-asciicast", + "application/vnd.devolutions.trp", + "application/x-ndjson", + "application/octet-stream", + ], + ), (status = 400, description = "Bad request"), (status = 401, description = "Invalid or missing authorization token"), (status = 403, description = "Insufficient permissions"), @@ -609,10 +620,7 @@ where .await .map_err(HttpError::internal().err())?; - let content_type = path - .extension() - .and_then(RecordingFileType::from_extension) - .and_then(RecordingFileType::content_type); + let content_type = recording_file_content_type(&path); if let Some(content_type) = content_type { response @@ -640,6 +648,12 @@ fn is_safe_recording_file_name(file_name: &str) -> bool { !file_name.is_empty() && !file_name.contains("..") && !file_name.contains('/') && !file_name.contains('\\') } +fn recording_file_content_type(path: &Utf8Path) -> Option<&'static str> { + path.extension() + .and_then(RecordingFileType::from_extension) + .map(RecordingFileType::content_type) +} + /// Immutable package membership for one download attempt. /// /// `manifest_bytes` are the exact `recording.json` contents used to derive `clip_names`, @@ -1016,6 +1030,27 @@ mod tests { assert!(!is_safe_recording_file_name("a\\b.webm")); } + #[test] + fn detects_recording_file_content_types() { + assert_eq!( + recording_file_content_type(Utf8Path::new("recording-0.webm")), + Some("video/webm") + ); + assert_eq!( + recording_file_content_type(Utf8Path::new("recording-0.trp")), + Some("application/vnd.devolutions.trp") + ); + assert_eq!( + recording_file_content_type(Utf8Path::new("recording-0.cast")), + Some("application/x-asciicast") + ); + assert_eq!( + recording_file_content_type(Utf8Path::new("recording-0.slog")), + Some("application/x-ndjson") + ); + assert_eq!(recording_file_content_type(Utf8Path::new("recording-0.bin")), None); + } + #[tokio::test] async fn snapshots_manifest_files_for_zip() { let dir = tempfile::tempdir().expect("temp dir"); diff --git a/devolutions-gateway/src/streaming.rs b/devolutions-gateway/src/streaming.rs index b62f4994c..7d77b4ccc 100644 --- a/devolutions-gateway/src/streaming.rs +++ b/devolutions-gateway/src/streaming.rs @@ -32,10 +32,11 @@ pub(crate) async fn stream_file( let path = Arc::new(path.to_owned()); let upgrade_result = match streaming_type { - StreamingType::Terminal => { + StreamingType::Terminal(input_type) => { let shutdown_notify = Arc::clone(&shutdown_notify); ws.on_upgrade(move |socket| async move { - if let Err(e) = setup_terminal_streaming(&path, socket, shutdown_notify, when_new_chunk_appended).await + if let Err(e) = + setup_terminal_streaming(&path, input_type, socket, shutdown_notify, when_new_chunk_appended).await { error!(error = ?e, "Terminal streaming failed"); } @@ -77,7 +78,7 @@ impl terminal_streamer::TerminalStreamSocket for TerminalStreamSocketImpl { } enum StreamingType { - Terminal, + Terminal(terminal_streamer::InputStreamType), WebM, } @@ -87,24 +88,23 @@ async fn validate_streaming_file(path: &camino::Utf8Path) -> anyhow::Result anyhow::Result { + match file_type { + RecordingFileType::Asciicast => Ok(StreamingType::Terminal(terminal_streamer::InputStreamType::Asciinema)), + RecordingFileType::TRP => Ok(StreamingType::Terminal(terminal_streamer::InputStreamType::Trp)), + RecordingFileType::WebM => Ok(StreamingType::WebM), + RecordingFileType::SessionRecordingLog => anyhow::bail!("invalid file type"), } } async fn setup_terminal_streaming( path: &camino::Utf8Path, + input_type: terminal_streamer::InputStreamType, socket: WebSocket, shutdown_notify: Arc, when_new_chunk_appended: impl Fn() -> tokio::sync::oneshot::Receiver<()> + Send + 'static, @@ -127,15 +127,6 @@ async fn setup_terminal_streaming( .await .with_context(|| format!("failed to open file: {path:?}"))?; - let path_extension = path - .extension() - .context("no extension found in the recording file path")?; - let input_type = if path_extension == RecordingFileType::Asciicast.extension() { - terminal_streamer::InputStreamType::Asciinema - } else { - terminal_streamer::InputStreamType::Trp - }; - terminal_stream( TerminalStreamSocketImpl(socket), streaming_file, @@ -192,3 +183,28 @@ async fn setup_webm_streaming( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_recording_file_type_to_streaming_type() { + let asciicast_type = + streaming_type_for_file_type(RecordingFileType::Asciicast).expect("asciicast should stream in terminal"); + assert!(matches!( + asciicast_type, + StreamingType::Terminal(terminal_streamer::InputStreamType::Asciinema) + )); + + let trp_type = streaming_type_for_file_type(RecordingFileType::TRP).expect("trp should stream in terminal"); + assert!(matches!( + trp_type, + StreamingType::Terminal(terminal_streamer::InputStreamType::Trp) + )); + + let webm_type = streaming_type_for_file_type(RecordingFileType::WebM).expect("webm should stream as video"); + assert!(matches!(webm_type, StreamingType::WebM)); + assert!(streaming_type_for_file_type(RecordingFileType::SessionRecordingLog).is_err()); + } +} diff --git a/devolutions-gateway/src/token.rs b/devolutions-gateway/src/token.rs index a3f6e0e7f..6cddbe725 100644 --- a/devolutions-gateway/src/token.rs +++ b/devolutions-gateway/src/token.rs @@ -286,6 +286,9 @@ pub enum RecordingFileType { } impl RecordingFileType { + pub const WEBM_CONTENT_TYPE: &'static str = "video/webm"; + pub const TRP_CONTENT_TYPE: &'static str = "application/vnd.devolutions.trp"; + pub const ASCIICAST_CONTENT_TYPE: &'static str = "application/x-asciicast"; pub const SLOG_CONTENT_TYPE: &'static str = "application/x-ndjson"; pub const fn format_name(self) -> &'static str { @@ -316,10 +319,12 @@ impl RecordingFileType { } } - pub const fn content_type(self) -> Option<&'static str> { + pub const fn content_type(self) -> &'static str { match self { - RecordingFileType::SessionRecordingLog => Some(Self::SLOG_CONTENT_TYPE), - RecordingFileType::WebM | RecordingFileType::TRP | RecordingFileType::Asciicast => None, + RecordingFileType::WebM => Self::WEBM_CONTENT_TYPE, + RecordingFileType::TRP => Self::TRP_CONTENT_TYPE, + RecordingFileType::Asciicast => Self::ASCIICAST_CONTENT_TYPE, + RecordingFileType::SessionRecordingLog => Self::SLOG_CONTENT_TYPE, } } } @@ -1890,4 +1895,18 @@ mod tests { assert_ne!(claims.jti, Uuid::nil()); assert!(matches!(claims.destination, KdcDestination::Inject { .. })); } + + #[test] + fn recording_file_types_have_concrete_content_types() { + let expected = [ + (RecordingFileType::WebM, "video/webm"), + (RecordingFileType::TRP, "application/vnd.devolutions.trp"), + (RecordingFileType::Asciicast, "application/x-asciicast"), + (RecordingFileType::SessionRecordingLog, "application/x-ndjson"), + ]; + + for (recording_file_type, expected_content_type) in expected { + assert_eq!(recording_file_type.content_type(), expected_content_type); + } + } } From 4cb6f632645a3c2d52ac6dab7d472136801f5e9a Mon Sep 17 00:00:00 2001 From: Krista House Date: Mon, 24 Aug 2026 14:00:26 -0400 Subject: [PATCH 2/5] fix(dgw): include json pull media type Address PR feedback by declaring application/json in PullRecordingFile response media types, matching routes that can serve recording.json. Regenerate gateway-api.yaml from source annotations to keep generated OpenAPI output synchronized with the contract definition. Issue: DGW-406 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- devolutions-gateway/openapi/gateway-api.yaml | 5 +++++ devolutions-gateway/src/api/jrec.rs | 1 + 2 files changed, 6 insertions(+) diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index 607c8904f..15d09eb78 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -331,6 +331,10 @@ paths: schema: type: string format: binary + application/json: + schema: + type: string + format: binary application/octet-stream: schema: type: string @@ -2296,3 +2300,4 @@ components: scheme: bearer bearerFormat: JWT description: Token allowing usage of the standalone web application + diff --git a/devolutions-gateway/src/api/jrec.rs b/devolutions-gateway/src/api/jrec.rs index 95f60ee8b..929a6c16c 100644 --- a/devolutions-gateway/src/api/jrec.rs +++ b/devolutions-gateway/src/api/jrec.rs @@ -572,6 +572,7 @@ pub(crate) async fn pull_recording_session( "application/x-asciicast", "application/vnd.devolutions.trp", "application/x-ndjson", + "application/json", "application/octet-stream", ], ), From d7d990c7bc80addfbd494510559b436a10c5f2f0 Mon Sep 17 00:00:00 2001 From: Krista House Date: Mon, 24 Aug 2026 15:41:58 -0400 Subject: [PATCH 3/5] test(dgw): add extension boundary regressions Add a filename-driven streaming regression test that validates routing for .webm/.cast/.trp and rejection for unsupported or missing extensions. Expand recording content-type assertions with a table-driven check that covers supported artifacts and confirms recording.json falls back to ServeFile behavior. Issue: DGW-406 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- devolutions-gateway/src/api/jrec.rs | 33 +++++++++++---------- devolutions-gateway/src/streaming.rs | 43 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/devolutions-gateway/src/api/jrec.rs b/devolutions-gateway/src/api/jrec.rs index 929a6c16c..fb95d43cc 100644 --- a/devolutions-gateway/src/api/jrec.rs +++ b/devolutions-gateway/src/api/jrec.rs @@ -1033,23 +1033,22 @@ mod tests { #[test] fn detects_recording_file_content_types() { - assert_eq!( - recording_file_content_type(Utf8Path::new("recording-0.webm")), - Some("video/webm") - ); - assert_eq!( - recording_file_content_type(Utf8Path::new("recording-0.trp")), - Some("application/vnd.devolutions.trp") - ); - assert_eq!( - recording_file_content_type(Utf8Path::new("recording-0.cast")), - Some("application/x-asciicast") - ); - assert_eq!( - recording_file_content_type(Utf8Path::new("recording-0.slog")), - Some("application/x-ndjson") - ); - assert_eq!(recording_file_content_type(Utf8Path::new("recording-0.bin")), None); + let expected_content_types = [ + ("recording-0.webm", Some("video/webm")), + ("recording-0.trp", Some("application/vnd.devolutions.trp")), + ("recording-0.cast", Some("application/x-asciicast")), + ("recording-0.slog", Some("application/x-ndjson")), + ("recording.json", None), + ("recording-0.bin", None), + ]; + + for (file_name, expected_content_type) in expected_content_types { + assert_eq!( + recording_file_content_type(Utf8Path::new(file_name)), + expected_content_type, + "unexpected content type for {file_name}" + ); + } } #[tokio::test] diff --git a/devolutions-gateway/src/streaming.rs b/devolutions-gateway/src/streaming.rs index 7d77b4ccc..5291ab383 100644 --- a/devolutions-gateway/src/streaming.rs +++ b/devolutions-gateway/src/streaming.rs @@ -188,6 +188,49 @@ async fn setup_webm_streaming( mod tests { use super::*; + #[tokio::test] + async fn validates_streaming_behavior_from_file_extension() { + let webm_type = validate_streaming_file(camino::Utf8Path::new("recording-0.webm")) + .await + .expect("webm should be accepted"); + assert!(matches!(webm_type, StreamingType::WebM)); + + let cast_type = validate_streaming_file(camino::Utf8Path::new("recording-0.cast")) + .await + .expect("cast should be accepted"); + assert!(matches!( + cast_type, + StreamingType::Terminal(terminal_streamer::InputStreamType::Asciinema) + )); + + let trp_type = validate_streaming_file(camino::Utf8Path::new("recording-0.trp")) + .await + .expect("trp should be accepted"); + assert!(matches!( + trp_type, + StreamingType::Terminal(terminal_streamer::InputStreamType::Trp) + )); + + assert!( + validate_streaming_file(camino::Utf8Path::new("recording-0.slog")) + .await + .is_err(), + "slog should be rejected for streaming" + ); + assert!( + validate_streaming_file(camino::Utf8Path::new("recording-0.bin")) + .await + .is_err(), + "unknown extension should be rejected" + ); + assert!( + validate_streaming_file(camino::Utf8Path::new("recording-0")) + .await + .is_err(), + "missing extension should be rejected" + ); + } + #[test] fn maps_recording_file_type_to_streaming_type() { let asciicast_type = From 98647c4b6c4f9767c0aac205740736afd2325f95 Mon Sep 17 00:00:00 2001 From: Krista House Date: Wed, 26 Aug 2026 12:48:06 -0400 Subject: [PATCH 4/5] fix(dgw): address human review feedback Use application/octet-stream for TRP artifacts, make pull content type selection explicit with fallback behavior, and document streamability intent boundaries for /shadow streaming. Also update PullRecordingFile media types and add module-level streaming intent documentation requested in review. Issue: DGW-406 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- devolutions-gateway/openapi/gateway-api.yaml | 4 -- devolutions-gateway/src/api/jrec.rs | 30 ++++++------ devolutions-gateway/src/streaming.intent.md | 51 ++++++++++++++++++++ devolutions-gateway/src/streaming.rs | 2 + devolutions-gateway/src/token.rs | 4 +- 5 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 devolutions-gateway/src/streaming.intent.md diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index 15d09eb78..34ff45970 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -323,10 +323,6 @@ paths: schema: type: string format: binary - application/vnd.devolutions.trp: - schema: - type: string - format: binary application/x-ndjson: schema: type: string diff --git a/devolutions-gateway/src/api/jrec.rs b/devolutions-gateway/src/api/jrec.rs index fb95d43cc..5212eb095 100644 --- a/devolutions-gateway/src/api/jrec.rs +++ b/devolutions-gateway/src/api/jrec.rs @@ -570,7 +570,6 @@ pub(crate) async fn pull_recording_session( content_type = [ "video/webm", "application/x-asciicast", - "application/vnd.devolutions.trp", "application/x-ndjson", "application/json", "application/octet-stream", @@ -622,12 +621,9 @@ where .map_err(HttpError::internal().err())?; let content_type = recording_file_content_type(&path); - - if let Some(content_type) = content_type { - response - .headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); - } + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); Ok(response) } @@ -649,10 +645,15 @@ fn is_safe_recording_file_name(file_name: &str) -> bool { !file_name.is_empty() && !file_name.contains("..") && !file_name.contains('/') && !file_name.contains('\\') } -fn recording_file_content_type(path: &Utf8Path) -> Option<&'static str> { +fn recording_file_content_type(path: &Utf8Path) -> &'static str { + if path.file_name() == Some("recording.json") { + return "application/json"; + } + path.extension() .and_then(RecordingFileType::from_extension) .map(RecordingFileType::content_type) + .unwrap_or("application/octet-stream") } /// Immutable package membership for one download attempt. @@ -1034,12 +1035,13 @@ mod tests { #[test] fn detects_recording_file_content_types() { let expected_content_types = [ - ("recording-0.webm", Some("video/webm")), - ("recording-0.trp", Some("application/vnd.devolutions.trp")), - ("recording-0.cast", Some("application/x-asciicast")), - ("recording-0.slog", Some("application/x-ndjson")), - ("recording.json", None), - ("recording-0.bin", None), + ("recording-0.webm", "video/webm"), + ("recording-0.trp", "application/octet-stream"), + ("recording-0.cast", "application/x-asciicast"), + ("recording-0.slog", "application/x-ndjson"), + ("recording.json", "application/json"), + ("recording-0.bin", "application/octet-stream"), + ("recording-0", "application/octet-stream"), ]; for (file_name, expected_content_type) in expected_content_types { diff --git a/devolutions-gateway/src/streaming.intent.md b/devolutions-gateway/src/streaming.intent.md new file mode 100644 index 000000000..06abae19e --- /dev/null +++ b/devolutions-gateway/src/streaming.intent.md @@ -0,0 +1,51 @@ +# Recording streaming intent +This document captures the intended behaviour and architectural invariants for recording-file streaming in `streaming.rs`. + +## Scope + +These rules apply to the `/shadow` WebSocket streaming path implemented by `streaming.rs`. + +This includes: + +- recording file-type classification +- streamability decisions +- streaming implementation selection +- terminal input-format selection +They do not define the following: + +- JREC push behaviour +- JREC pull behaviour +- artifact storage +- download MIME types +- consumer-side rendering. + +## Streaming contract + +Only WebM, asciicast, and TRP recording artifacts are accepted by the `/shadow` streaming path. + +| Recording file type | Extension | Streaming behaviour | +| --- | --- | --- | +| `WebM` | `.webm` | WebM streaming | +| `Asciicast` | `.cast` | Terminal streaming using asciinema input | +| `TRP` | `.trp` | Terminal streaming using TRP input | +| `SessionRecordingLog` | `.slog` | Explicitly rejected by the `/shadow` streaming path | + +- A recognised`RecordingFileType` is not automatically supported by `/shadow` streaming. Each recognised recording file type must have explicitly defined behaviour for the `/shadow` streaming path. +- Files with missing or unrecognised extensions must be rejected before WebSocket streaming begins. + +## Architectural invariants + +- Recording artifact streaming must use the canonical `RecordingFileType` extension mapping as its source of truth. +- A recording file must be classified once. The resulting `RecordingFileType` must determine: + - if the artifact is supported by the `/shadow` streaming path + - which streaming implementation is used (when applicable) + - which terminal input format is used (when applicable) + +- Streaming validation, streamer selection, and terminal input selection must not maintain separate extension mappings or independently compare known recording extensions as raw strings. +- Adding a new `RecordingFileType` requires an explicit decision about whether it is supported by the `/shadow` streaming path and, if supported, how it is streamed. +- A new or unsupported recording file type must not silently fall back to an existing streaming implementation or terminal input format. +## Component boundaries +JREC artifact handling, storage, download content types, and consumer-side rendering are outside the scope of this document. + + +> **Boundary:** Session Recording Log artifacts are supported elsewhere in Gateway through the JREC recording flow. Their rejection by `/shadow` applies only to the WebSocket streaming path covered by this document. \ No newline at end of file diff --git a/devolutions-gateway/src/streaming.rs b/devolutions-gateway/src/streaming.rs index 5291ab383..61dca69f2 100644 --- a/devolutions-gateway/src/streaming.rs +++ b/devolutions-gateway/src/streaming.rs @@ -82,6 +82,8 @@ enum StreamingType { WebM, } +/// Determines streamability from recording type, which is stricter than pull MIME handling. +/// A file may be downloadable but still rejected here when there is no streaming backend. async fn validate_streaming_file(path: &camino::Utf8Path) -> anyhow::Result { let path_extension = path .extension() diff --git a/devolutions-gateway/src/token.rs b/devolutions-gateway/src/token.rs index 6cddbe725..1b2e194e0 100644 --- a/devolutions-gateway/src/token.rs +++ b/devolutions-gateway/src/token.rs @@ -287,7 +287,7 @@ pub enum RecordingFileType { impl RecordingFileType { pub const WEBM_CONTENT_TYPE: &'static str = "video/webm"; - pub const TRP_CONTENT_TYPE: &'static str = "application/vnd.devolutions.trp"; + pub const TRP_CONTENT_TYPE: &'static str = "application/octet-stream"; pub const ASCIICAST_CONTENT_TYPE: &'static str = "application/x-asciicast"; pub const SLOG_CONTENT_TYPE: &'static str = "application/x-ndjson"; @@ -1900,7 +1900,7 @@ mod tests { fn recording_file_types_have_concrete_content_types() { let expected = [ (RecordingFileType::WebM, "video/webm"), - (RecordingFileType::TRP, "application/vnd.devolutions.trp"), + (RecordingFileType::TRP, "application/octet-stream"), (RecordingFileType::Asciicast, "application/x-asciicast"), (RecordingFileType::SessionRecordingLog, "application/x-ndjson"), ]; From 475b928257061529de7cc8bab08ebd69b89aec9a Mon Sep 17 00:00:00 2001 From: Krista House Date: Tue, 1 Sep 2026 09:35:47 -0400 Subject: [PATCH 5/5] docs(dgw): trim streaming intent preface Remove the redundant introductory sentence from streaming.intent.md so the document starts directly with scope, matching the intent-file convention feedback. Issue: DGW-406 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- devolutions-gateway/src/streaming.intent.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/devolutions-gateway/src/streaming.intent.md b/devolutions-gateway/src/streaming.intent.md index 06abae19e..f3be7928e 100644 --- a/devolutions-gateway/src/streaming.intent.md +++ b/devolutions-gateway/src/streaming.intent.md @@ -1,6 +1,4 @@ # Recording streaming intent -This document captures the intended behaviour and architectural invariants for recording-file streaming in `streaming.rs`. - ## Scope These rules apply to the `/shadow` WebSocket streaming path implemented by `streaming.rs`.