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#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn plan_chunks_evenly() {
167        let meta = FileMeta::new("/a/b.bin", 10, 0);
168        let plan = TransferPlan::with_chunk_size(meta, 4);
169        assert_eq!(plan.chunks.len(), 3);
170        assert_eq!(
171            plan.chunks.iter().map(|c| c.offset).collect::<Vec<_>>(),
172            vec![0, 4, 8]
173        );
174        assert_eq!(
175            plan.chunks.iter().map(|c| c.size).collect::<Vec<_>>(),
176            vec![4, 4, 2]
177        );
178        assert_eq!(plan.total_bytes(), 10);
179    }
180
181    #[test]
182    fn plan_exact_multiple() {
183        let meta = FileMeta::new("/f", 8, 0);
184        let plan = TransferPlan::with_chunk_size(meta, 4);
185        assert_eq!(plan.chunks.len(), 2);
186        assert_eq!(plan.total_bytes(), 8);
187    }
188
189    #[test]
190    fn plan_empty_file_has_one_zero_chunk() {
191        let meta = FileMeta::new("/empty", 0, 0);
192        let plan = TransferPlan::new(meta);
193        assert_eq!(plan.chunks.len(), 1);
194        assert_eq!(plan.chunks[0].size, 0);
195    }
196
197    #[test]
198    fn etag_is_stable_and_quoted() {
199        let a = etag_from_size_mtime(100, 1_700_000_000);
200        let b = etag_from_size_mtime(100, 1_700_000_000);
201        let c = etag_from_size_mtime(101, 1_700_000_000);
202        assert_eq!(a, b);
203        assert_ne!(a, c);
204        assert!(a.starts_with('"') && a.ends_with('"'));
205    }
206
207    #[test]
208    fn file_meta_json_roundtrip() {
209        let meta = FileMeta::new("dir/file.txt", 1234, 42);
210        let encoded = encode_file_meta(&meta);
211        assert!(encoded.contains("etag"));
212        let decoded = decode_file_meta(&encoded).unwrap();
213        assert_eq!(decoded, meta);
214    }
215}