use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use std::path::PathBuf;
use crate::error::{AccessError, TokenParseError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum PermissionStatus {
Valid,
Revoked,
Unknown,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) enum TokenInner {
Desktop {
path: PathBuf,
display_name: String,
},
Android {
uri: String,
display_name: String,
mime_type: Option<String>,
},
Ios {
bookmark: Vec<u8>,
display_name: String,
mime_type: Option<String>,
},
Wasm {
data: Vec<u8>,
name: String,
mime_type: Option<String>,
},
}
#[derive(Debug, Clone)]
pub struct FileAccessToken {
pub(crate) inner: TokenInner,
}
impl FileAccessToken {
#[must_use = "this returns a Result that may contain an error"]
pub fn open_read(&self) -> Result<Box<dyn ReadSeek>, AccessError> {
crate::platform::open_read(&self.inner)
}
#[must_use = "this returns a Result that may contain an error"]
pub fn open_write(&self) -> Result<Box<dyn WriteSeek>, AccessError> {
crate::platform::open_write(&self.inner)
}
#[must_use]
pub fn display_name(&self) -> &str {
match &self.inner {
TokenInner::Desktop { display_name, .. }
| TokenInner::Android { display_name, .. }
| TokenInner::Ios { display_name, .. } => display_name,
TokenInner::Wasm { name, .. } => name,
}
}
#[must_use]
pub fn mime_type(&self) -> Option<&str> {
match &self.inner {
TokenInner::Desktop { .. } => None,
TokenInner::Android { mime_type, .. }
| TokenInner::Ios { mime_type, .. }
| TokenInner::Wasm { mime_type, .. } => mime_type.as_deref(),
}
}
#[must_use]
pub fn check_permission(&self) -> PermissionStatus {
crate::platform::check_permission(&self.inner)
}
#[must_use]
pub fn serialize(&self) -> String {
let json = match serde_json::to_string(&self.inner) {
Ok(j) => j,
Err(_) => return URL_SAFE_NO_PAD.encode(b"{}"),
};
URL_SAFE_NO_PAD.encode(json.as_bytes())
}
pub fn deserialize(s: &str) -> Result<Self, TokenParseError> {
let bytes = URL_SAFE_NO_PAD
.decode(s)
.map_err(|e| TokenParseError::InvalidBase64 {
message: e.to_string(),
})?;
let json = String::from_utf8(bytes).map_err(|e| TokenParseError::InvalidBase64 {
message: e.to_string(),
})?;
let inner: TokenInner =
serde_json::from_str(&json).map_err(|e| TokenParseError::InvalidJson {
message: e.to_string(),
})?;
Ok(Self { inner })
}
}
pub trait ReadSeek: std::io::Read + std::io::Seek + Send {}
impl<T: std::io::Read + std::io::Seek + Send> ReadSeek for T {}
pub trait WriteSeek: std::io::Write + std::io::Seek + Send {}
impl<T: std::io::Write + std::io::Seek + Send> WriteSeek for T {}
impl std::fmt::Display for FileAccessToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.serialize())
}
}
impl std::str::FromStr for FileAccessToken {
type Err = TokenParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::deserialize(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_desktop_token() {
let token = FileAccessToken {
inner: TokenInner::Desktop {
path: PathBuf::from("/tmp/test.txt"),
display_name: "test.txt".into(),
},
};
let serialized = token.serialize();
let restored = FileAccessToken::deserialize(&serialized).unwrap();
assert_eq!(restored.display_name(), "test.txt");
assert!(restored.mime_type().is_none());
}
#[test]
fn round_trip_android_token() {
let token = FileAccessToken {
inner: TokenInner::Android {
uri: "content://com.example/doc/1".into(),
display_name: "photo.jpg".into(),
mime_type: Some("image/jpeg".into()),
},
};
let serialized = token.serialize();
let restored = FileAccessToken::deserialize(&serialized).unwrap();
assert_eq!(restored.display_name(), "photo.jpg");
assert_eq!(restored.mime_type(), Some("image/jpeg"));
}
#[test]
fn round_trip_ios_token() {
let token = FileAccessToken {
inner: TokenInner::Ios {
bookmark: vec![0xDE, 0xAD, 0xBE, 0xEF],
display_name: "notes.pdf".into(),
mime_type: Some("application/pdf".into()),
},
};
let serialized = token.serialize();
let restored = FileAccessToken::deserialize(&serialized).unwrap();
assert_eq!(restored.display_name(), "notes.pdf");
assert_eq!(restored.mime_type(), Some("application/pdf"));
}
#[test]
fn round_trip_wasm_token() {
let token = FileAccessToken {
inner: TokenInner::Wasm {
data: vec![1, 2, 3, 4, 5],
name: "data.bin".into(),
mime_type: Some("application/octet-stream".into()),
},
};
let serialized = token.serialize();
let restored = FileAccessToken::deserialize(&serialized).unwrap();
assert_eq!(restored.display_name(), "data.bin");
assert_eq!(restored.mime_type(), Some("application/octet-stream"));
}
#[test]
fn deserialize_invalid_base64_returns_error() {
let result = FileAccessToken::deserialize("not!valid!base64!!!");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
TokenParseError::InvalidBase64 { .. }
));
}
#[test]
fn deserialize_invalid_json_returns_error() {
let bad = URL_SAFE_NO_PAD.encode(b"not json");
let result = FileAccessToken::deserialize(&bad);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
TokenParseError::InvalidJson { .. }
));
}
#[test]
fn display_and_from_str_round_trip() {
let token = FileAccessToken {
inner: TokenInner::Desktop {
path: PathBuf::from("/tmp/x.txt"),
display_name: "x.txt".into(),
},
};
let s = token.to_string();
let restored: FileAccessToken = s.parse().unwrap();
assert_eq!(restored.display_name(), "x.txt");
}
}