Skip to main content

binstalk_bins/
lib.rs

1use std::{
2    borrow::Cow,
3    fmt, io,
4    path::{self, Component, Path, PathBuf},
5};
6
7use atomic_file_install::{
8    atomic_install, atomic_install_noclobber, atomic_symlink_file, atomic_symlink_file_noclobber,
9};
10use binstalk_types::cargo_toml_binstall::{PkgFmt, PkgMeta};
11use compact_str::{format_compact, CompactString};
12use leon::Template;
13use miette::Diagnostic;
14use normalize_path::NormalizePath;
15use thiserror::Error as ThisError;
16use tracing::debug;
17
18#[derive(Debug, ThisError, Diagnostic)]
19pub enum Error {
20    /// bin-dir configuration provided generates source path outside
21    /// of the temporary dir.
22    #[error(
23        "bin-dir configuration provided generates source path outside of the temporary dir: {}", .0.display()
24    )]
25    InvalidSourceFilePath(Box<Path>),
26
27    /// bin-dir configuration provided generates empty source path.
28    #[error("bin-dir configuration provided generates empty source path")]
29    EmptySourceFilePath,
30
31    /// Bin file is not found.
32    #[error("bin file {} not found", .0.display())]
33    BinFileNotFound(Box<Path>),
34
35    #[error(transparent)]
36    Io(#[from] io::Error),
37
38    #[error("Failed to render template: {0}")]
39    #[diagnostic(transparent)]
40    TemplateRender(#[from] leon::RenderError),
41}
42
43/// Return true if the path does not look outside of current dir
44///
45///  * `path` - must be normalized before passing to this function
46fn is_valid_path(path: &Path) -> bool {
47    !matches!(
48        path.components().next(),
49        // normalized path cannot have curdir or parentdir,
50        // so checking prefix/rootdir is enough.
51        Some(Component::Prefix(..) | Component::RootDir)
52    )
53}
54
55/// Must be called after the archive is downloaded and extracted.
56/// This function might uses blocking I/O.
57pub fn infer_bin_dir_template(
58    data: &Data,
59    has_dir: &mut dyn FnMut(&Path) -> bool,
60) -> Cow<'static, str> {
61    let name = data.name;
62    let target = data.target;
63    let version = data.version;
64
65    // Make sure to update
66    // fetchers::gh_crate_meta::hosting::{FULL_FILENAMES,
67    // NOVERSION_FILENAMES} if you update this array.
68    let gen_possible_dirs: [for<'r> fn(&'r str, &'r str, &'r str) -> String; 8] = [
69        |name, target, version| format!("{name}-{target}-v{version}"),
70        |name, target, version| format!("{name}-{target}-{version}"),
71        |name, target, version| format!("{name}-{version}-{target}"),
72        |name, target, version| format!("{name}-v{version}-{target}"),
73        |name, target, _version| format!("{name}-{target}"),
74        // Ignore the following when updating hosting::{FULL_FILENAMES, NOVERSION_FILENAMES}
75        |name, _target, version| format!("{name}-{version}"),
76        |name, _target, version| format!("{name}-v{version}"),
77        |name, _target, _version| name.to_string(),
78    ];
79
80    let default_bin_dir_template = Cow::Borrowed("{ bin }{ binary-ext }");
81
82    gen_possible_dirs
83        .into_iter()
84        .map(|gen_possible_dir| gen_possible_dir(name, target, version))
85        .find(|dirname| has_dir(Path::new(&dirname)))
86        .map(|mut dir| {
87            dir.reserve_exact(1 + default_bin_dir_template.len());
88            dir += "/";
89            dir += &default_bin_dir_template;
90            Cow::Owned(dir)
91        })
92        // Fallback to no dir
93        .unwrap_or(default_bin_dir_template)
94}
95
96pub struct BinFile {
97    pub base_name: CompactString,
98    pub source: PathBuf,
99    pub archive_source_path: PathBuf,
100    pub dest: PathBuf,
101    pub link: Option<PathBuf>,
102}
103
104impl BinFile {
105    /// * `tt` - must have a template with name "bin_dir"
106    pub fn new(
107        data: &Data<'_>,
108        base_name: &str,
109        tt: &Template<'_>,
110        no_symlinks: bool,
111    ) -> Result<Self, Error> {
112        let binary_ext = if data.target.contains("windows") {
113            ".exe"
114        } else {
115            ""
116        };
117
118        let ctx = Context {
119            name: data.name,
120            repo: data.repo,
121            target: data.target,
122            version: data.version,
123            bin: base_name,
124            binary_ext,
125
126            target_related_info: data.target_related_info,
127        };
128
129        let (source, archive_source_path) = if data.meta.pkg_fmt == Some(PkgFmt::Bin) {
130            (
131                data.bin_path.to_path_buf(),
132                data.bin_path.file_name().unwrap().into(),
133            )
134        } else {
135            // Generate install paths
136            // Source path is the download dir + the generated binary path
137            let path = tt.render(&ctx)?;
138
139            let path_normalized = Path::new(&path).normalize();
140
141            if path_normalized.components().next().is_none() {
142                return Err(Error::EmptySourceFilePath);
143            }
144
145            if !is_valid_path(&path_normalized) {
146                return Err(Error::InvalidSourceFilePath(path_normalized.into()));
147            }
148
149            (data.bin_path.join(&path_normalized), path_normalized)
150        };
151
152        // Destination at install dir + base-name{.extension}
153        let mut dest = data.install_path.join(ctx.bin);
154        if !binary_ext.is_empty() {
155            let binary_ext = binary_ext.strip_prefix('.').unwrap();
156
157            // PathBuf::set_extension returns false if Path::file_name
158            // is None, but we know that the file name must be Some,
159            // thus we assert! the return value here.
160            assert!(dest.set_extension(binary_ext));
161        }
162
163        let (dest, link) = if no_symlinks {
164            (dest, None)
165        } else {
166            // Destination path is the install dir + base-name-version{.extension}
167            let dest_file_path_with_ver = format!("{}-v{}{}", ctx.bin, ctx.version, ctx.binary_ext);
168            let dest_with_ver = data.install_path.join(dest_file_path_with_ver);
169
170            (dest_with_ver, Some(dest))
171        };
172
173        Ok(Self {
174            base_name: format_compact!("{base_name}{binary_ext}"),
175            source,
176            archive_source_path,
177            dest,
178            link,
179        })
180    }
181
182    pub fn preview_bin(&self) -> impl fmt::Display + '_ {
183        struct PreviewBin<'a> {
184            base_name: &'a str,
185            dest: path::Display<'a>,
186        }
187
188        impl fmt::Display for PreviewBin<'_> {
189            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190                write!(f, "{} => {}", self.base_name, self.dest)
191            }
192        }
193
194        PreviewBin {
195            base_name: &self.base_name,
196            dest: self.dest.display(),
197        }
198    }
199
200    pub fn preview_link(&self) -> impl fmt::Display + '_ {
201        OptionalLazyFormat(self.link.as_ref().map(|link| LazyFormat {
202            base_name: &self.base_name,
203            source: link.display(),
204            dest: self.link_dest().display(),
205        }))
206    }
207
208    /// Return `Ok` if the source exists, otherwise `Err`.
209    pub fn check_source_exists(
210        &self,
211        has_file: &mut dyn FnMut(&Path) -> bool,
212    ) -> Result<(), Error> {
213        if has_file(&self.archive_source_path) {
214            Ok(())
215        } else {
216            Err(Error::BinFileNotFound((&*self.source).into()))
217        }
218    }
219
220    fn pre_install_bin(&self) -> Result<(), Error> {
221        if !self.source.try_exists()? {
222            return Err(Error::BinFileNotFound((&*self.source).into()));
223        }
224
225        #[cfg(unix)]
226        std::fs::set_permissions(
227            &self.source,
228            std::os::unix::fs::PermissionsExt::from_mode(0o755),
229        )?;
230
231        Ok(())
232    }
233
234    pub fn install_bin(&self) -> Result<(), Error> {
235        self.pre_install_bin()?;
236
237        debug!(
238            "Atomically install file from '{}' to '{}'",
239            self.source.display(),
240            self.dest.display()
241        );
242
243        atomic_install(&self.source, &self.dest)?;
244
245        Ok(())
246    }
247
248    pub fn install_bin_noclobber(&self) -> Result<(), Error> {
249        self.pre_install_bin()?;
250
251        debug!(
252            "Installing file from '{}' to '{}' only if dst not exists",
253            self.source.display(),
254            self.dest.display()
255        );
256
257        atomic_install_noclobber(&self.source, &self.dest)?;
258
259        Ok(())
260    }
261
262    pub fn install_link(&self) -> Result<(), Error> {
263        if let Some(link) = &self.link {
264            let dest = self.link_dest();
265            debug!(
266                "Create link '{}' pointing to '{}'",
267                link.display(),
268                dest.display()
269            );
270            atomic_symlink_file(dest, link)?;
271        }
272
273        Ok(())
274    }
275
276    pub fn install_link_noclobber(&self) -> Result<(), Error> {
277        if let Some(link) = &self.link {
278            let dest = self.link_dest();
279            debug!(
280                "Create link '{}' pointing to '{}' only if dst not exists",
281                link.display(),
282                dest.display()
283            );
284            atomic_symlink_file_noclobber(dest, link)?;
285        }
286
287        Ok(())
288    }
289
290    fn link_dest(&self) -> &Path {
291        if cfg!(target_family = "unix") {
292            Path::new(self.dest.file_name().unwrap())
293        } else {
294            &self.dest
295        }
296    }
297}
298
299/// Data required to get bin paths
300pub struct Data<'a> {
301    pub name: &'a str,
302    pub target: &'a str,
303    pub version: &'a str,
304    pub repo: Option<&'a str>,
305    pub meta: PkgMeta,
306    pub bin_path: &'a Path,
307    pub install_path: &'a Path,
308    /// More target related info, it's recommend to provide the following keys:
309    ///  - target_family,
310    ///  - target_arch
311    ///  - target_libc
312    ///  - target_vendor
313    pub target_related_info: &'a dyn leon::Values,
314}
315
316#[derive(Clone)]
317struct Context<'c> {
318    name: &'c str,
319    repo: Option<&'c str>,
320    target: &'c str,
321    version: &'c str,
322    bin: &'c str,
323
324    /// Filename extension on the binary, i.e. .exe on Windows, nothing otherwise
325    binary_ext: &'c str,
326
327    target_related_info: &'c dyn leon::Values,
328}
329
330impl leon::Values for Context<'_> {
331    fn get_value<'s>(&'s self, key: &str) -> Option<Cow<'s, str>> {
332        match key {
333            "name" => Some(Cow::Borrowed(self.name)),
334            "repo" => self.repo.map(Cow::Borrowed),
335            "target" => Some(Cow::Borrowed(self.target)),
336            "version" => Some(Cow::Borrowed(self.version)),
337            "bin" => Some(Cow::Borrowed(self.bin)),
338            "binary-ext" => Some(Cow::Borrowed(self.binary_ext)),
339            // Soft-deprecated alias for binary-ext
340            "format" => Some(Cow::Borrowed(self.binary_ext)),
341
342            key => self.target_related_info.get_value(key),
343        }
344    }
345}
346
347struct LazyFormat<'a> {
348    base_name: &'a str,
349    source: path::Display<'a>,
350    dest: path::Display<'a>,
351}
352
353impl fmt::Display for LazyFormat<'_> {
354    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355        write!(f, "{} ({} -> {})", self.base_name, self.source, self.dest)
356    }
357}
358
359struct OptionalLazyFormat<'a>(Option<LazyFormat<'a>>);
360
361impl fmt::Display for OptionalLazyFormat<'_> {
362    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
363        if let Some(lazy_format) = self.0.as_ref() {
364            fmt::Display::fmt(lazy_format, f)
365        } else {
366            Ok(())
367        }
368    }
369}