Skip to main content

libfw_core/
metadata.rs

1//! Transfer metadata: file info, chunk plans and ETags.
2//!
3//! These structures are serialized to JSON and exchanged through
4//! [`HEADER_FILE_META`](crate::HEADER_FILE_META) and the transfer
5//! manifest so that server and client agree on chunk boundaries and can
6//! validate resume offsets.
7
8use serde::{Deserialize, Serialize};
9
10use crate::CHUNK_SIZE;
11
12/// Metadata about a single file involved in a transfer.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct FileMeta {
15    /// Relative path (POSIX separators), e.g. `dir/sub/file.txt`.
16    pub path: String,
17    /// Size in bytes.
18    pub size: u64,
19    /// Last-modified time as unix seconds.
20    #[serde(default)]
21    pub mtime: u64,
22    /// Stable identifier for content — see [`etag_from_size_mtime`].
23    #[serde(default)]
24    pub etag: String,
25}
26
27impl FileMeta {
28    /// Constructs a `FileMeta` and computes the ETag from size + mtime.
29    pub fn new(path: impl Into<String>, size: u64, mtime: u64) -> Self {
30        let path = path.into();
31        let etag = etag_from_size_mtime(size, mtime);
32        FileMeta {
33            path,
34            size,
35            mtime,
36            etag,
37        }
38    }
39}
40
41/// A contiguous byte range of a file: `[start, end)`.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub struct ChunkRange {
44    /// First byte (inclusive).
45    pub start: u64,
46    /// One past the last byte (exclusive).
47    pub end: u64,
48}
49
50impl ChunkRange {
51    /// Number of bytes in this range.
52    pub fn len(&self) -> u64 {
53        self.end.saturating_sub(self.start)
54    }
55
56    /// True when the range covers zero bytes.
57    pub fn is_empty(&self) -> bool {
58        self.len() == 0
59    }
60}
61
62/// One chunk of a file transfer.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ChunkMeta {
65    /// Zero-based chunk index.
66    pub index: u32,
67    /// Byte offset of this chunk within the file.
68    pub offset: u64,
69    /// Byte length of this chunk.
70    pub size: u64,
71}
72
73/// The full transfer plan for one file: chunk layout derived from size.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct TransferPlan {
76    /// The file being transferred.
77    pub file: FileMeta,
78    /// Chunk size used to slice the file.
79    pub chunk_size: u64,
80    /// All chunks, in order.
81    pub chunks: Vec<ChunkMeta>,
82}
83
84impl TransferPlan {
85    /// Build a plan for `file` using the protocol default
86    /// [`CHUNK_SIZE`](crate::CHUNK_SIZE).
87    pub fn new(file: FileMeta) -> Self {
88        TransferPlan::with_chunk_size(file, CHUNK_SIZE)
89    }
90
91    /// Build a plan for `file` with an explicit chunk size.
92    ///
93    /// The last chunk may be shorter than `chunk_size`.
94    pub fn with_chunk_size(file: FileMeta, chunk_size: u64) -> Self {
95        debug_assert!(chunk_size > 0);
96        let mut chunks = Vec::new();
97        let mut offset = 0u64;
98        while offset < file.size {
99            let len = chunk_size.min(file.size - offset);
100            chunks.push(ChunkMeta {
101                index: chunks.len() as u32,
102                offset,
103                size: len,
104            });
105            offset += len;
106        }
107        if file.size == 0 {
108            chunks.push(ChunkMeta {
109                index: 0,
110                offset: 0,
111                size: 0,
112            });
113        }
114        TransferPlan {
115            file,
116            chunk_size,
117            chunks,
118        }
119    }
120
121    /// The total number of bytes covered by the plan.
122    pub fn total_bytes(&self) -> u64 {
123        self.chunks.iter().map(|c| c.size).sum()
124    }
125}
126
127/// Compute a deterministic, content-independent ETag from size + mtime.
128///
129/// This is a *strong* ETag in the sense of being stable for a given file
130/// version, but it does not read the file contents; two files with equal
131/// size and mtime collide (acceptable for resume-validation purposes).
132pub fn etag_from_size_mtime(size: u64, mtime: u64) -> String {
133    use sha2::{Digest, Sha256};
134    let mut h = Sha256::new();
135    h.update(size.to_le_bytes());
136    h.update(mtime.to_le_bytes());
137    let digest = h.finalize();
138    format!("\"{}\"", hex(&digest[..8]))
139}
140
141/// Hex-encode a byte slice (lowercase, no prefix).
142pub fn hex(bytes: &[u8]) -> String {
143    let mut s = String::with_capacity(bytes.len() * 2);
144    for b in bytes {
145        use std::fmt::Write;
146        let _ = write!(s, "{b:02x}");
147    }
148    s
149}
150
151/// Serialize [`FileMeta`] for the [`HEADER_FILE_META`] header.
152pub fn encode_file_meta(meta: &FileMeta) -> String {
153    serde_json::to_string(meta).expect("FileMeta serializes")
154}
155
156/// Parse [`FileMeta`] from the [`HEADER_FILE_META`] header.
157pub fn decode_file_meta(header: &str) -> Result<FileMeta, serde_json::Error> {
158    serde_json::from_str(header)
159}
160
161/// Base64 alphabet (RFC 4648 §4, standard).
162const B64_ALPHABET: &[u8; 64] =
163    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
164
165/// Base64-encode `input` (standard alphabet, `=` padding).
166pub fn base64_encode(input: &[u8]) -> String {
167    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
168    for chunk in input.chunks(3) {
169        let b0 = chunk[0] as u32;
170        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
171        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
172        let n = (b0 << 16) | (b1 << 8) | b2;
173        out.push(B64_ALPHABET[(n >> 18) as usize & 63] as char);
174        out.push(B64_ALPHABET[(n >> 12) as usize & 63] as char);
175        out.push(if chunk.len() > 1 {
176            B64_ALPHABET[(n >> 6) as usize & 63] as char
177        } else {
178            '='
179        });
180        out.push(if chunk.len() > 2 {
181            B64_ALPHABET[n as usize & 63] as char
182        } else {
183            '='
184        });
185    }
186    out
187}
188
189/// Base64-decode `input` (standard alphabet, `=`/whitespace tolerated).
190pub fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
191    let mut out = Vec::with_capacity(input.len().div_ceil(4) * 3);
192    let mut buf = 0u32;
193    let mut bits = 0u32;
194    for c in input.chars() {
195        if c == '=' || c.is_whitespace() {
196            continue;
197        }
198        let val = match c {
199            'A'..='Z' => c as u32 - 'A' as u32,
200            'a'..='z' => c as u32 - 'a' as u32 + 26,
201            '0'..='9' => c as u32 - '0' as u32 + 52,
202            '+' => 62,
203            '/' => 63,
204            _ => return Err(format!("invalid base64 character: {c:?}")),
205        };
206        buf = (buf << 6) | val;
207        bits += 6;
208        if bits >= 8 {
209            bits -= 8;
210            out.push((buf >> bits) as u8);
211        }
212    }
213    Ok(out)
214}
215
216/// Encode [`FileMeta`] for the [`HEADER_FILE_META`] header.
217///
218/// The raw JSON is base64-encoded so that non-Latin-1 virtual paths (e.g.
219/// CJK filenames) survive HTTP header transport — browsers reject header
220/// values containing characters outside ISO-8859-1.
221pub fn encode_file_meta_header(meta: &FileMeta) -> String {
222    base64_encode(encode_file_meta(meta).as_bytes())
223}
224
225/// Decode [`FileMeta`] from the [`HEADER_FILE_META`] header.
226pub fn decode_file_meta_header(header: &str) -> Result<FileMeta, serde_json::Error> {
227    let raw = base64_decode(header)
228        .map_err(|e| serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
229    decode_file_meta(&String::from_utf8_lossy(&raw))
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn plan_chunks_evenly() {
238        let meta = FileMeta::new("/a/b.bin", 10, 0);
239        let plan = TransferPlan::with_chunk_size(meta, 4);
240        assert_eq!(plan.chunks.len(), 3);
241        assert_eq!(
242            plan.chunks.iter().map(|c| c.offset).collect::<Vec<_>>(),
243            vec![0, 4, 8]
244        );
245        assert_eq!(
246            plan.chunks.iter().map(|c| c.size).collect::<Vec<_>>(),
247            vec![4, 4, 2]
248        );
249        assert_eq!(plan.total_bytes(), 10);
250    }
251
252    #[test]
253    fn plan_exact_multiple() {
254        let meta = FileMeta::new("/f", 8, 0);
255        let plan = TransferPlan::with_chunk_size(meta, 4);
256        assert_eq!(plan.chunks.len(), 2);
257        assert_eq!(plan.total_bytes(), 8);
258    }
259
260    #[test]
261    fn plan_empty_file_has_one_zero_chunk() {
262        let meta = FileMeta::new("/empty", 0, 0);
263        let plan = TransferPlan::new(meta);
264        assert_eq!(plan.chunks.len(), 1);
265        assert_eq!(plan.chunks[0].size, 0);
266    }
267
268    #[test]
269    fn etag_is_stable_and_quoted() {
270        let a = etag_from_size_mtime(100, 1_700_000_000);
271        let b = etag_from_size_mtime(100, 1_700_000_000);
272        let c = etag_from_size_mtime(101, 1_700_000_000);
273        assert_eq!(a, b);
274        assert_ne!(a, c);
275        assert!(a.starts_with('"') && a.ends_with('"'));
276    }
277
278    #[test]
279    fn file_meta_json_roundtrip() {
280        let meta = FileMeta::new("dir/file.txt", 1234, 42);
281        let encoded = encode_file_meta(&meta);
282        assert!(encoded.contains("etag"));
283        let decoded = decode_file_meta(&encoded).unwrap();
284        assert_eq!(decoded, meta);
285    }
286
287    #[test]
288    fn base64_roundtrip_arbitrary_bytes() {
289        for bytes in [
290            b"".to_vec(),
291            b"f".to_vec(),
292            b"fo".to_vec(),
293            b"foo".to_vec(),
294            b"foob".to_vec(),
295            vec![0u8, 1, 2, 3, 255, 128, 64],
296            "中文文件名🚀".as_bytes().to_vec(),
297        ] {
298            let enc = base64_encode(&bytes);
299            assert_eq!(base64_decode(&enc).unwrap(), bytes, "for {bytes:?}");
300        }
301    }
302
303    #[test]
304    fn base64_encode_known_vectors() {
305        assert_eq!(base64_encode(b""), "");
306        assert_eq!(base64_encode(b"f"), "Zg==");
307        assert_eq!(base64_encode(b"fo"), "Zm8=");
308        assert_eq!(base64_encode(b"foo"), "Zm9v");
309        assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
310    }
311
312    #[test]
313    fn file_meta_header_roundtrip_with_unicode_path() {
314        // A CJK filename must survive the header-encoding roundtrip.
315        let meta = FileMeta::new("目录/报告.txt", 999, 7);
316        let encoded = encode_file_meta_header(&meta);
317        // The base64 form is pure ASCII — safe as an HTTP header value.
318        assert!(encoded.is_ascii());
319        let decoded = decode_file_meta_header(&encoded).unwrap();
320        assert_eq!(decoded, meta);
321    }
322
323    #[test]
324    fn decode_file_meta_header_rejects_garbage() {
325        assert!(decode_file_meta_header("!!!not-base64!!!").is_err());
326    }
327}