Skip to main content

libfw_client/native/
upload.rs

1//! Native single-file upload: the tus-style *session* protocol with
2//! probe-based resume, pipelined chunks and zrip compression.
3//!
4//! Mirrors the browser engine's `upload.rs`: the server is the source of
5//! truth (a probe reports which byte ranges it already holds), only the
6//! missing blocks are re-sent, each block is a positional write into a shared
7//! per-session temp, and a final commit validates coverage before atomically
8//! renaming it into place.
9
10use std::path::Path;
11use std::time::UNIX_EPOCH;
12
13use futures::StreamExt;
14use libfw_core::metadata::{FileMeta, encode_file_meta_header};
15use libfw_core::{
16    HEADER_COMPRESS, HEADER_FILE_META, HEADER_FINAL, HEADER_OFFSET, HEADER_SESSION,
17    HEADER_SESSION_STATUS,
18};
19use reqwest::StatusCode;
20use serde::Deserialize;
21use tokio::io::{AsyncReadExt, AsyncSeekExt};
22
23use super::{NativeClient, NativeEvent, compress_chunk, session_id_for, validate_relative};
24use crate::LibfwError;
25use crate::plan::FileEntry;
26use crate::tune::{CompressLevel, LEVEL_SAMPLE_SIZE, TransferKind};
27use crate::upload::aligned_missing;
28
29/// File metadata for a local file being uploaded as `remote`.
30fn local_meta(local: &Path, remote: &str) -> Result<FileMeta, LibfwError> {
31    let meta = std::fs::metadata(local)
32        .map_err(|e| LibfwError::Storage(format!("stat {}: {e}", local.display())))?;
33    if !meta.is_file() {
34        return Err(LibfwError::Storage(format!(
35            "{} is not a regular file",
36            local.display()
37        )));
38    }
39    let mtime = meta
40        .modified()
41        .ok()
42        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
43        .map(|d| d.as_secs())
44        .unwrap_or(0);
45    Ok(FileMeta::new(remote, meta.len(), mtime))
46}
47
48/// Read `len` bytes at `start` from `path` (each concurrent block opens its
49/// own handle so reads never interleave).
50async fn read_slice(path: &Path, start: u64, len: u64) -> Result<Vec<u8>, LibfwError> {
51    let mut file = tokio::fs::File::open(path)
52        .await
53        .map_err(|e| LibfwError::Storage(format!("open {}: {e}", path.display())))?;
54    file.seek(std::io::SeekFrom::Start(start))
55        .await
56        .map_err(|e| LibfwError::Storage(format!("seek: {e}")))?;
57    let mut buf = vec![0u8; len as usize];
58    file.read_exact(&mut buf)
59        .await
60        .map_err(|e| LibfwError::Storage(format!("read {} bytes at {start}: {e}", len)))?;
61    Ok(buf)
62}
63
64impl NativeClient {
65    /// Upload `local` to the virtual path `remote`.
66    ///
67    /// Returns the payload bytes sent **by this run** (0 when the server
68    /// already held the whole file). Resumes automatically: a probe reports
69    /// the ranges the server still has, so an interrupted upload re-sends
70    /// only the gaps.
71    pub async fn upload_file(&self, local: &Path, remote: &str) -> Result<u64, LibfwError> {
72        validate_relative(remote)?;
73        // The upload path resolves its own zrip level below (`upload_level`,
74        // which may micro-benchmark `Auto`), so the preamble is needed here
75        // only to bootstrap the tuning engine for this direction.
76        let _ = self.prepare_transfer(TransferKind::Upload).await?;
77        let meta = local_meta(local, remote)?;
78        let entry = FileEntry {
79            path: remote.to_string(),
80            size: meta.size,
81            mtime: meta.mtime,
82        };
83        let session = session_id_for(&meta);
84        let compress = self.config().compress;
85
86        self.emit(NativeEvent::FileStart {
87            path: remote.to_string(),
88            size: meta.size,
89        });
90
91        // Resolve the zrip level: `Auto` micro-benchmarks a real sample of
92        // this file (once per session) against the advertised range.
93        let caps = self.capabilities().unwrap_or_default();
94        let sample = if compress && self.config().compress_level == CompressLevel::Auto {
95            let len = (meta.size).min(LEVEL_SAMPLE_SIZE as u64) as usize;
96            if len == 0 {
97                None
98            } else {
99                read_slice(local, 0, len as u64).await.ok()
100            }
101        } else {
102            None
103        };
104        let mbps = self.with_tune(|t| t.stats().mbps).unwrap_or(0.0);
105        let level = self.upload_level(&caps, sample.as_deref(), mbps);
106
107        let outcome = self
108            .upload_session(local, &entry, &meta, &session, compress, level)
109            .await;
110        self.finish_transfer(outcome.is_ok());
111
112        let sent = outcome?;
113        self.emit(NativeEvent::FileDone {
114            path: remote.to_string(),
115            bytes: sent,
116        });
117        Ok(sent)
118    }
119
120    /// Drive one file's session upload to completion.
121    async fn upload_session(
122        &self,
123        local: &Path,
124        entry: &FileEntry,
125        meta: &FileMeta,
126        session: &str,
127        compress: bool,
128        level: i32,
129    ) -> Result<u64, LibfwError> {
130        let size = meta.size;
131        let mut sent = 0u64;
132        let mut rounds = 0u32;
133        let mut first_error: Option<LibfwError> = None;
134
135        // Round 0 reuses the initial probe; later rounds re-probe so a lost
136        // ack is detected instead of blindly re-sent.
137        let mut received = self.probe_session(meta, session).await?;
138        let covered: u64 = received
139            .iter()
140            .map(|(s, e)| e.saturating_sub(*s))
141            .sum::<u64>()
142            .min(size);
143        if covered > 0 {
144            self.log(format!(
145                "upload `{}`: server already holds {covered} of {size} bytes; resuming",
146                entry.path
147            ));
148        }
149
150        loop {
151            if rounds > 0 {
152                received = self.probe_session(meta, session).await?;
153            }
154            let window = self
155                .with_tune(|t| t.params().upload_window.max(1))
156                .unwrap_or(self.config().upload_window.max(1));
157            let chunk_size = self
158                .with_tune(|t| t.params().chunk_size.max(1))
159                .unwrap_or(self.config().chunk_size.max(1));
160            let missing = aligned_missing(entry, chunk_size, &received);
161
162            // Everything the server needs is present → commit (this is also
163            // the converged-retry path).
164            if missing.is_empty() {
165                match self.commit(meta, session).await {
166                    Ok(()) => {
167                        // The success path skips the end-of-loop tick, so feed
168                        // the engine here: an upload must contribute a
169                        // measurement or the ramp never leaves the minimums.
170                        self.tune_tick(sent, None, false);
171                        return Ok(sent);
172                    }
173                    Err(e) => {
174                        if rounds >= self.config().max_retries {
175                            return Err(e);
176                        }
177                        rounds += 1;
178                        self.log(format!(
179                            "commit failed for `{}`; re-verifying server state: {e}",
180                            entry.path
181                        ));
182                        first_error.get_or_insert(e);
183                        continue;
184                    }
185                }
186            }
187
188            // Send only the missing blocks, pipelined up to `window`.
189            let local_owned = local.to_path_buf();
190            let meta_owned = meta.clone();
191            let session_owned = session.to_string();
192            let mut stream = futures::stream::iter(missing.into_iter().map(|(start, end)| {
193                let local = local_owned.clone();
194                let meta = meta_owned.clone();
195                let session = session_owned.clone();
196                async move { (start, self.post_block(&local, &meta, &session, start, end, compress, level).await) }
197            }))
198            .buffer_unordered(window);
199
200            let mut round_errors = 0u32;
201            while let Some((start, result)) = stream.next().await {
202                match result {
203                    Ok(sent_len) => {
204                        sent = sent.saturating_add(sent_len);
205                        self.emit(NativeEvent::Progress {
206                            done: sent,
207                            total: size,
208                        });
209                        self.tune_tick(sent, None, false);
210                    }
211                    Err(e) => {
212                        round_errors += 1;
213                        self.log(format!("block at {start} of `{}` failed: {e}", entry.path));
214                        first_error.get_or_insert(e);
215                    }
216                }
217            }
218
219            // No-ack happy path: trust the 201 acks and commit; a rejection
220            // re-probes and refills on the next round.
221            match self.commit(meta, session).await {
222                Ok(()) => {
223                    self.tune_tick(sent, None, round_errors > 0);
224                    return Ok(sent);
225                }
226                Err(e) => {
227                    rounds += 1;
228                    if rounds > self.config().max_retries {
229                        // One last probe decides: converged (commit once more)
230                        // or truly stuck (surface the first error).
231                        let received = self.probe_session(meta, session).await?;
232                        if aligned_missing(entry, chunk_size, &received).is_empty() {
233                            self.commit(meta, session).await?;
234                            return Ok(sent);
235                        }
236                        return Err(first_error.unwrap_or_else(|| {
237                            LibfwError::Protocol(format!(
238                                "upload of `{}` did not converge after {rounds} rounds",
239                                entry.path
240                            ))
241                        }));
242                    }
243                    self.log(format!(
244                        "commit failed for `{}`; re-verifying server state: {e}",
245                        entry.path
246                    ));
247                    first_error.get_or_insert(e);
248                }
249            }
250
251            self.tune_tick(sent, None, round_errors > 0);
252        }
253    }
254
255    /// Probe the server for the ranges a session already holds.
256    async fn probe_session(
257        &self,
258        meta: &FileMeta,
259        session: &str,
260    ) -> Result<Vec<(u64, u64)>, LibfwError> {
261        let url = self.file_url(&meta.path);
262        let resp = self
263            .http()
264            .post(&url)
265            .headers(self.headers(false, None))
266            .header(HEADER_OFFSET, "0")
267            .header(HEADER_FILE_META, encode_file_meta_header(meta))
268            .header(HEADER_SESSION, session)
269            .header(HEADER_SESSION_STATUS, "1")
270            .send()
271            .await
272            .map_err(|e| LibfwError::Network(e.to_string()))?;
273        let status = resp.status().as_u16();
274        if status != 200 && status != 201 {
275            return Err(LibfwError::Http { status, url });
276        }
277        let body = resp
278            .bytes()
279            .await
280            .map_err(|e| LibfwError::Network(e.to_string()))?;
281        #[derive(Deserialize)]
282        struct Ranges {
283            #[serde(default)]
284            ranges: Vec<[u64; 2]>,
285        }
286        // An empty range list means "nothing received" — or a server that
287        // echoed the meta without a range list; both are safe, because a full
288        // re-send is idempotent under positional writes.
289        let parsed: Ranges = serde_json::from_slice(&body)
290            .map_err(|e| LibfwError::Protocol(format!("bad session-status JSON: {e}")))?;
291        Ok(parsed
292            .ranges
293            .into_iter()
294            .map(|[s, e]| (s, e.max(s)))
295            .collect())
296    }
297
298    /// POST one block (with retries), returning the payload bytes sent.
299    async fn post_block(
300        &self,
301        local: &Path,
302        meta: &FileMeta,
303        session: &str,
304        start: u64,
305        end: u64,
306        compress: bool,
307        level: i32,
308    ) -> Result<u64, LibfwError> {
309        let raw = read_slice(local, start, end - start).await?;
310        // Compress into many ~64 KiB frames so no single frame can exceed the
311        // server's per-frame cap, whatever `chunk_size` is.
312        let payload = if compress {
313            compress_chunk(&raw, level)?
314        } else {
315            raw
316        };
317        let len = payload.len() as u64;
318        let url = self.file_url(&meta.path);
319        let mut attempt = 0u32;
320        loop {
321            let mut request = self
322                .http()
323                .post(&url)
324                .headers(self.headers(false, None))
325                .header(HEADER_OFFSET, start.to_string())
326                .header(HEADER_FILE_META, encode_file_meta_header(meta))
327                .header(HEADER_SESSION, session)
328                .body(payload.clone());
329            if compress {
330                request = request.header(HEADER_COMPRESS, "zrip");
331            }
332            match request.send().await {
333                Ok(resp) if resp.status() == StatusCode::CREATED => return Ok(len),
334                Ok(resp) => {
335                    let status = resp.status().as_u16();
336                    let err = LibfwError::Http { status, url: url.clone() };
337                    if attempt >= self.config().max_retries {
338                        return Err(err);
339                    }
340                    attempt += 1;
341                    self.log(format!(
342                        "retrying block {start}..{end} of `{}` (attempt {attempt}): {err}",
343                        meta.path
344                    ));
345                    tokio::time::sleep(self.backoff(attempt)).await;
346                }
347                Err(e) => {
348                    if attempt >= self.config().max_retries {
349                        return Err(LibfwError::Network(e.to_string()));
350                    }
351                    attempt += 1;
352                    self.log(format!(
353                        "retrying block {start}..{end} of `{}` (attempt {attempt}): {e}",
354                        meta.path
355                    ));
356                    tokio::time::sleep(self.backoff(attempt)).await;
357                }
358            }
359        }
360    }
361
362    /// Commit the session (validates coverage, then renames into place).
363    async fn commit(&self, meta: &FileMeta, session: &str) -> Result<(), LibfwError> {
364        let url = self.file_url(&meta.path);
365        let resp = self
366            .http()
367            .post(&url)
368            .headers(self.headers(false, None))
369            .header(HEADER_OFFSET, meta.size.to_string())
370            .header(HEADER_FILE_META, encode_file_meta_header(meta))
371            .header(HEADER_SESSION, session)
372            .header(HEADER_FINAL, "1")
373            .send()
374            .await
375            .map_err(|e| LibfwError::Network(e.to_string()))?;
376        let status = resp.status();
377        if status == StatusCode::CREATED {
378            Ok(())
379        } else {
380            Err(LibfwError::Http {
381                status: status.as_u16(),
382                url,
383            })
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn local_meta_rejects_missing_files() {
394        let dir = tempfile::tempdir().unwrap();
395        let missing = dir.path().join("nope.bin");
396        assert!(local_meta(&missing, "nope.bin").is_err());
397    }
398
399    #[test]
400    fn local_meta_computes_a_stable_session_etag() {
401        let dir = tempfile::tempdir().unwrap();
402        let path = dir.path().join("f.bin");
403        std::fs::write(&path, b"hello").unwrap();
404        let meta = local_meta(&path, "f.bin").unwrap();
405        assert_eq!(meta.size, 5);
406        assert_eq!(meta.path, "f.bin");
407        // The session id is derived from size + mtime, so it is stable across
408        // runs (which is what makes an interrupted upload resumable).
409        let again = local_meta(&path, "f.bin").unwrap();
410        assert_eq!(session_id_for(&meta), session_id_for(&again));
411        assert!(!session_id_for(&meta).is_empty());
412    }
413
414    #[tokio::test]
415    async fn read_slice_reads_the_requested_window() {
416        let dir = tempfile::tempdir().unwrap();
417        let path = dir.path().join("f.bin");
418        std::fs::write(&path, b"0123456789").unwrap();
419        assert_eq!(read_slice(&path, 2, 4).await.unwrap(), b"2345");
420        // Reading past EOF is an error, not silent truncation.
421        assert!(read_slice(&path, 8, 4).await.is_err());
422    }
423}