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