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`] (bytes)
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        // `chunk_size == 0` would loop forever below (`len` never advances
96        // `offset`). A `debug_assert!` is compiled out of release builds, so
97        // this must be a runtime check — misuse fails loudly instead of
98        // hanging the caller.
99        assert!(chunk_size > 0, "chunk_size must be > 0");
100        let mut chunks = Vec::new();
101        let mut offset = 0u64;
102        while offset < file.size {
103            let len = chunk_size.min(file.size - offset);
104            chunks.push(ChunkMeta {
105                index: chunks.len() as u32,
106                offset,
107                size: len,
108            });
109            offset += len;
110        }
111        if file.size == 0 {
112            chunks.push(ChunkMeta {
113                index: 0,
114                offset: 0,
115                size: 0,
116            });
117        }
118        TransferPlan {
119            file,
120            chunk_size,
121            chunks,
122        }
123    }
124
125    /// The total number of bytes covered by the plan.
126    pub fn total_bytes(&self) -> u64 {
127        self.chunks.iter().map(|c| c.size).sum()
128    }
129}
130
131/// Compute a deterministic, content-independent ETag from size + mtime.
132///
133/// This is a *strong* ETag in the sense of being stable for a given file
134/// version, but it does not read the file contents; two files with equal
135/// size and mtime collide (acceptable for resume-validation purposes).
136pub fn etag_from_size_mtime(size: u64, mtime: u64) -> String {
137    use sha2::{Digest, Sha256};
138    let mut h = Sha256::new();
139    h.update(size.to_le_bytes());
140    h.update(mtime.to_le_bytes());
141    let digest = h.finalize();
142    format!("\"{}\"", hex(&digest[..8]))
143}
144
145/// Hex-encode a byte slice (lowercase, no prefix).
146pub fn hex(bytes: &[u8]) -> String {
147    let mut s = String::with_capacity(bytes.len() * 2);
148    for b in bytes {
149        use std::fmt::Write;
150        let _ = write!(s, "{b:02x}");
151    }
152    s
153}
154
155/// Serialize [`FileMeta`] for the [`crate::HEADER_FILE_META`] header.
156pub fn encode_file_meta(meta: &FileMeta) -> String {
157    serde_json::to_string(meta).expect("FileMeta serializes")
158}
159
160/// Parse [`FileMeta`] from the [`crate::HEADER_FILE_META`] header.
161pub fn decode_file_meta(header: &str) -> Result<FileMeta, serde_json::Error> {
162    serde_json::from_str(header)
163}
164
165/// Base64 alphabet (RFC 4648 §4, standard).
166const B64_ALPHABET: &[u8; 64] =
167    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
168
169/// Base64-encode `input` (standard alphabet, `=` padding).
170pub fn base64_encode(input: &[u8]) -> String {
171    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
172    for chunk in input.chunks(3) {
173        let b0 = chunk[0] as u32;
174        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
175        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
176        let n = (b0 << 16) | (b1 << 8) | b2;
177        out.push(B64_ALPHABET[(n >> 18) as usize & 63] as char);
178        out.push(B64_ALPHABET[(n >> 12) as usize & 63] as char);
179        out.push(if chunk.len() > 1 {
180            B64_ALPHABET[(n >> 6) as usize & 63] as char
181        } else {
182            '='
183        });
184        out.push(if chunk.len() > 2 {
185            B64_ALPHABET[n as usize & 63] as char
186        } else {
187            '='
188        });
189    }
190    out
191}
192
193/// Base64-decode `input` (standard alphabet, `=`/whitespace tolerated).
194pub fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
195    let mut out = Vec::with_capacity(input.len().div_ceil(4) * 3);
196    let mut buf = 0u32;
197    let mut bits = 0u32;
198    for c in input.chars() {
199        if c == '=' || c.is_whitespace() {
200            continue;
201        }
202        let val = match c {
203            'A'..='Z' => c as u32 - 'A' as u32,
204            'a'..='z' => c as u32 - 'a' as u32 + 26,
205            '0'..='9' => c as u32 - '0' as u32 + 52,
206            '+' => 62,
207            '/' => 63,
208            _ => return Err(format!("invalid base64 character: {c:?}")),
209        };
210        buf = (buf << 6) | val;
211        bits += 6;
212        if bits >= 8 {
213            bits -= 8;
214            out.push((buf >> bits) as u8);
215        }
216    }
217    Ok(out)
218}
219
220/// Encode [`FileMeta`] as base64 for the [`crate::HEADER_FILE_META`] header.
221///
222/// The raw JSON is base64-encoded so that non-Latin-1 virtual paths (e.g.
223/// CJK filenames) survive HTTP header transport — browsers reject header
224/// values containing characters outside ISO-8859-1.
225pub fn encode_file_meta_header(meta: &FileMeta) -> String {
226    base64_encode(encode_file_meta(meta).as_bytes())
227}
228
229/// Decode base64 [`FileMeta`] from the [`crate::HEADER_FILE_META`] header.
230pub fn decode_file_meta_header(header: &str) -> Result<FileMeta, serde_json::Error> {
231    let raw = base64_decode(header)
232        .map_err(|e| serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
233    decode_file_meta(&String::from_utf8_lossy(&raw))
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn plan_chunks_evenly() {
242        let meta = FileMeta::new("/a/b.bin", 10, 0);
243        let plan = TransferPlan::with_chunk_size(meta, 4);
244        assert_eq!(plan.chunks.len(), 3);
245        assert_eq!(
246            plan.chunks.iter().map(|c| c.offset).collect::<Vec<_>>(),
247            vec![0, 4, 8]
248        );
249        assert_eq!(
250            plan.chunks.iter().map(|c| c.size).collect::<Vec<_>>(),
251            vec![4, 4, 2]
252        );
253        assert_eq!(plan.total_bytes(), 10);
254    }
255
256    #[test]
257    fn plan_exact_multiple() {
258        let meta = FileMeta::new("/f", 8, 0);
259        let plan = TransferPlan::with_chunk_size(meta, 4);
260        assert_eq!(plan.chunks.len(), 2);
261        assert_eq!(plan.total_bytes(), 8);
262    }
263
264    #[test]
265    fn plan_empty_file_has_one_zero_chunk() {
266        let meta = FileMeta::new("/empty", 0, 0);
267        let plan = TransferPlan::new(meta);
268        assert_eq!(plan.chunks.len(), 1);
269        assert_eq!(plan.chunks[0].size, 0);
270    }
271
272    #[test]
273    fn etag_is_stable_and_quoted() {
274        let a = etag_from_size_mtime(100, 1_700_000_000);
275        let b = etag_from_size_mtime(100, 1_700_000_000);
276        let c = etag_from_size_mtime(101, 1_700_000_000);
277        assert_eq!(a, b);
278        assert_ne!(a, c);
279        assert!(a.starts_with('"') && a.ends_with('"'));
280    }
281
282    #[test]
283    fn file_meta_json_roundtrip() {
284        let meta = FileMeta::new("dir/file.txt", 1234, 42);
285        let encoded = encode_file_meta(&meta);
286        assert!(encoded.contains("etag"));
287        let decoded = decode_file_meta(&encoded).unwrap();
288        assert_eq!(decoded, meta);
289    }
290
291    #[test]
292    fn base64_roundtrip_arbitrary_bytes() {
293        for bytes in [
294            b"".to_vec(),
295            b"f".to_vec(),
296            b"fo".to_vec(),
297            b"foo".to_vec(),
298            b"foob".to_vec(),
299            vec![0u8, 1, 2, 3, 255, 128, 64],
300            "中文文件名🚀".as_bytes().to_vec(),
301        ] {
302            let enc = base64_encode(&bytes);
303            assert_eq!(base64_decode(&enc).unwrap(), bytes, "for {bytes:?}");
304        }
305    }
306
307    #[test]
308    fn base64_encode_known_vectors() {
309        assert_eq!(base64_encode(b""), "");
310        assert_eq!(base64_encode(b"f"), "Zg==");
311        assert_eq!(base64_encode(b"fo"), "Zm8=");
312        assert_eq!(base64_encode(b"foo"), "Zm9v");
313        assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
314    }
315
316    #[test]
317    fn file_meta_header_roundtrip_with_unicode_path() {
318        // A CJK filename must survive the header-encoding roundtrip.
319        let meta = FileMeta::new("目录/报告.txt", 999, 7);
320        let encoded = encode_file_meta_header(&meta);
321        // The base64 form is pure ASCII — safe as an HTTP header value.
322        assert!(encoded.is_ascii());
323        let decoded = decode_file_meta_header(&encoded).unwrap();
324        assert_eq!(decoded, meta);
325    }
326
327    #[test]
328    fn decode_file_meta_header_rejects_garbage() {
329        assert!(decode_file_meta_header("!!!not-base64!!!").is_err());
330    }
331}