Skip to main content

webui_desktop/
protocol.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::error::{DesktopError, Result};
8use crate::{DesktopResponseContent, DesktopResponseFile};
9
10/// Reserved custom-protocol path for protobuf desktop IPC.
11#[cfg(feature = "application-ipc")]
12pub const IPC_ENDPOINT: &str = "/_webui/ipc";
13
14/// Default maximum file length delivered by a custom-protocol response.
15pub const DEFAULT_MAX_ASSET_BYTES: u64 = 32 * 1024 * 1024;
16
17/// Maximum ordinary native API request body size. Typed IPC has its own limits.
18pub const DEFAULT_MAX_REQUEST_BYTES: u64 = 1024 * 1024;
19
20/// HTTP method for a desktop custom-protocol request.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub enum DesktopHttpMethod {
23    /// GET.
24    Get,
25    /// POST.
26    Post,
27    /// Any other method.
28    Other(String),
29}
30
31impl DesktopHttpMethod {
32    /// Parse an HTTP method name.
33    #[must_use]
34    pub fn parse(method: &str) -> Self {
35        match method {
36            "GET" | "get" => Self::Get,
37            "POST" | "post" => Self::Post,
38            other => Self::Other(other.to_string()),
39        }
40    }
41}
42
43/// Runtime-neutral representation of a custom-protocol request.
44pub struct DesktopProtocolRequest<'a> {
45    /// Request method.
46    pub method: DesktopHttpMethod,
47    /// URL path, including a leading slash.
48    pub path: &'a str,
49    /// Request body bytes.
50    pub body: &'a [u8],
51    /// Whether the request asks for a router JSON/NDJSON response.
52    pub wants_json: bool,
53}
54
55impl<'a> DesktopProtocolRequest<'a> {
56    /// Create a GET request.
57    #[must_use]
58    pub fn get(path: &'a str) -> Self {
59        Self {
60            method: DesktopHttpMethod::Get,
61            path,
62            body: &[],
63            wants_json: false,
64        }
65    }
66
67    /// Create a POST request.
68    #[must_use]
69    pub fn post(path: &'a str, body: &'a [u8]) -> Self {
70        Self {
71            method: DesktopHttpMethod::Post,
72            path,
73            body,
74            wants_json: false,
75        }
76    }
77}
78
79/// Runtime-neutral representation of a custom-protocol response.
80#[derive(Debug)]
81pub struct DesktopProtocolResponse {
82    /// HTTP status code.
83    pub status: u16,
84    /// Content type header value.
85    pub content_type: String,
86    /// Owned bytes or an already-opened file for bounded streaming delivery.
87    pub body: DesktopResponseContent,
88}
89
90/// Owned response bytes and any resource reservation backing their lifetime.
91///
92/// Native adapters must retain the complete body, or both values returned by
93/// [`Self::into_parts`], until the native consumer releases the bytes.
94pub struct DesktopResponseBody {
95    bytes: Vec<u8>,
96    lease: Option<DesktopResponseLease>,
97}
98
99/// Opaque response-lifetime reservation released when the native body is freed.
100pub struct DesktopResponseLease {
101    _owner: Box<dyn Send + Sync>,
102}
103
104impl DesktopResponseBody {
105    /// Attach a reservation to an owned response buffer without copying it.
106    #[must_use]
107    pub fn with_guard<G: Send + Sync + 'static>(bytes: Vec<u8>, guard: G) -> Self {
108        Self {
109            bytes,
110            lease: Some(DesktopResponseLease {
111                _owner: Box::new(guard),
112            }),
113        }
114    }
115
116    /// Borrow response bytes while retaining their reservation.
117    #[must_use]
118    pub fn as_slice(&self) -> &[u8] {
119        &self.bytes
120    }
121
122    /// Transfer bytes and reservation to a native ownership container.
123    ///
124    /// The returned lease must not be dropped before the last native view of
125    /// the corresponding buffer is released.
126    #[must_use]
127    pub fn into_parts(self) -> (Vec<u8>, Option<DesktopResponseLease>) {
128        (self.bytes, self.lease)
129    }
130}
131
132impl From<Vec<u8>> for DesktopResponseBody {
133    fn from(bytes: Vec<u8>) -> Self {
134        Self { bytes, lease: None }
135    }
136}
137
138impl AsRef<[u8]> for DesktopResponseBody {
139    fn as_ref(&self) -> &[u8] {
140        &self.bytes
141    }
142}
143
144impl std::ops::Deref for DesktopResponseBody {
145    type Target = [u8];
146
147    fn deref(&self) -> &Self::Target {
148        &self.bytes
149    }
150}
151
152impl std::fmt::Debug for DesktopResponseBody {
153    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        formatter
155            .debug_struct("DesktopResponseBody")
156            .field("length", &self.bytes.len())
157            .field("reserved", &self.lease.is_some())
158            .finish()
159    }
160}
161
162impl<T: AsRef<[u8]> + ?Sized> PartialEq<T> for DesktopResponseBody {
163    fn eq(&self, other: &T) -> bool {
164        self.bytes.as_slice() == other.as_ref()
165    }
166}
167
168impl Eq for DesktopResponseBody {}
169
170impl DesktopProtocolResponse {
171    /// Create a response.
172    #[must_use]
173    pub fn new(
174        status: u16,
175        content_type: impl Into<String>,
176        body: impl Into<DesktopResponseContent>,
177    ) -> Self {
178        Self {
179            status,
180            content_type: content_type.into(),
181            body: body.into(),
182        }
183    }
184
185    /// Create a plain-text response.
186    #[must_use]
187    pub fn text(status: u16, body: impl Into<String>) -> Self {
188        Self::new(
189            status,
190            "text/plain; charset=utf-8",
191            body.into().into_bytes(),
192        )
193    }
194
195    /// Create a protobuf response.
196    #[must_use]
197    pub fn protobuf(body: impl Into<DesktopResponseBody>) -> Self {
198        Self::new(200, "application/x-protobuf", body.into())
199    }
200
201    /// Create an HTML response.
202    #[must_use]
203    pub fn html(body: impl Into<Vec<u8>>) -> Self {
204        Self::new(200, "text/html; charset=utf-8", body.into())
205    }
206}
207
208pub(crate) fn read_asset_response(
209    asset_root: &Path,
210    asset_path: PathBuf,
211    max_asset_bytes: u64,
212) -> Result<Option<DesktopProtocolResponse>> {
213    let canonical = match asset_path.canonicalize() {
214        Ok(path) => path,
215        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
216        Err(source) => {
217            return Err(DesktopError::Io {
218                context: format!("resolving desktop asset {}", asset_path.display()),
219                source,
220            })
221        }
222    };
223
224    if !canonical.starts_with(asset_root) {
225        return Err(DesktopError::InvalidAssetPath {
226            path: canonical.display().to_string(),
227        });
228    }
229
230    let metadata = fs::metadata(&canonical).map_err(|source| DesktopError::Io {
231        context: format!("reading metadata for desktop asset {}", canonical.display()),
232        source,
233    })?;
234
235    if !metadata.is_file() {
236        return Ok(None);
237    }
238
239    let size = metadata.len();
240    if size > max_asset_bytes {
241        return Err(DesktopError::AssetTooLarge {
242            path: canonical,
243            size,
244            max_bytes: max_asset_bytes,
245        });
246    }
247
248    let content_type = mime_guess::from_path(&canonical)
249        .first_or_octet_stream()
250        .to_string();
251
252    read_known_asset_response(asset_root, &canonical, &content_type, size, max_asset_bytes)
253        .map(Some)
254}
255
256pub(crate) fn read_known_asset_response(
257    asset_root: &Path,
258    asset_path: &Path,
259    content_type: &str,
260    size_bytes: u64,
261    max_asset_bytes: u64,
262) -> Result<DesktopProtocolResponse> {
263    if size_bytes > max_asset_bytes {
264        return Err(DesktopError::AssetTooLarge {
265            path: asset_path.to_path_buf(),
266            size: size_bytes,
267            max_bytes: max_asset_bytes,
268        });
269    }
270
271    let file = crate::asset_file::open(asset_root, asset_path)?;
272    let metadata = file.metadata().map_err(|source| DesktopError::Io {
273        context: format!("checking opened desktop asset {}", asset_path.display()),
274        source,
275    })?;
276    if !metadata.is_file() {
277        return Err(DesktopError::InvalidAssetPath {
278            path: asset_path.display().to_string(),
279        });
280    }
281    if metadata.len() > max_asset_bytes {
282        return Err(DesktopError::AssetTooLarge {
283            path: asset_path.to_path_buf(),
284            size: metadata.len(),
285            max_bytes: max_asset_bytes,
286        });
287    }
288
289    Ok(DesktopProtocolResponse::new(
290        200,
291        content_type.to_string(),
292        DesktopResponseContent::File(DesktopResponseFile::new(file, metadata.len())),
293    ))
294}
295
296#[cfg(test)]
297#[allow(clippy::disallowed_methods)]
298mod response_body_tests {
299    use super::*;
300    use std::sync::{
301        atomic::{AtomicUsize, Ordering},
302        Arc,
303    };
304
305    struct ReleaseCount(Arc<AtomicUsize>);
306
307    impl Drop for ReleaseCount {
308        fn drop(&mut self) {
309            self.0.fetch_add(1, Ordering::SeqCst);
310        }
311    }
312
313    #[test]
314    fn response_body_retains_its_reservation_until_drop() {
315        let released = Arc::new(AtomicUsize::new(0));
316        let bytes = vec![1, 2, 3];
317        let address = bytes.as_ptr();
318        let body = DesktopResponseBody::with_guard(bytes, ReleaseCount(Arc::clone(&released)));
319        assert_eq!(body.as_slice(), &[1, 2, 3]);
320        assert_eq!(body.as_ptr(), address);
321        assert_eq!(released.load(Ordering::SeqCst), 0);
322        drop(body);
323        assert_eq!(released.load(Ordering::SeqCst), 1);
324    }
325
326    #[test]
327    fn native_transfer_preserves_reservation_and_allocation() {
328        let released = Arc::new(AtomicUsize::new(0));
329        let bytes = vec![4, 5, 6];
330        let address = bytes.as_ptr();
331        let response = DesktopProtocolResponse::protobuf(DesktopResponseBody::with_guard(
332            bytes,
333            ReleaseCount(Arc::clone(&released)),
334        ));
335        let (bytes, lease) = response.body.into_bytes().unwrap().into_parts();
336        assert_eq!(bytes.as_ptr(), address);
337        assert_eq!(released.load(Ordering::SeqCst), 0);
338        drop(bytes);
339        assert_eq!(released.load(Ordering::SeqCst), 0);
340        drop(lease);
341        assert_eq!(released.load(Ordering::SeqCst), 1);
342    }
343
344    #[test]
345    fn ordinary_responses_do_not_need_a_reservation() {
346        let response = DesktopProtocolResponse::new(201, "text/plain", b"created".to_vec());
347        assert_eq!(response.body, b"created");
348        let (bytes, lease) = response.body.into_bytes().unwrap().into_parts();
349        assert_eq!(bytes, b"created");
350        assert!(lease.is_none());
351    }
352}