fetch_unroll/
lib.rs

1/*!
2Simple functions intended to use in __Rust__ `build.rs` scripts for tasks which related to fetching from _HTTP_ and unrolling `.tar.gz` archives with precompiled binaries and etc.
3
4```
5use fetch_unroll::Fetch;
6
7let pack_url = format!(
8    concat!("{base}/{user}/{repo}/releases/download/",
9            "{package}-{version}/{package}_{target}_{profile}.tar.gz"),
10    base = "https://github.com",
11    user = "katyo",
12    repo = "aubio-rs",
13    package = "libaubio",
14    version = "0.5.0-alpha",
15    target = "armv7-linux-androideabi",
16    profile = "debug",
17);
18
19let dest_dir = "target/test_download";
20
21// Fetching and unrolling archive
22Fetch::from(pack_url)
23    .unroll().strip_components(1).to(dest_dir)
24    .unwrap();
25```
26 */
27
28#![warn(
29    clippy::all,
30    clippy::pedantic,
31    clippy::nursery,
32    //clippy::cargo,
33)]
34
35use std::{
36    error::Error as StdError,
37    fmt::{Display, Formatter, Result as FmtResult},
38    fs::{create_dir_all, remove_dir_all, remove_file, File},
39    io::{copy, Cursor, Error as IoError, Read},
40    path::{Path, PathBuf},
41    result::Result as StdResult,
42};
43
44use libflate::gzip::Decoder as GzipDecoder;
45use tar::{Archive as TarArchive, EntryType as TarEntryType};
46use ureq::{get as http_get, Error as HttpError};
47
48/// Result type
49pub type Result<T> = StdResult<T, Error>;
50
51/// Status type
52///
53/// The result without payload
54pub type Status = Result<()>;
55
56/// Error type
57#[derive(Debug)]
58pub enum Error {
59    /// Generic HTTP error
60    Http(String),
61
62    /// Generic IO error
63    Io(IoError),
64}
65
66impl StdError for Error {}
67
68impl Display for Error {
69    fn fmt(&self, f: &mut Formatter) -> FmtResult {
70        match self {
71            Self::Http(error) => {
72                "Http error: ".fmt(f)?;
73                error.fmt(f)
74            }
75            Self::Io(error) => {
76                "IO error: ".fmt(f)?;
77                error.fmt(f)
78            }
79        }
80    }
81}
82
83impl From<&HttpError> for Error {
84    #[must_use]
85    fn from(error: &HttpError) -> Self {
86        // Map the error to our error type.
87        Self::Http(match error {
88            HttpError::Status(code, _) => {
89                format!("Invalid status: {}", code)
90            }
91            HttpError::Transport(transport) => {
92                format!("Transport error: {}", transport)
93            }
94        })
95    }
96}
97
98impl From<IoError> for Error {
99    #[must_use]
100    fn from(error: IoError) -> Self {
101        Self::Io(error)
102    }
103}
104
105type Flag = u8;
106
107const CREATE_DEST_PATH: Flag = 1 << 0;
108const FORCE_OVERWRITE: Flag = 1 << 1;
109const FIX_INVALID_DEST: Flag = 1 << 2;
110const CLEANUP_ON_ERROR: Flag = 1 << 3;
111const CLEANUP_DEST_DIR: Flag = 1 << 4;
112const STRIP_WHEN_ALONE: Flag = 1 << 5;
113
114const DEFAULT_SAVE_FLAGS: Flag =
115    CREATE_DEST_PATH | FORCE_OVERWRITE | FIX_INVALID_DEST | CLEANUP_ON_ERROR;
116const DEFAULT_UNROLL_FLAGS: Flag =
117    CREATE_DEST_PATH | FIX_INVALID_DEST | CLEANUP_ON_ERROR | CLEANUP_DEST_DIR;
118
119macro_rules! flag {
120    // Get flag
121    ($($var:ident).* [$key:ident]) => {
122        ($($var).* & $key) == $key
123    };
124
125    // Set flag
126    ($($var:ident).* [$key:ident] = $val:expr) => {
127        if $val {
128            $($var).* |= $key;
129        } else {
130            $($var).* &= !$key;
131        }
132    };
133}
134
135/// HTTP(S) fetcher
136pub struct Fetch<R> {
137    source: Result<R>,
138}
139
140#[allow(clippy::use_self)]
141impl Fetch<()> {
142    /// Fetch data from url
143    pub fn from<U>(url: U) -> Fetch<impl Read>
144    where
145        U: AsRef<str>,
146    {
147        Fetch {
148            source: http_fetch(url.as_ref()),
149        }
150    }
151}
152
153fn http_fetch(url: &str) -> Result<impl Read> {
154    match http_get(url).call() {
155        Ok(response) => Ok(response.into_reader()),
156        Err(error) => {
157            // Map the error to our error type.
158            Err(Error::from(&error))
159        }
160    }
161}
162
163impl<R> Fetch<R>
164where
165    R: Read,
166{
167    /// Write fetched data to file
168    pub fn save(self) -> Save<impl Read> {
169        Save::from(self.source)
170    }
171
172    /// Unroll fetched archive
173    pub fn unroll(self) -> Unroll<impl Read> {
174        Unroll::from(self.source)
175    }
176}
177
178/// File writer
179pub struct Save<R> {
180    source: Result<R>,
181    options: SaveOptions,
182}
183
184struct SaveOptions {
185    flags: Flag,
186}
187
188impl Default for SaveOptions {
189    fn default() -> Self {
190        Self {
191            flags: DEFAULT_SAVE_FLAGS,
192        }
193    }
194}
195
196impl<R> From<Result<R>> for Save<R> {
197    fn from(source: Result<R>) -> Self {
198        Self {
199            source,
200            options: SaveOptions::default(),
201        }
202    }
203}
204
205impl<R> Save<R> {
206    /// Create destination directory when it doesn't exists
207    ///
208    /// Default: `true`
209    pub const fn create_dest_path(mut self, flag: bool) -> Self {
210        flag! { self.options.flags[CREATE_DEST_PATH] = flag }
211        self
212    }
213
214    /// Overwrite existing file
215    ///
216    /// Default: `true`
217    pub const fn force_overwrite(mut self, flag: bool) -> Self {
218        flag! { self.options.flags[FORCE_OVERWRITE] = flag }
219        self
220    }
221
222    /// Try to fix destination path when it is not a valid
223    ///
224    /// For example, when destination already exists
225    /// and it is a directory, it will be removed
226    ///
227    /// Default: `true`
228    pub const fn fix_invalid_dest(mut self, flag: bool) -> Self {
229        flag! { self.options.flags[FIX_INVALID_DEST] = flag }
230        self
231    }
232
233    /// Cleanup already written data when errors occurs
234    ///
235    /// Default: `true`
236    pub const fn cleanup_on_error(mut self, flag: bool) -> Self {
237        flag! { self.options.flags[CLEANUP_ON_ERROR] = flag }
238        self
239    }
240}
241
242impl<R> Save<R> {
243    /// Save file to specified path
244    ///
245    /// # Errors
246    /// - Destination directory does not exists when `create_dest_path` is not set
247    /// - File already exist at destination directory when `force_overwrite` is not set
248    /// - Destination path is not a file when `fix_invalid_dest` is not set
249    pub fn to<D>(self, path: D) -> Status
250    where
251        R: Read,
252        D: AsRef<Path>,
253    {
254        let Self { source, options } = self;
255
256        let mut source = source?;
257
258        let path = path.as_ref();
259
260        if path.is_file() {
261            if flag!(options.flags[FORCE_OVERWRITE]) {
262                remove_file(path)?;
263            } else {
264                return Ok(());
265            }
266        } else if path.is_dir() {
267            if flag!(options.flags[FIX_INVALID_DEST]) {
268                remove_dir_all(path)?;
269            }
270        } else {
271            // not exists
272            if flag!(options.flags[CREATE_DEST_PATH]) {
273                if let Some(path) = path.parent() {
274                    create_dir_all(path)?;
275                }
276            }
277        }
278
279        copy(&mut source, &mut File::create(path)?)
280            .map(|_| ())
281            .or_else(|error| {
282                if flag!(options.flags[CLEANUP_ON_ERROR]) && path.is_file() {
283                    remove_file(path)?;
284                }
285                Err(error)
286            })?;
287
288        Ok(())
289    }
290}
291
292/// Archive unroller
293///
294/// *NOTE*: Currently supported __.tar.gz__ archives only.
295pub struct Unroll<R> {
296    source: Result<R>,
297    options: UnrollOptions,
298}
299
300struct UnrollOptions {
301    strip_components: usize,
302    flags: Flag,
303}
304
305impl Default for UnrollOptions {
306    fn default() -> Self {
307        Self {
308            strip_components: 0,
309            flags: DEFAULT_UNROLL_FLAGS,
310        }
311    }
312}
313
314impl<R> From<Result<R>> for Unroll<R> {
315    fn from(source: Result<R>) -> Self {
316        Self {
317            source,
318            options: UnrollOptions::default(),
319        }
320    }
321}
322
323impl<R> Unroll<R> {
324    /// Create destination directory when it doesn't exists
325    ///
326    /// Default: `true`
327    pub const fn create_dest_path(mut self, flag: bool) -> Self {
328        flag! { self.options.flags[CREATE_DEST_PATH] = flag }
329        self
330    }
331
332    /// Cleanup destination directory before extraction
333    ///
334    /// Default: `true`
335    pub const fn cleanup_dest_dir(mut self, flag: bool) -> Self {
336        flag! { self.options.flags[CLEANUP_DEST_DIR] = flag }
337        self
338    }
339
340    /// Try to fix destination path when it is not a valid
341    ///
342    /// For example, when destination already exists
343    /// and it is not a directory, it will be removed
344    ///
345    /// Default: `true`
346    pub const fn fix_invalid_dest(mut self, flag: bool) -> Self {
347        flag! { self.options.flags[FIX_INVALID_DEST] = flag }
348        self
349    }
350
351    /// Cleanup already extracted data when errors occurs
352    ///
353    /// Default: `true`
354    pub const fn cleanup_on_error(mut self, flag: bool) -> Self {
355        flag! { self.options.flags[CLEANUP_ON_ERROR] = flag }
356        self
357    }
358
359    /// Strip the number of leading components from file names on extraction
360    ///
361    /// Default: `0`
362    pub const fn strip_components(mut self, num_of_components: usize) -> Self {
363        self.options.strip_components = num_of_components;
364        self
365    }
366
367    /// Strip the leading components only when it's alone
368    ///
369    /// Default: `false`
370    pub const fn strip_when_alone(mut self, flag: bool) -> Self {
371        flag! { self.options.flags[STRIP_WHEN_ALONE] = flag }
372        self
373    }
374}
375
376impl<R> Unroll<R> {
377    /// Extract contents to specified directory
378    ///
379    /// # Errors
380    /// - Destination directory does not exists when `create_dest_path` is not set
381    /// - Destination directory is not empty when `cleanup_dest_dir` is not set
382    /// - Destination path is not a directory when `fix_invalid_dest` is not set
383    /// - Required number of path components cannot be stripped  when `strip_when_alone` is not set
384    pub fn to<D>(self, path: D) -> Status
385    where
386        R: Read,
387        D: AsRef<Path>,
388    {
389        let Self { source, options } = self;
390
391        let source = source?;
392
393        let path = path.as_ref();
394        let mut dest_already_exists = false;
395
396        if path.is_dir() {
397            dest_already_exists = true;
398
399            if flag!(options.flags[CLEANUP_DEST_DIR]) {
400                remove_dir_entries(path)?;
401            }
402        } else if path.is_file() {
403            //dest_already_exists = true;
404
405            if flag!(options.flags[FIX_INVALID_DEST]) {
406                remove_file(path)?;
407
408                if flag!(options.flags[CREATE_DEST_PATH]) {
409                    create_dir_all(path)?;
410                }
411            }
412        } else {
413            // not exists
414            if flag!(options.flags[CREATE_DEST_PATH]) {
415                create_dir_all(path)?;
416            }
417        }
418
419        unroll_archive_to(source, &options, path).or_else(|error| {
420            if flag!(options.flags[CLEANUP_ON_ERROR]) && path.is_dir() {
421                if dest_already_exists {
422                    remove_dir_entries(path)?;
423                } else {
424                    remove_dir_all(path)?;
425                }
426            }
427            Err(error)
428        })
429    }
430}
431
432fn unroll_archive_to<R>(source: R, options: &UnrollOptions, destin: &Path) -> Status
433where
434    R: Read,
435{
436    let mut decoder = GzipDecoder::new(source)?;
437
438    if options.strip_components < 1 {
439        let mut archive = TarArchive::new(decoder);
440        archive.unpack(destin)?;
441        Ok(())
442    } else {
443        let mut decoded_data = Vec::new();
444        decoder.read_to_end(&mut decoded_data)?;
445
446        let strip_components = if flag!(options.flags[STRIP_WHEN_ALONE]) {
447            let mut archive = TarArchive::new(Cursor::new(&decoded_data));
448            options
449                .strip_components
450                .min(count_common_components(&mut archive)?)
451        } else {
452            options.strip_components
453        };
454
455        let mut archive = TarArchive::new(Cursor::new(decoded_data));
456        let entries = archive.entries()?;
457
458        for entry in entries {
459            let mut entry = entry?;
460            let type_ = entry.header().entry_type();
461
462            {
463                let entry_path = entry.path()?;
464
465                match type_ {
466                    TarEntryType::Directory => {
467                        let stripped_path = entry_path
468                            .iter()
469                            .skip(strip_components)
470                            .collect::<PathBuf>();
471                        if stripped_path.iter().count() < 1 {
472                            continue;
473                        }
474                        let dest_path = destin.join(stripped_path);
475
476                        //create_dir_all(dest_path);
477                        entry.unpack(dest_path)?;
478                    }
479                    TarEntryType::Regular => {
480                        let strip_components = strip_components.min(entry_path.iter().count() - 1);
481                        let stripped_path = entry_path
482                            .iter()
483                            .skip(strip_components)
484                            .collect::<PathBuf>();
485                        let dest_path = destin.join(stripped_path);
486
487                        entry.unpack(dest_path)?;
488                    }
489                    _ => println!("other: {:?}", entry_path),
490                }
491            }
492        }
493
494        Ok(())
495    }
496}
497
498fn count_common_components<R>(archive: &mut TarArchive<R>) -> StdResult<usize, IoError>
499where
500    R: Read,
501{
502    let mut common_ancestor = None;
503
504    for entry in archive.entries()? {
505        let entry = entry?;
506        let entry_path = entry.path()?;
507
508        match entry.header().entry_type() {
509            TarEntryType::Directory | TarEntryType::Regular => {
510                if common_ancestor.is_none() {
511                    common_ancestor = Some(entry_path.to_path_buf());
512                } else {
513                    let common_ancestor = common_ancestor.as_mut().unwrap();
514
515                    *common_ancestor = common_ancestor
516                        .iter()
517                        .zip(entry_path.iter())
518                        .take_while(|(common_component, entry_component)| {
519                            common_component == entry_component
520                        })
521                        .map(|(common_component, _)| common_component)
522                        .collect();
523                }
524            }
525            _ => (),
526        }
527    }
528
529    Ok(common_ancestor.map_or(0, |path| path.iter().count()))
530}
531
532fn remove_dir_entries(path: &Path) -> StdResult<(), IoError> {
533    for entry in path.read_dir()? {
534        let path = entry?.path();
535        if path.is_file() {
536            remove_file(path)?;
537        } else {
538            remove_dir_all(path)?;
539        }
540    }
541    Ok(())
542}
543
544#[cfg(test)]
545mod test {
546    use super::*;
547
548    #[test]
549    fn github_archive_new() {
550        let src_url = format!(
551            "{base}/{user}/{repo}/archive/{ver}.tar.gz",
552            base = "https://github.com",
553            user = "katyo",
554            repo = "fluidlite",
555            ver = "1.2.0",
556        );
557
558        let dst_dir = "target/test_archive_new";
559
560        // Fetching and unrolling archive (new way)
561        Fetch::from(src_url)
562            .unroll()
563            .strip_components(1)
564            .strip_when_alone(true)
565            .to(dst_dir)
566            .unwrap();
567
568        //std::fs::remove_dir_all(dst_dir).unwrap();
569    }
570}