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
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}