Skip to main content

sofie_http/
uploads.rs

1use std::collections::HashSet;
2use std::fmt::{self, Display};
3use std::sync::{Arc, Mutex, OnceLock};
4use std::time::Duration;
5
6use bytes::Bytes;
7use http::request::Parts;
8use http::{Method, StatusCode};
9use hyper::body::{Body, Incoming};
10use serde::Deserialize;
11use serde_json::{Map, Value};
12use sofie_auth::SigningKeys;
13use sofie_core::{Error, Result, unpoisoned};
14use sofie_storage::{ContentType, Put, Storage, StorageKey};
15use tokio::time::Instant;
16
17use crate::Serving;
18use crate::answer::{self, Response};
19use crate::caller::Caller;
20use crate::deadline::{Deadline, Paused};
21use crate::multipart::{self, Multipart, PartHead, Stop};
22use crate::pipeline::{Asked, RequestBody, discard_rest, read_body, too_large};
23use crate::routes::BODY_LIMIT;
24use crate::target::Target;
25use crate::urlencoded::Encoding;
26
27/// Text fields are held in memory, files are not, so text keeps the default limit however large
28/// the route's; on a route taking files, so does a body that is not a form.
29const TEXT_LIMIT: usize = BODY_LIMIT;
30const PART_LIMIT: usize = 100;
31const FILENAME_LIMIT: usize = 255;
32const SEAL: &str = "seal";
33/// A sealed upload is read back within its own request, which ends within this.
34const SEAL_LIFETIME: Duration = Duration::from_mins(2);
35/// While a file arrives the request deadline pauses; this still bounds the whole form.
36const FORM_TIME: Duration = Duration::from_mins(10);
37
38tokio::task_local! {
39    /// What the handler's input took, as the input is read through serde, which carries nothing.
40    static TAKEN: Arc<Mutex<HashSet<StorageKey>>>;
41}
42
43/// A file received with the request, already in the app's [`Storage`]. Only the framework makes
44/// one, from a `multipart/form-data` body; a JSON body cannot name a stored file.
45#[derive(Clone, PartialEq, Eq)]
46pub struct Upload {
47    key: StorageKey,
48    filename: String,
49    content_type: ContentType,
50    size: u64,
51}
52
53impl Upload {
54    #[must_use]
55    pub fn key(&self) -> &StorageKey {
56        &self.key
57    }
58
59    /// The name it had on the sender's machine, without its folders or characters unsafe in a
60    /// file name; never a path in storage.
61    #[must_use]
62    pub fn filename(&self) -> &str {
63        &self.filename
64    }
65
66    /// As declared, and matching the file's first bytes for the types those tell apart.
67    #[must_use]
68    pub fn content_type(&self) -> &ContentType {
69        &self.content_type
70    }
71
72    #[must_use]
73    pub fn size(&self) -> u64 {
74        self.size
75    }
76
77    fn sealed(&self, expires: u64) -> String {
78        format!(
79            "{}\n{}\n{}\n{}\n{expires}",
80            self.key, self.content_type, self.size, self.filename
81        )
82    }
83
84    fn to_json(&self) -> Result<Value> {
85        self.sealed_json(seconds_now() + SEAL_LIFETIME.as_secs())
86    }
87
88    fn sealed_json(&self, expires: u64) -> Result<Value> {
89        let sealed = self.sealed(expires);
90        let seal = seal_keys()?.sign(sofie_auth::Signed::UploadSeal, sealed.as_bytes());
91        Ok(serde_json::json!({
92            "key": self.key,
93            "filename": self.filename,
94            "content_type": self.content_type,
95            "size": self.size,
96            "expires": expires,
97            SEAL: seal,
98        }))
99    }
100}
101
102impl fmt::Debug for Upload {
103    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104        formatter
105            .debug_struct("Upload")
106            .field("key", &self.key)
107            .field("content_type", &self.content_type)
108            .field("size", &self.size)
109            .finish_non_exhaustive()
110    }
111}
112
113#[derive(Deserialize)]
114#[serde(deny_unknown_fields)]
115struct Received {
116    key: StorageKey,
117    filename: String,
118    content_type: ContentType,
119    size: u64,
120    expires: u64,
121    seal: String,
122}
123
124impl<'de> Deserialize<'de> for Upload {
125    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
126        let not_received = || serde::de::Error::custom("is not a file sent with this form");
127        let received = Received::deserialize(deserializer).map_err(|_| not_received())?;
128        let upload = Self {
129            key: received.key,
130            filename: received.filename,
131            content_type: received.content_type,
132            size: received.size,
133        };
134        let keys = seal_keys().map_err(Error::into_deserialize_error)?;
135        let sealed = upload.sealed(received.expires);
136        if received.expires <= seconds_now()
137            || !keys.verifies(
138                sofie_auth::Signed::UploadSeal,
139                sealed.as_bytes(),
140                &received.seal,
141            )
142        {
143            return Err(not_received());
144        }
145        // Outside a request's handler, such as in a test, no stored file waits to be settled.
146        let _outside_a_request = TAKEN.try_with(|taken| {
147            unpoisoned::lock(taken).insert(upload.key.clone());
148        });
149        Ok(upload)
150    }
151}
152
153#[cfg(feature = "engine")]
154impl sofie_values::Present for Upload {
155    const INPUT: sofie_values::Input = sofie_values::Input::new(sofie_values::Control::File);
156    const SEARCH: sofie_values::Search = sofie_values::Search::Skipped;
157
158    fn csv(&self) -> String {
159        self.filename.clone()
160    }
161}
162
163pub(crate) fn is_upload(value: &Value) -> bool {
164    value.get(SEAL).is_some()
165}
166
167fn seconds_now() -> u64 {
168    std::time::SystemTime::now()
169        .duration_since(std::time::UNIX_EPOCH)
170        .map_or(0, |since| since.as_secs())
171}
172
173/// The process's own: an upload is sealed and opened within one request.
174fn seal_keys() -> Result<&'static SigningKeys> {
175    static KEYS: OnceLock<SigningKeys> = OnceLock::new();
176    if let Some(keys) = KEYS.get() {
177        return Ok(keys);
178    }
179    let generated = SigningKeys::generate()?;
180    Ok(KEYS.get_or_init(|| generated))
181}
182
183/// The files stored while reading a request, deleted unless its handler succeeds with an input
184/// that took them; a request cut by its timeout or a panic drops them unsettled, so they are
185/// deleted then too.
186#[derive(Default)]
187pub(crate) struct Stored {
188    storage: Option<Storage>,
189    keys: Vec<StorageKey>,
190    taken: Arc<Mutex<HashSet<StorageKey>>>,
191}
192
193impl Stored {
194    pub(crate) async fn taking<F: Future>(&self, handler: F) -> F::Output {
195        TAKEN.scope(Arc::clone(&self.taken), handler).await
196    }
197
198    pub(crate) async fn settle(mut self, status: StatusCode) {
199        let mut keys = std::mem::take(&mut self.keys);
200        if status.is_success() || status.is_redirection() {
201            let taken = unpoisoned::lock(&self.taken);
202            keys.retain(|key| !taken.contains(key));
203        }
204        if let Some(storage) = &self.storage {
205            discard(storage, &keys).await;
206        }
207    }
208}
209
210impl Drop for Stored {
211    fn drop(&mut self) {
212        let keys = std::mem::take(&mut self.keys);
213        if let Some(storage) = self.storage.take()
214            && !keys.is_empty()
215        {
216            tokio::spawn(async move { discard(&storage, &keys).await });
217        }
218    }
219}
220
221async fn discard(storage: &Storage, keys: &[StorageKey]) {
222    for key in keys {
223        if let Err(error) = storage.delete(key).await {
224            tracing::warn!(error.kind = "storage", error = %error, "deleting an unused upload failed");
225        }
226    }
227}
228
229/// A `multipart/form-data` body sent to an action becomes the JSON object of its fields, each file
230/// a sealed [`Upload`] stored as it arrives; any other body is read as before. A file sent to a
231/// route without `.file_limit(…)`, to a page or queue route, or to an app without storage, is
232/// refused: nothing would keep it. A webhook's body is always read as sent.
233pub(crate) async fn read(
234    asked: Asked<'_>,
235    target: &Target,
236    serving: &Serving,
237    mut body: Incoming,
238    limit: usize,
239) -> Result<RequestBody, Box<Response>> {
240    let (request, caller) = (asked.request, asked.caller);
241    let boundary = multipart::boundary(&request.headers).filter(|_| !is_webhook(target));
242    let Some(boundary) = boundary else {
243        let limit = match target.admission.rules.file_limit {
244            Some(_) => limit.min(TEXT_LIMIT),
245            None => limit,
246        };
247        let body = read_body(&mut body, limit, caller).await?;
248        return Ok(RequestBody::plain(body, &request.headers));
249    };
250    let refused = |error: Error| Box::new(answer::failure(error, caller));
251    let boundary = match boundary {
252        Ok(boundary) => boundary,
253        Err(stop) => return Err(refused_unread(body, malformed(&stop), caller).await),
254    };
255    let storage = serving
256        .storage
257        .as_ref()
258        .filter(|_| takes_files(request, target));
259    let file_limit = target.admission.rules.file_limit.unwrap_or(limit);
260    let mut form = Form::new(body, &boundary, limit, storage, file_limit, asked.deadline);
261    let read = form.read().await;
262    match read {
263        Ok(()) => {
264            let encoded = |fields: &Map<String, Value>| {
265                serde_json::to_vec(fields)
266                    .map(Bytes::from)
267                    .map_err(|error| refused(Error::internal("encoding a form's fields", error)))
268            };
269            Ok(RequestBody {
270                body: encoded(&form.fields)?,
271                #[cfg(feature = "idempotency")]
272                fingerprint: encoded(&as_sent(&form.fields))?,
273                encoding: Encoding::Json,
274                stored: form.stored,
275            })
276        }
277        Err(failure) => {
278            if matches!(failure, Failure::TakesNoFiles)
279                && serving.storage.is_none()
280                && target.admission.rules.file_limit.is_some()
281            {
282                tracing::warn!(
283                    error.kind = "storage",
284                    "a form sent a file, but the app has no storage; see `Serving::storage`"
285                );
286            }
287            form.stored.settle(StatusCode::BAD_REQUEST).await;
288            Err(Box::new(failure.answer(form.multipart, caller).await))
289        }
290    }
291}
292
293/// Each upload by what was sent of it, without where it was stored.
294#[cfg(feature = "idempotency")]
295fn as_sent(fields: &Map<String, Value>) -> Map<String, Value> {
296    let mut sent = fields.clone();
297    for value in sent.values_mut() {
298        if let Value::Object(upload) = value
299            && upload.contains_key(SEAL)
300        {
301            upload.retain(|name, _| matches!(name.as_str(), "filename" | "content_type" | "size"));
302        }
303    }
304    sent
305}
306
307/// A queued job reads its input in another process, which cannot open an upload's seal.
308/// A webhook's signature covers the body exactly as sent, so a multipart one is never parsed.
309fn is_webhook(target: &Target) -> bool {
310    #[cfg(feature = "webhooks")]
311    return target.admission.rules.verify.is_some();
312    #[cfg(not(feature = "webhooks"))]
313    {
314        let _ = target;
315        false
316    }
317}
318
319fn takes_files(request: &Parts, target: &Target) -> bool {
320    #[cfg(feature = "jobs")]
321    if target.admission.rules.queues {
322        return false;
323    }
324    target.admission.rules.file_limit.is_some()
325        && request.method != Method::GET
326        && request.method != Method::HEAD
327}
328
329async fn refused_unread(mut body: Incoming, error: Error, caller: Caller<'_>) -> Box<Response> {
330    discard_rest(&mut body).await;
331    Box::new(answer::failure(error, caller))
332}
333
334enum Failure {
335    Stopped(Stop),
336    Refused(Error),
337    /// A file sent where none is taken, or to an app without storage.
338    TakesNoFiles,
339    TooSlow,
340}
341
342impl From<Stop> for Failure {
343    fn from(stop: Stop) -> Self {
344        Self::Stopped(stop)
345    }
346}
347
348impl From<Error> for Failure {
349    fn from(error: Error) -> Self {
350        Self::Refused(error)
351    }
352}
353
354impl Failure {
355    /// The rest of the body is read first, as `read_body` does.
356    async fn answer(self, multipart: Multipart<Incoming>, caller: Caller<'_>) -> Response {
357        discard_rest(&mut multipart.into_body()).await;
358        match self {
359            Self::Stopped(Stop::TooLarge) => too_large(caller),
360            Self::Stopped(stop) => answer::failure(malformed(&stop), caller),
361            Self::Refused(error) => answer::failure(error, caller),
362            Self::TakesNoFiles => answer::refusal(
363                StatusCode::UNSUPPORTED_MEDIA_TYPE,
364                "This address does not take files.",
365                caller,
366            ),
367            Self::TooSlow => answer::refusal(
368                StatusCode::REQUEST_TIMEOUT,
369                "The form took too long to arrive.",
370                caller,
371            ),
372        }
373    }
374}
375
376fn malformed(stop: &Stop) -> Error {
377    let reason = match stop {
378        Stop::TooLarge => "it is too large",
379        Stop::Malformed(reason) => reason,
380        Stop::Unread(reason) => reason,
381    };
382    Error::BadRequest(format!("the form cannot be read: {reason}"))
383}
384
385struct Form<'s, B = Incoming> {
386    multipart: Multipart<B>,
387    storage: Option<&'s Storage>,
388    file_limit: usize,
389    fields: Map<String, Value>,
390    stored: Stored,
391    deadline: &'s Deadline,
392    paused: Option<Paused<'s>>,
393    ends: Instant,
394}
395
396impl<'s, B: Body<Data = Bytes> + Unpin> Form<'s, B>
397where
398    B::Error: Display,
399{
400    fn new(
401        body: B,
402        boundary: &str,
403        limit: usize,
404        storage: Option<&'s Storage>,
405        file_limit: usize,
406        deadline: &'s Deadline,
407    ) -> Self {
408        Self {
409            multipart: Multipart::new(body, boundary, limit),
410            storage,
411            file_limit,
412            fields: Map::new(),
413            stored: Stored {
414                storage: storage.cloned(),
415                keys: Vec::new(),
416                taken: Arc::default(),
417            },
418            deadline,
419            paused: None,
420            ends: Instant::now() + FORM_TIME,
421        }
422    }
423
424    async fn read(&mut self) -> Result<(), Failure> {
425        let read = self.parts().await;
426        self.paused = None;
427        read
428    }
429
430    async fn next_part(&mut self) -> Result<Option<PartHead>, Failure> {
431        within(self.ends, self.multipart.next_part()).await
432    }
433
434    async fn next_chunk(&mut self) -> Result<Option<Bytes>, Failure> {
435        within(self.ends, self.multipart.next_chunk()).await
436    }
437
438    async fn parts(&mut self) -> Result<(), Failure> {
439        let mut parts = 0;
440        let mut text = 0;
441        while let Some(head) = self.next_part().await? {
442            parts += 1;
443            if parts > PART_LIMIT {
444                return Err(Error::BadRequest(format!(
445                    "the form has more than {PART_LIMIT} fields"
446                ))
447                .into());
448            }
449            match head.filename {
450                Some(_) => self.file(head).await?,
451                None => text += self.text(&head.name, TEXT_LIMIT - text).await?,
452            }
453        }
454        Ok(())
455    }
456
457    async fn text(&mut self, name: &str, room: usize) -> Result<usize, Failure> {
458        let mut value = Vec::new();
459        while let Some(chunk) = self.next_chunk().await? {
460            if value.len() + chunk.len() > room {
461                return Err(Stop::TooLarge.into());
462            }
463            value.extend_from_slice(&chunk);
464        }
465        let length = value.len();
466        let value = String::from_utf8(value)
467            .map_err(|_| Error::BadRequest("the form is not UTF-8".to_owned()))?;
468        if !value.is_empty() {
469            crate::form::insert(&mut self.fields, name, value)?;
470        }
471        Ok(length)
472    }
473
474    async fn file(&mut self, head: PartHead) -> Result<(), Failure> {
475        let name = head.name;
476        if !sofie_core::is_field_name(&name) || self.fields.contains_key(&name) {
477            return Err(Error::BadRequest(format!(
478                "the form's file field `{name}` is not a plain field name or is sent twice"
479            ))
480            .into());
481        }
482        if self.storage.is_some() && self.paused.is_none() {
483            self.paused = Some(self.deadline.paused());
484        }
485        let Some(first) = self.next_chunk().await? else {
486            return Ok(());
487        };
488        let declared = head
489            .content_type
490            .as_deref()
491            .map_or_else(|| Ok(ContentType::octet_stream()), ContentType::parse)
492            .map_err(|_| Error::invalid(&name, "does not declare a media type"))?;
493        let Some(storage) = self.storage else {
494            return Err(Failure::TakesNoFiles);
495        };
496        let mut put = storage.put().await?;
497        let written = self.write(&mut put, first, &declared, &name).await;
498        match written {
499            Ok(()) => {
500                let size = put.size();
501                let key = put.finish().await?;
502                self.stored.keys.push(key.clone());
503                let upload = Upload {
504                    key,
505                    filename: sanitized(head.filename.as_deref().unwrap_or_default()),
506                    content_type: declared,
507                    size,
508                };
509                self.fields.insert(name, upload.to_json()?);
510                Ok(())
511            }
512            Err(failure) => {
513                if let Err(error) = put.abort().await {
514                    tracing::warn!(error.kind = "storage", error = %error, "discarding a refused upload failed");
515                }
516                Err(failure)
517            }
518        }
519    }
520
521    async fn write(
522        &mut self,
523        put: &mut Put,
524        first: Bytes,
525        declared: &ContentType,
526        name: &str,
527    ) -> Result<(), Failure> {
528        let mut start = Vec::with_capacity(ContentType::SNIFFED_LENGTH);
529        let mut chunk = Some(first);
530        while let Some(data) = chunk {
531            let size = put.size() + u64::try_from(data.len()).unwrap_or(u64::MAX);
532            if size > u64::try_from(self.file_limit).unwrap_or(u64::MAX) {
533                return Err(Error::invalid(
534                    name,
535                    format!("must be at most {} bytes", self.file_limit),
536                )
537                .into());
538            }
539            let missing = ContentType::SNIFFED_LENGTH.saturating_sub(start.len());
540            start.extend_from_slice(&data[..missing.min(data.len())]);
541            put.write(data).await?;
542            chunk = self.next_chunk().await?;
543        }
544        if !declared.matches(&start) {
545            return Err(
546                Error::invalid(name, format!("is not the {declared} it claims to be")).into(),
547            );
548        }
549        Ok(())
550    }
551}
552
553async fn within<T>(
554    ends: Instant,
555    reading: impl Future<Output = Result<T, Stop>>,
556) -> Result<T, Failure> {
557    let read = tokio::time::timeout_at(ends, reading).await;
558    read.map_err(|_| Failure::TooSlow)?.map_err(Failure::from)
559}
560
561/// The last segment of what the sender named it, without characters a file system or a header
562/// would misread.
563fn sanitized(filename: &str) -> String {
564    let base = filename.rsplit(['/', '\\']).next().unwrap_or_default();
565    let kept: String = base
566        .chars()
567        .filter(|character| {
568            !sofie_core::misleads_display(*character) && !"\"<>:|?*".contains(*character)
569        })
570        .collect();
571    let trimmed =
572        kept.trim_matches(|character: char| character == '.' || character.is_whitespace());
573    let mut end = trimmed.len().min(FILENAME_LIMIT);
574    while !trimmed.is_char_boundary(end) {
575        end -= 1;
576    }
577    match &trimmed[..end] {
578        "" => "file".to_owned(),
579        kept => kept.to_owned(),
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use std::convert::Infallible;
586    use std::pin::Pin;
587    use std::task::{Context, Poll};
588
589    use hyper::body::{Body, Frame};
590    use tokio::sync::mpsc;
591    use tokio::time::sleep;
592
593    use super::*;
594
595    const BOUNDARY: &str = "trickle";
596
597    struct Trickle(mpsc::Receiver<Bytes>);
598
599    impl Body for Trickle {
600        type Data = Bytes;
601        type Error = Infallible;
602
603        fn poll_frame(
604            mut self: Pin<&mut Self>,
605            context: &mut Context<'_>,
606        ) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
607            self.0
608                .poll_recv(context)
609                .map(|chunk| chunk.map(|bytes| Ok(Frame::data(bytes))))
610        }
611    }
612
613    /// A part whose value arrives a byte every `pause`, `bytes` times.
614    fn trickled(head: &'static str, bytes: usize, pause: Duration) -> Trickle {
615        let (sender, receiver) = mpsc::channel(1);
616        tokio::spawn(async move {
617            let opening = format!("--{BOUNDARY}\r\n{head}\r\n\r\n");
618            sender.send(Bytes::from(opening)).await.unwrap();
619            for _ in 0..bytes {
620                sleep(pause).await;
621                let _closed = sender.send(Bytes::from_static(b"x")).await;
622            }
623            let closing = format!("\r\n--{BOUNDARY}--\r\n");
624            let _closed = sender.send(Bytes::from(closing)).await;
625        });
626        Trickle(receiver)
627    }
628
629    async fn read_within_deadline(body: Trickle) -> Option<Result<(), Failure>> {
630        let directory = std::env::temp_dir().join(format!(
631            "sofie-http-trickle-{}",
632            StorageKey::generate().unwrap()
633        ));
634        let keys = Arc::new(SigningKeys::generate().unwrap());
635        let storage = Storage::new(sofie_storage::Local::new(&directory, keys).unwrap());
636        let deadline = Deadline::default();
637        let mut form = Form::new(body, BOUNDARY, 1024, Some(&storage), 1024, &deadline);
638        let read = deadline.within(form.read()).await;
639        drop(form);
640        std::fs::remove_dir_all(&directory).unwrap();
641        read
642    }
643
644    #[tokio::test(start_paused = true)]
645    async fn only_a_file_part_pauses_the_deadline_and_then_for_ten_minutes_at_most() {
646        let text = r#"Content-Disposition: form-data; name="title""#;
647        let file = r#"Content-Disposition: form-data; name="photo"; filename="a.bin""#;
648        let under_the_silence_timeout = Duration::from_secs(50);
649
650        let slow_text = read_within_deadline(trickled(text, 2, under_the_silence_timeout)).await;
651        let slow_file = read_within_deadline(trickled(file, 4, under_the_silence_timeout)).await;
652        let endless_file =
653            read_within_deadline(trickled(file, 15, under_the_silence_timeout)).await;
654
655        assert!(slow_text.is_none());
656        assert!(matches!(slow_file, Some(Ok(()))));
657        assert!(matches!(endless_file, Some(Err(Failure::TooSlow))));
658    }
659
660    #[test]
661    fn a_filename_keeps_only_its_last_safe_segment() {
662        for (sent, kept) in [
663            ("photo.png", "photo.png"),
664            ("C:\\Users\\ada\\photo.png", "photo.png"),
665            ("../../etc/passwd", "passwd"),
666            ("..", "file"),
667            ("", "file"),
668            (" .hidden. ", "hidden"),
669            ("a\"b<c>|?*\u{0}\n.txt", "abc.txt"),
670            ("Jürgen's résumé.pdf", "Jürgen's résumé.pdf"),
671        ] {
672            assert_eq!(sanitized(sent), kept, "{sent:?}");
673        }
674        let long = "é".repeat(200);
675        assert_eq!(sanitized(&long).len(), 254);
676    }
677
678    fn upload() -> Upload {
679        Upload {
680            key: StorageKey::generate().unwrap(),
681            filename: "photo.png".to_owned(),
682            content_type: ContentType::parse("image/png").unwrap(),
683            size: 12,
684        }
685    }
686
687    #[test]
688    fn only_an_upload_this_process_sealed_is_read_back() {
689        let upload = upload();
690        let json = upload.to_json().unwrap();
691
692        assert_eq!(
693            serde_json::from_value::<Upload>(json.clone()).unwrap(),
694            upload
695        );
696        for (field, forged) in [
697            ("key", Value::from(StorageKey::generate().unwrap().as_str())),
698            ("size", Value::from(13)),
699            ("expires", Value::from(u64::MAX)),
700            ("content_type", Value::from("text/html")),
701            ("filename", Value::from("other.png")),
702            (SEAL, Value::from("AAAA")),
703        ] {
704            let mut forged_json = json.clone();
705            forged_json[field] = forged;
706            assert!(
707                serde_json::from_value::<Upload>(forged_json).is_err(),
708                "{field}"
709            );
710        }
711        assert!(serde_json::from_value::<Upload>(Value::from("photo.png")).is_err());
712        let expired = upload.sealed_json(seconds_now() - 1).unwrap();
713        assert!(serde_json::from_value::<Upload>(expired).is_err());
714    }
715}