1use std::fs::{File, OpenOptions};
2use std::io::{Read, Seek, SeekFrom};
3use std::os::unix::fs::FileExt;
4use std::path::Path;
5
6use crate::checksum::calc_sha1_stream;
7use crate::control::ControlFile;
8use crate::http::{
9 DEFAULT_RANGE_GAP_THRESHOLD, HttpClient, byte_ranges_from_block_ranges, merge_byte_ranges,
10};
11use crate::matcher::BlockMatcher;
12use crate::matcher::MatchError;
13
14const STREAM_CHUNK_SIZE: usize = 1024 * 1024;
15
16#[derive(Debug, thiserror::Error)]
17pub enum AssemblyError {
18 #[error("IO error: {0}")]
19 Io(#[from] std::io::Error),
20 #[error("HTTP error: {0}")]
21 Http(#[from] crate::http::HttpError),
22 #[error("Matcher error: {0}")]
23 Matcher(#[from] MatchError),
24 #[error("Control file error: {0}")]
25 Control(String),
26 #[error("Checksum mismatch: expected {expected}, got {actual}")]
27 ChecksumMismatch { expected: String, actual: String },
28 #[error("No URLs available")]
29 NoUrls,
30}
31
32pub type ProgressCallback = Box<dyn Fn(u64, u64) + Send + Sync>;
33
34pub struct ZsyncAssembly {
35 control: ControlFile,
36 base_url: Option<String>,
37 matcher: BlockMatcher,
38 http: HttpClient,
39 output_path: std::path::PathBuf,
40 temp_path: std::path::PathBuf,
41 file: Option<File>,
42 range_gap_threshold: u64,
43 progress_callback: Option<ProgressCallback>,
44}
45
46impl ZsyncAssembly {
47 pub fn new(control: ControlFile, output_path: &Path) -> Result<Self, AssemblyError> {
48 Self::with_base_url(control, output_path, None)
49 }
50
51 pub fn with_base_url(
52 control: ControlFile,
53 output_path: &Path,
54 base_url: Option<&str>,
55 ) -> Result<Self, AssemblyError> {
56 Self::with_client(control, output_path, base_url, HttpClient::new())
57 }
58
59 pub fn with_client(
65 control: ControlFile,
66 output_path: &Path,
67 base_url: Option<&str>,
68 http: HttpClient,
69 ) -> Result<Self, AssemblyError> {
70 let matcher = BlockMatcher::new(&control);
71 let temp_path = output_path.with_extension("zsync-tmp");
72
73 Ok(Self {
74 control,
75 base_url: base_url.map(|s| s.to_string()),
76 matcher,
77 http,
78 output_path: output_path.to_path_buf(),
79 temp_path,
80 file: None,
81 range_gap_threshold: DEFAULT_RANGE_GAP_THRESHOLD,
82 progress_callback: None,
83 })
84 }
85
86 pub fn from_url(control_url: &str, output_path: &Path) -> Result<Self, AssemblyError> {
87 Self::from_url_with_client(control_url, output_path, HttpClient::new())
88 }
89
90 pub fn from_url_with_client(
95 control_url: &str,
96 output_path: &Path,
97 http: HttpClient,
98 ) -> Result<Self, AssemblyError> {
99 let control = http.fetch_control_file(control_url)?;
100 let base_url = extract_base_url(control_url);
101 Self::with_client(control, output_path, Some(&base_url), http)
102 }
103
104 pub fn set_range_gap_threshold(&mut self, threshold: u64) {
105 self.range_gap_threshold = threshold;
106 }
107
108 pub fn set_progress_callback<F>(&mut self, callback: F)
109 where
110 F: Fn(u64, u64) + Send + Sync + 'static,
111 {
112 self.progress_callback = Some(Box::new(callback));
113 }
114
115 fn report_progress(&self) {
116 if let Some(ref cb) = self.progress_callback {
117 let (done, total) = self.progress();
118 cb(done, total);
119 }
120 }
121
122 pub fn progress(&self) -> (u64, u64) {
123 let total = self.control.length;
124 let got = self.matcher.blocks_todo();
125 let blocks_done = self.matcher.total_blocks() - got;
126 let done_bytes = (blocks_done * self.control.blocksize) as u64;
127 (done_bytes.min(total), total)
128 }
129
130 pub fn is_complete(&self) -> bool {
131 self.matcher.is_complete()
132 }
133
134 pub fn block_stats(&self) -> (usize, usize) {
135 let total = self.matcher.total_blocks();
136 let todo = self.matcher.blocks_todo();
137 (total - todo, total)
138 }
139
140 pub fn submit_source_file(&mut self, path: &Path) -> Result<usize, AssemblyError> {
141 let file = File::open(path)?;
142 let file_size = file.metadata()?.len() as usize;
143
144 let blocksize = self.control.blocksize;
145 let context = blocksize * self.control.hash_lengths.seq_matches as usize;
146
147 if file_size < context {
148 return Ok(0);
149 }
150
151 let chunk_size = STREAM_CHUNK_SIZE.max(context * 2);
152 let mut total_matched = 0;
153 let mut buf = vec![0u8; chunk_size + 2 * context];
154 let mut file_offset = 0usize;
155
156 loop {
157 let overlap_start = file_offset.saturating_sub(context);
158 let overlap_len = file_offset - overlap_start;
159
160 if overlap_len > 0 {
161 file.read_at(&mut buf[..overlap_len], overlap_start as u64)?;
162 }
163
164 let read_start = overlap_len;
165 let read_len = chunk_size;
166
167 let bytes_read = file.read_at(
168 &mut buf[read_start..read_start + read_len],
169 file_offset as u64,
170 )?;
171 if bytes_read == 0 {
172 break;
173 }
174
175 let data_len = read_start + bytes_read;
176 let chunk_context = if file_offset + bytes_read < file_size {
177 let context_start = file_offset + bytes_read;
178 let context_available = file_size.saturating_sub(context_start).min(context);
179 file.read_at(
180 &mut buf[data_len..data_len + context_available],
181 context_start as u64,
182 )?;
183 if context_available < context {
184 buf[data_len + context_available..data_len + context].fill(0);
185 }
186 data_len + context
187 } else {
188 buf[data_len..data_len + context].fill(0);
189 data_len + context
190 };
191
192 let matched_blocks = self
193 .matcher
194 .submit_source_data(&buf[..chunk_context], overlap_start as u64);
195
196 for (block_id, source_offset) in &matched_blocks {
197 let file_handle = self.ensure_file()?;
198 let offset = (block_id * blocksize) as u64;
199 let buf_offset = source_offset.saturating_sub(overlap_start);
200 debug_assert!(
201 buf_offset + blocksize <= chunk_context,
202 "buf_offset {} + blocksize {} > chunk_context {} (source_offset={}, overlap_start={})",
203 buf_offset,
204 blocksize,
205 chunk_context,
206 source_offset,
207 overlap_start
208 );
209 let block_data = &buf[buf_offset..buf_offset + blocksize];
210 Self::write_at_offset(file_handle, block_data, offset)?;
211 }
212
213 total_matched += matched_blocks.len();
214 file_offset += bytes_read;
215
216 if bytes_read < read_len {
217 break;
218 }
219 }
220
221 Ok(total_matched)
222 }
223
224 pub fn submit_self_referential(&mut self) -> Result<usize, AssemblyError> {
225 if self.file.is_none() {
226 return Ok(0);
227 }
228
229 let file = self.file.as_mut().unwrap();
230 file.sync_all()?;
231
232 let file_size = file.metadata()?.len() as usize;
233
234 let blocksize = self.control.blocksize;
235 let context = blocksize * self.control.hash_lengths.seq_matches as usize;
236
237 if file_size < context {
238 return Ok(0);
239 }
240
241 let chunk_size = STREAM_CHUNK_SIZE.max(context * 2);
242 let mut total_matched = 0;
243 let mut buf = vec![0u8; chunk_size + 2 * context];
244 let mut file_offset = 0usize;
245
246 loop {
247 let overlap_start = file_offset.saturating_sub(context);
248 let overlap_len = file_offset - overlap_start;
249
250 if overlap_len > 0 {
251 file.read_at(&mut buf[..overlap_len], overlap_start as u64)?;
252 }
253
254 let read_start = overlap_len;
255 let read_len = chunk_size;
256
257 let bytes_read = file.read_at(
258 &mut buf[read_start..read_start + read_len],
259 file_offset as u64,
260 )?;
261 if bytes_read == 0 {
262 break;
263 }
264
265 let data_len = read_start + bytes_read;
266 let chunk_context = if file_offset + bytes_read < file_size {
267 let context_start = file_offset + bytes_read;
268 let context_available = file_size.saturating_sub(context_start).min(context);
269 file.read_at(
270 &mut buf[data_len..data_len + context_available],
271 context_start as u64,
272 )?;
273 if context_available < context {
274 buf[data_len + context_available..data_len + context].fill(0);
275 }
276 data_len + context
277 } else {
278 buf[data_len..data_len + context].fill(0);
279 data_len + context
280 };
281
282 let matched_blocks = self
283 .matcher
284 .submit_source_data(&buf[..chunk_context], overlap_start as u64);
285
286 for (block_id, source_offset) in &matched_blocks {
287 let offset = (block_id * blocksize) as u64;
288 let buf_offset = source_offset.saturating_sub(overlap_start);
289 debug_assert!(
290 buf_offset + blocksize <= chunk_context,
291 "buf_offset {} + blocksize {} > chunk_context {} (source_offset={}, overlap_start={})",
292 buf_offset,
293 blocksize,
294 chunk_context,
295 source_offset,
296 overlap_start
297 );
298 let block_data = &buf[buf_offset..buf_offset + blocksize];
299 Self::write_at_offset(file, block_data, offset)?;
300 }
301
302 total_matched += matched_blocks.len();
303 file_offset += bytes_read;
304
305 if bytes_read < read_len {
306 break;
307 }
308 }
309
310 Ok(total_matched)
311 }
312
313 fn write_at_offset(file: &File, data: &[u8], offset: u64) -> Result<(), AssemblyError> {
314 file.write_all_at(data, offset)?;
315 Ok(())
316 }
317
318 fn ensure_file(&mut self) -> Result<&mut File, AssemblyError> {
319 if self.file.is_none() {
320 let file = OpenOptions::new()
321 .read(true)
322 .write(true)
323 .create(true)
324 .truncate(false)
325 .open(&self.temp_path)?;
326 self.file = Some(file);
327 }
328 Ok(self.file.as_mut().unwrap())
329 }
330
331 pub fn download_missing_blocks(&mut self) -> Result<usize, AssemblyError> {
332 let relative_url = self
333 .control
334 .urls
335 .first()
336 .ok_or(AssemblyError::NoUrls)?
337 .clone();
338
339 let url = self
340 .base_url
341 .as_ref()
342 .map(|base| resolve_url(base, &relative_url))
343 .unwrap_or(relative_url);
344
345 let block_ranges = self.matcher.needed_block_ranges();
346 if block_ranges.is_empty() {
347 return Ok(0);
348 }
349
350 let byte_ranges = byte_ranges_from_block_ranges(
351 &block_ranges,
352 self.control.blocksize,
353 self.control.length,
354 );
355 let merged_ranges = merge_byte_ranges(&byte_ranges, self.range_gap_threshold);
356 let mut downloaded_blocks = 0;
357 let blocksize = self.control.blocksize;
358 let total_blocks = self.matcher.total_blocks();
359 let mut padded_buf = vec![0u8; blocksize];
360
361 for (range_start, range_end) in merged_ranges {
362 let mut reader = self.http.fetch_range_reader(&url, range_start, range_end)?;
363 let block_start = (range_start / blocksize as u64) as usize;
364 let initial_offset = (range_start % blocksize as u64) as usize;
365
366 let mut buf = vec![0u8; blocksize + 64 * 1024];
367 buf[..initial_offset].fill(0);
368 let mut buf_len = initial_offset;
369 let mut current_block_id = block_start;
370
371 let mut read_buf = [0u8; 64 * 1024];
372 loop {
373 let n = reader.read(&mut read_buf)?;
374 if n == 0 {
375 break;
376 }
377
378 if buf_len + n > buf.len() {
379 buf.resize(buf_len + n, 0);
380 }
381 buf[buf_len..buf_len + n].copy_from_slice(&read_buf[..n]);
382 buf_len += n;
383
384 while buf_len >= blocksize {
385 if current_block_id >= total_blocks {
386 break;
387 }
388
389 if !self.matcher.is_block_known(current_block_id) {
390 let block_data_end = if current_block_id == total_blocks - 1 {
391 let last_block_size = (self.control.length as usize) % blocksize;
392 if last_block_size == 0 {
393 blocksize
394 } else {
395 last_block_size
396 }
397 } else {
398 blocksize
399 };
400
401 let block_data = &buf[..block_data_end];
402 padded_buf[..block_data_end].copy_from_slice(block_data);
403 if block_data_end < blocksize {
404 padded_buf[block_data_end..].fill(0);
405 }
406
407 if self.matcher.submit_blocks(&padded_buf, current_block_id)? {
408 let file = self.ensure_file()?;
409 let file_offset = (current_block_id * blocksize) as u64;
410 Self::write_at_offset(file, block_data, file_offset)?;
411 downloaded_blocks += 1;
412 self.report_progress();
413 }
414 }
415
416 current_block_id += 1;
417 buf.copy_within(blocksize..buf_len, 0);
418 buf_len -= blocksize;
419 }
420 }
421
422 if buf_len > 0
423 && current_block_id < total_blocks
424 && !self.matcher.is_block_known(current_block_id)
425 {
426 let block_data = &buf[..buf_len];
427 padded_buf[..buf_len].copy_from_slice(block_data);
428 padded_buf[buf_len..].fill(0);
429
430 if self.matcher.submit_blocks(&padded_buf, current_block_id)? {
431 let file = self.ensure_file()?;
432 let file_offset = (current_block_id * blocksize) as u64;
433 Self::write_at_offset(file, block_data, file_offset)?;
434 downloaded_blocks += 1;
435 self.report_progress();
436 }
437 }
438 }
439
440 Ok(downloaded_blocks)
441 }
442
443 pub fn complete(mut self) -> Result<(), AssemblyError> {
444 if !self.matcher.is_complete() {
445 return Err(AssemblyError::Control(
446 "Not all blocks downloaded".to_string(),
447 ));
448 }
449
450 let file_length = self.control.length;
451 let expected_sha1 = self.control.sha1.clone();
452
453 let file = self.ensure_file()?;
454 file.set_len(file_length)?;
455
456 if let Some(ref expected) = expected_sha1 {
457 file.seek(SeekFrom::Start(0))?;
458 let actual_checksum = calc_sha1_stream(file)?;
459 let actual_hex = hex_encode(&actual_checksum);
460
461 if !actual_hex.eq_ignore_ascii_case(expected) {
462 return Err(AssemblyError::ChecksumMismatch {
463 expected: expected.clone(),
464 actual: actual_hex,
465 });
466 }
467 }
468
469 drop(self.file);
470 std::fs::rename(&self.temp_path, &self.output_path)?;
471
472 Ok(())
473 }
474
475 pub fn abort(self) {
476 let _ = std::fs::remove_file(&self.temp_path);
477 }
478}
479
480fn hex_encode(bytes: &[u8]) -> String {
481 bytes.iter().map(|b| format!("{:02x}", b)).collect()
482}
483
484fn extract_base_url(url: &str) -> String {
485 url.rfind('/')
486 .map(|i| url[..=i].to_string())
487 .unwrap_or_default()
488}
489
490fn resolve_url(base: &str, relative: &str) -> String {
491 if relative.contains("://") {
492 return relative.to_string();
493 }
494 if relative.starts_with('/') {
495 let scheme_end = base.find("://").map(|i| i + 3).unwrap_or(0);
496 let host_end = base[scheme_end..]
497 .find('/')
498 .map(|i| scheme_end + i)
499 .unwrap_or(base.len());
500 format!("{}{}", &base[..host_end], relative)
501 } else {
502 format!("{}{}", base, relative)
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
511 fn test_hex_encode() {
512 assert_eq!(hex_encode(&[0x00, 0xff, 0x10]), "00ff10");
513 }
514
515 #[test]
516 fn test_extract_base_url() {
517 assert_eq!(
518 extract_base_url("https://example.com/path/file.zsync"),
519 "https://example.com/path/"
520 );
521 assert_eq!(
522 extract_base_url("https://example.com/file.zsync"),
523 "https://example.com/"
524 );
525 }
526
527 #[test]
528 fn test_resolve_url() {
529 assert_eq!(
530 resolve_url("https://example.com/path/", "file.bin"),
531 "https://example.com/path/file.bin"
532 );
533 assert_eq!(
534 resolve_url("https://example.com/path/", "/file.bin"),
535 "https://example.com/file.bin"
536 );
537 assert_eq!(
538 resolve_url("https://example.com/path/", "https://other.com/file.bin"),
539 "https://other.com/file.bin"
540 );
541 }
542}