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 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 pub fn total_bytes(&self) -> u64 {
123 self.chunks.iter().map(|c| c.size).sum()
124 }
125}
126
127pub 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
141pub 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
151pub fn encode_file_meta(meta: &FileMeta) -> String {
153 serde_json::to_string(meta).expect("FileMeta serializes")
154}
155
156pub fn decode_file_meta(header: &str) -> Result<FileMeta, serde_json::Error> {
158 serde_json::from_str(header)
159}
160
161const B64_ALPHABET: &[u8; 64] =
163 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
164
165pub 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
189pub 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
216pub fn encode_file_meta_header(meta: &FileMeta) -> String {
222 base64_encode(encode_file_meta(meta).as_bytes())
223}
224
225pub 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 let meta = FileMeta::new("目录/报告.txt", 999, 7);
316 let encoded = encode_file_meta_header(&meta);
317 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}