Skip to main content

fileloft_core/
info.rs

1use std::collections::HashMap;
2
3use base64::{engine::general_purpose::STANDARD, Engine};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::TusError;
8
9/// Newtype around String making upload IDs distinct in function signatures.
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub struct UploadId(pub String);
12
13impl UploadId {
14    pub fn new() -> Self {
15        Self(uuid::Uuid::new_v4().to_string())
16    }
17
18    pub fn parse(value: &str) -> Result<Self, TusError> {
19        Self::validate_str(value)?;
20        Ok(Self(value.to_string()))
21    }
22
23    pub fn as_str(&self) -> &str {
24        &self.0
25    }
26
27    pub fn validate(&self) -> Result<(), TusError> {
28        Self::validate_str(&self.0)
29    }
30
31    fn validate_str(value: &str) -> Result<(), TusError> {
32        if value.is_empty() || value.len() > 128 {
33            return Err(TusError::InvalidUploadId);
34        }
35        if value == "." || value == ".." {
36            return Err(TusError::InvalidUploadId);
37        }
38        if !value
39            .bytes()
40            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
41        {
42            return Err(TusError::InvalidUploadId);
43        }
44        Ok(())
45    }
46}
47
48impl Default for UploadId {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl std::fmt::Display for UploadId {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.write_str(&self.0)
57    }
58}
59
60impl From<String> for UploadId {
61    fn from(s: String) -> Self {
62        Self(s)
63    }
64}
65
66impl From<&str> for UploadId {
67    fn from(s: &str) -> Self {
68        Self(s.to_string())
69    }
70}
71
72/// Key-value pairs from the `Upload-Metadata` header.
73/// Values are base64-decoded strings; a key with no value has `None`.
74///
75/// Wire format: `"key1 base64val1,key2 base64val2,keyOnly"`
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct Metadata(pub HashMap<String, Option<String>>);
78
79impl Metadata {
80    /// Parse the `Upload-Metadata` header value.
81    pub fn parse(header: &str) -> Result<Self, TusError> {
82        let mut map = HashMap::new();
83        for pair in header.split(',') {
84            let pair = pair.trim();
85            if pair.is_empty() {
86                continue;
87            }
88            let mut parts = pair.splitn(2, ' ');
89            let key = parts.next().unwrap_or("").trim().to_string();
90            if key.is_empty() {
91                return Err(TusError::InvalidMetadata("empty key in metadata".into()));
92            }
93            let value = match parts.next() {
94                None | Some("") => None,
95                Some(b64) => {
96                    let decoded = STANDARD
97                        .decode(b64.trim())
98                        .map_err(|e| TusError::InvalidMetadata(e.to_string()))?;
99                    Some(String::from_utf8(decoded).map_err(|e| {
100                        TusError::InvalidMetadata(format!("non-UTF8 value for key {key}: {e}"))
101                    })?)
102                }
103            };
104            map.insert(key, value);
105        }
106        Ok(Self(map))
107    }
108
109    /// Encode back to the `Upload-Metadata` wire format.
110    pub fn encode(&self) -> String {
111        self.0
112            .iter()
113            .map(|(k, v)| match v {
114                None => k.clone(),
115                Some(val) => format!("{k} {}", STANDARD.encode(val)),
116            })
117            .collect::<Vec<_>>()
118            .join(",")
119    }
120
121    pub fn get(&self, key: &str) -> Option<&Option<String>> {
122        self.0.get(key)
123    }
124
125    pub fn is_empty(&self) -> bool {
126        self.0.is_empty()
127    }
128}
129
130/// Complete state of a single upload resource.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct UploadInfo {
133    pub id: UploadId,
134    /// Declared total size in bytes. `None` when `Upload-Defer-Length: 1` was used.
135    pub size: Option<u64>,
136    /// Bytes successfully written so far.
137    pub offset: u64,
138    pub metadata: Metadata,
139    /// True when created with `Upload-Defer-Length: 1` and size not yet set.
140    pub size_is_deferred: bool,
141    /// Set by the expiration extension.
142    pub expires_at: Option<DateTime<Utc>>,
143    /// True for partial uploads (concatenation extension).
144    pub is_partial: bool,
145    /// True for final assembled uploads (concatenation extension).
146    pub is_final: bool,
147    /// IDs of partial uploads used to build this final upload.
148    pub partial_uploads: Vec<UploadId>,
149    /// Opaque storage-backend-specific metadata (e.g. file path, S3 key).
150    pub storage: HashMap<String, String>,
151}
152
153impl UploadInfo {
154    pub fn new(id: UploadId, size: Option<u64>) -> Self {
155        Self {
156            id,
157            size,
158            offset: 0,
159            metadata: Metadata::default(),
160            size_is_deferred: size.is_none(),
161            expires_at: None,
162            is_partial: false,
163            is_final: false,
164            partial_uploads: Vec::new(),
165            storage: HashMap::new(),
166        }
167    }
168
169    /// Returns true when all bytes have been received.
170    pub fn is_complete(&self) -> bool {
171        match self.size {
172            Some(s) => self.offset == s,
173            None => false,
174        }
175    }
176}
177
178/// Fields a `pre_create` hook may override before the upload slot is created.
179#[derive(Debug, Default)]
180pub struct UploadInfoChanges {
181    pub id: Option<UploadId>,
182    pub metadata: Option<Metadata>,
183    pub storage: Option<HashMap<String, String>>,
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn metadata_parse_key_value() {
192        let m = Metadata::parse("filename dGVzdC50eHQ=,type dGV4dC9wbGFpbg==").unwrap();
193        assert_eq!(m.0["filename"], Some("test.txt".into()));
194        assert_eq!(m.0["type"], Some("text/plain".into()));
195    }
196
197    #[test]
198    fn metadata_parse_key_only() {
199        let m = Metadata::parse("is_private").unwrap();
200        assert_eq!(m.0["is_private"], None);
201    }
202
203    #[test]
204    fn metadata_parse_mixed() {
205        let m = Metadata::parse("filename dGVzdC50eHQ=,is_private,size MTAyNA==").unwrap();
206        assert_eq!(m.0["filename"], Some("test.txt".into()));
207        assert_eq!(m.0["is_private"], None);
208        assert_eq!(m.0["size"], Some("1024".into()));
209    }
210
211    #[test]
212    fn metadata_parse_empty_string() {
213        let m = Metadata::parse("").unwrap();
214        assert!(m.0.is_empty());
215    }
216
217    #[test]
218    fn metadata_parse_invalid_base64() {
219        assert!(Metadata::parse("key not!!valid_b64").is_err());
220    }
221
222    #[test]
223    fn metadata_roundtrip() {
224        let original = "filename dGVzdC50eHQ=";
225        let m = Metadata::parse(original).unwrap();
226        let encoded = m.encode();
227        let m2 = Metadata::parse(&encoded).unwrap();
228        assert_eq!(m.0["filename"], m2.0["filename"]);
229    }
230
231    #[test]
232    fn upload_info_is_complete() {
233        let mut info = UploadInfo::new(UploadId::new(), Some(100));
234        assert!(!info.is_complete());
235        info.offset = 100;
236        assert!(info.is_complete());
237    }
238
239    #[test]
240    fn upload_info_deferred_never_complete_until_size_set() {
241        let info = UploadInfo::new(UploadId::new(), None);
242        assert!(!info.is_complete());
243    }
244}