Skip to main content

loki_file_access/
token.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 AppThere
3
4//! Capability token for accessing user-selected files.
5//!
6//! [`FileAccessToken`] is the central type returned by every picker operation.
7//! It encapsulates all platform-specific state needed to re-open a file,
8//! including Android URIs, iOS security-scoped bookmarks, desktop paths, and
9//! in-memory WASM data.
10//!
11//! Tokens are serializable to a URL-safe base64-encoded JSON string via
12//! [`FileAccessToken::serialize`] and [`FileAccessToken::deserialize`], making
13//! them suitable for persisting in a recent-files list or application database.
14
15use base64::engine::general_purpose::URL_SAFE_NO_PAD;
16use base64::Engine as _;
17use std::path::PathBuf;
18
19use crate::error::{AccessError, TokenParseError};
20
21/// Status of the permission grant associated with a [`FileAccessToken`].
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum PermissionStatus {
25    /// The token's permission is still valid and the file can be opened.
26    Valid,
27    /// The permission has been revoked by the user or the operating system.
28    Revoked,
29    /// The permission status cannot be determined on this platform.
30    Unknown,
31}
32
33/// Internal representation of platform-specific token data.
34///
35/// This enum is serialized to JSON and then base64-encoded for storage.
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37pub(crate) enum TokenInner {
38    /// Desktop file identified by filesystem path.
39    Desktop {
40        /// Absolute path to the file.
41        path: PathBuf,
42        /// User-visible file name.
43        display_name: String,
44    },
45    /// Android file identified by a content URI.
46    Android {
47        /// Content URI string (e.g. `content://...`).
48        uri: String,
49        /// User-visible file name from the document provider.
50        display_name: String,
51        /// MIME type reported by the document provider.
52        mime_type: Option<String>,
53    },
54    /// iOS file identified by a security-scoped bookmark.
55    Ios {
56        /// Opaque bookmark data created by `NSURL.bookmarkData(...)`.
57        bookmark: Vec<u8>,
58        /// User-visible file name.
59        display_name: String,
60        /// MIME type (often inferred from the file extension).
61        mime_type: Option<String>,
62    },
63    /// WASM file held entirely in memory.
64    Wasm {
65        /// Complete file contents.
66        data: Vec<u8>,
67        /// Original file name from the `<input>` element.
68        name: String,
69        /// MIME type reported by the browser.
70        mime_type: Option<String>,
71    },
72}
73
74/// A serializable capability token representing access to a user-selected file.
75///
76/// Obtain instances from [`crate::FilePicker`] methods.  Serialize via
77/// [`serialize`](Self::serialize) for storage; deserialize to reopen files
78/// across app restarts.
79#[derive(Debug, Clone)]
80pub struct FileAccessToken {
81    pub(crate) inner: TokenInner,
82}
83
84impl FileAccessToken {
85    /// Open the file for reading.  Returns `Read + Seek`.
86    ///
87    /// # Errors
88    ///
89    /// Returns [`AccessError`] if permission is revoked or the file cannot be opened.
90    #[must_use = "this returns a Result that may contain an error"]
91    pub fn open_read(&self) -> Result<Box<dyn ReadSeek>, AccessError> {
92        crate::platform::open_read(&self.inner)
93    }
94
95    /// Open the file for writing.  Returns `Write + Seek`.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`AccessError`] if permission is revoked or the file cannot be opened.
100    #[must_use = "this returns a Result that may contain an error"]
101    pub fn open_write(&self) -> Result<Box<dyn WriteSeek>, AccessError> {
102        crate::platform::open_write(&self.inner)
103    }
104
105    /// Returns the user-visible display name of the file (typically the filename).
106    #[must_use]
107    pub fn display_name(&self) -> &str {
108        match &self.inner {
109            TokenInner::Desktop { display_name, .. }
110            | TokenInner::Android { display_name, .. }
111            | TokenInner::Ios { display_name, .. } => display_name,
112            TokenInner::Wasm { name, .. } => name,
113        }
114    }
115
116    /// Returns the MIME type of the file, if known.  Desktop returns `None`.
117    #[must_use]
118    pub fn mime_type(&self) -> Option<&str> {
119        match &self.inner {
120            TokenInner::Desktop { .. } => None,
121            TokenInner::Android { mime_type, .. }
122            | TokenInner::Ios { mime_type, .. }
123            | TokenInner::Wasm { mime_type, .. } => mime_type.as_deref(),
124        }
125    }
126
127    /// Check whether the permission grant for this file is still valid.
128    #[must_use]
129    pub fn check_permission(&self) -> PermissionStatus {
130        crate::platform::check_permission(&self.inner)
131    }
132
133    /// Serialize the token to a URL-safe base64-encoded string for storage.
134    #[must_use]
135    pub fn serialize(&self) -> String {
136        // Serialization of the inner enum to JSON should not fail for our
137        // data types (no maps with non-string keys, no infinite floats).
138        // However, we handle the error path gracefully by returning an
139        // empty-object JSON fallback, which will fail on deserialization
140        // with a clear error rather than panicking here.
141        let json = match serde_json::to_string(&self.inner) {
142            Ok(j) => j,
143            Err(_) => return URL_SAFE_NO_PAD.encode(b"{}"),
144        };
145        URL_SAFE_NO_PAD.encode(json.as_bytes())
146    }
147
148    /// Deserialize a token from a string previously returned by [`serialize`](Self::serialize).
149    ///
150    /// # Errors
151    ///
152    /// Returns [`TokenParseError`] if the string is malformed.
153    pub fn deserialize(s: &str) -> Result<Self, TokenParseError> {
154        let bytes = URL_SAFE_NO_PAD
155            .decode(s)
156            .map_err(|e| TokenParseError::InvalidBase64 {
157                message: e.to_string(),
158            })?;
159
160        let json = String::from_utf8(bytes).map_err(|e| TokenParseError::InvalidBase64 {
161            message: e.to_string(),
162        })?;
163
164        let inner: TokenInner =
165            serde_json::from_str(&json).map_err(|e| TokenParseError::InvalidJson {
166                message: e.to_string(),
167            })?;
168
169        Ok(Self { inner })
170    }
171}
172
173/// Trait object combining [`std::io::Read`] and [`std::io::Seek`].
174pub trait ReadSeek: std::io::Read + std::io::Seek + Send {}
175impl<T: std::io::Read + std::io::Seek + Send> ReadSeek for T {}
176
177/// Trait object combining [`std::io::Write`] and [`std::io::Seek`].
178pub trait WriteSeek: std::io::Write + std::io::Seek + Send {}
179impl<T: std::io::Write + std::io::Seek + Send> WriteSeek for T {}
180
181impl std::fmt::Display for FileAccessToken {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.write_str(&self.serialize())
184    }
185}
186
187impl std::str::FromStr for FileAccessToken {
188    type Err = TokenParseError;
189
190    fn from_str(s: &str) -> Result<Self, Self::Err> {
191        Self::deserialize(s)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn round_trip_desktop_token() {
201        let token = FileAccessToken {
202            inner: TokenInner::Desktop {
203                path: PathBuf::from("/tmp/test.txt"),
204                display_name: "test.txt".into(),
205            },
206        };
207        let serialized = token.serialize();
208        let restored = FileAccessToken::deserialize(&serialized).unwrap();
209        assert_eq!(restored.display_name(), "test.txt");
210        assert!(restored.mime_type().is_none());
211    }
212
213    #[test]
214    fn round_trip_android_token() {
215        let token = FileAccessToken {
216            inner: TokenInner::Android {
217                uri: "content://com.example/doc/1".into(),
218                display_name: "photo.jpg".into(),
219                mime_type: Some("image/jpeg".into()),
220            },
221        };
222        let serialized = token.serialize();
223        let restored = FileAccessToken::deserialize(&serialized).unwrap();
224        assert_eq!(restored.display_name(), "photo.jpg");
225        assert_eq!(restored.mime_type(), Some("image/jpeg"));
226    }
227
228    #[test]
229    fn round_trip_ios_token() {
230        let token = FileAccessToken {
231            inner: TokenInner::Ios {
232                bookmark: vec![0xDE, 0xAD, 0xBE, 0xEF],
233                display_name: "notes.pdf".into(),
234                mime_type: Some("application/pdf".into()),
235            },
236        };
237        let serialized = token.serialize();
238        let restored = FileAccessToken::deserialize(&serialized).unwrap();
239        assert_eq!(restored.display_name(), "notes.pdf");
240        assert_eq!(restored.mime_type(), Some("application/pdf"));
241    }
242
243    #[test]
244    fn round_trip_wasm_token() {
245        let token = FileAccessToken {
246            inner: TokenInner::Wasm {
247                data: vec![1, 2, 3, 4, 5],
248                name: "data.bin".into(),
249                mime_type: Some("application/octet-stream".into()),
250            },
251        };
252        let serialized = token.serialize();
253        let restored = FileAccessToken::deserialize(&serialized).unwrap();
254        assert_eq!(restored.display_name(), "data.bin");
255        assert_eq!(restored.mime_type(), Some("application/octet-stream"));
256    }
257
258    #[test]
259    fn deserialize_invalid_base64_returns_error() {
260        let result = FileAccessToken::deserialize("not!valid!base64!!!");
261        assert!(result.is_err());
262        assert!(matches!(
263            result.unwrap_err(),
264            TokenParseError::InvalidBase64 { .. }
265        ));
266    }
267
268    #[test]
269    fn deserialize_invalid_json_returns_error() {
270        let bad = URL_SAFE_NO_PAD.encode(b"not json");
271        let result = FileAccessToken::deserialize(&bad);
272        assert!(result.is_err());
273        assert!(matches!(
274            result.unwrap_err(),
275            TokenParseError::InvalidJson { .. }
276        ));
277    }
278
279    #[test]
280    fn display_and_from_str_round_trip() {
281        let token = FileAccessToken {
282            inner: TokenInner::Desktop {
283                path: PathBuf::from("/tmp/x.txt"),
284                display_name: "x.txt".into(),
285            },
286        };
287        let s = token.to_string();
288        let restored: FileAccessToken = s.parse().unwrap();
289        assert_eq!(restored.display_name(), "x.txt");
290    }
291}