1use serde::{Deserialize, Serialize};
9
10use crate::CHUNK_SIZE;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct FileMeta {
15 pub path: String,
17 pub size: u64,
19 #[serde(default)]
21 pub mtime: u64,
22 #[serde(default)]
24 pub etag: String,
25}
26
27impl FileMeta {
28 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub struct ChunkRange {
44 pub start: u64,
46 pub end: u64,
48}
49
50impl ChunkRange {
51 pub fn len(&self) -> u64 {
53 self.end.saturating_sub(self.start)
54 }
55
56 pub fn is_empty(&self) -> bool {
58 self.len() == 0
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ChunkMeta {
65 pub index: u32,
67 pub offset: u64,
69 pub size: u64,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct TransferPlan {
76 pub file: FileMeta,
78 pub chunk_size: u64,
80 pub chunks: Vec<ChunkMeta>,
82}
83
84impl TransferPlan {
85 pub fn new(file: FileMeta) -> Self {
88 TransferPlan::with_chunk_size(file, CHUNK_SIZE)
89 }
90
91 pub fn with_chunk_size(file: FileMeta, chunk_size: u64) -> Self {
95 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 pub fn total_bytes(&self) -> u64 {
127 self.chunks.iter().map(|c| c.size).sum()
128 }
129}
130
131pub 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
145pub 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
155pub fn encode_file_meta(meta: &FileMeta) -> String {
157 serde_json::to_string(meta).expect("FileMeta serializes")
158}
159
160pub fn decode_file_meta(header: &str) -> Result<FileMeta, serde_json::Error> {
162 serde_json::from_str(header)
163}
164
165const B64_ALPHABET: &[u8; 64] =
167 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
168
169pub 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
193pub 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
220pub fn encode_file_meta_header(meta: &FileMeta) -> String {
226 base64_encode(encode_file_meta(meta).as_bytes())
227}
228
229pub 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 let meta = FileMeta::new("目录/报告.txt", 999, 7);
320 let encoded = encode_file_meta_header(&meta);
321 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}