Skip to main content

zsync_rs/
assembly.rs

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    /// Assemble using a caller-supplied HTTP client.
60    ///
61    /// The client carries the transport policy for every range request the
62    /// assembly makes, so an embedder that needs its own TLS roots or that
63    /// must refuse a redirect to plain HTTP can set it once here.
64    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    /// Fetch the control file and assemble, both using `http`.
91    ///
92    /// The same client serves the control-file fetch and every subsequent
93    /// range request, so one policy covers the whole transfer.
94    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}