crev_common/
lib.rs

1//! Bunch of code that is auxiliary and common for all `crev`
2#![allow(clippy::missing_errors_doc)]
3#![allow(clippy::missing_panics_doc)]
4#![allow(clippy::module_name_repetitions)]
5#![allow(clippy::redundant_closure_for_method_calls)]
6
7pub mod blake2b256;
8pub mod fs;
9pub mod rand;
10pub mod serde;
11
12pub use crate::blake2b256::Blake2b256;
13
14use base64::engine::general_purpose::URL_SAFE_NO_PAD;
15use base64::Engine;
16use blake2::{digest::FixedOutput, Digest};
17use std::{
18    collections::HashSet,
19    ffi::OsStr,
20    io::{self, BufRead, Write},
21    path::{Path, PathBuf},
22    process,
23};
24
25#[derive(Debug, thiserror::Error)]
26pub enum YAMLIOError {
27    #[error("I/O: {}", _0)]
28    IO(#[from] std::io::Error),
29
30    #[error("Can't save to root path")]
31    RootPath,
32
33    #[error("YAML: {}", _0)]
34    YAML(#[from] serde_yaml::Error),
35}
36
37/// Now with a fixed offset of the current system timezone
38#[must_use]
39pub fn now() -> chrono::DateTime<chrono::offset::FixedOffset> {
40    let date = chrono::offset::Local::now();
41    date.with_timezone(date.offset())
42}
43
44#[must_use]
45pub fn blake2b256sum(bytes: &[u8]) -> [u8; 32] {
46    let mut hasher = Blake2b256::new();
47    hasher.update(bytes);
48    hasher.finalize_fixed().into()
49}
50
51pub fn blake2b256sum_file(path: &Path) -> io::Result<[u8; 32]> {
52    let mut hasher = Blake2b256::new();
53    read_file_to_digest_input(path, &mut hasher)?;
54    Ok(hasher.finalize_fixed().into())
55}
56
57pub fn base64_decode<T: ?Sized + AsRef<[u8]>>(input: &T) -> Result<Vec<u8>, base64::DecodeError> {
58    URL_SAFE_NO_PAD.decode(input)
59}
60
61pub fn base64_encode<T: ?Sized + AsRef<[u8]>>(input: &T) -> String {
62    URL_SAFE_NO_PAD.encode(input)
63}
64
65/// Takes a name and converts it to something safe for use in paths etc.
66///
67/// # Examples
68///
69/// ```
70/// # use std::path::Path;
71/// # use crev_common::sanitize_name_for_fs;
72/// // Pass through when able
73/// assert_eq!(sanitize_name_for_fs("lazy_static"), Path::new("lazy_static-Bda78Hdy9hiPaGTczi9ADA"));
74///
75/// // Hash reserved windows filenames (or any other 3 letter name)
76/// assert_eq!(sanitize_name_for_fs("CON"), Path::new("CON--NhvzH8hSGvoA4DSfBFbpg"));
77///
78/// // Hash on escaped chars to avoid collisions
79/// assert_eq!(sanitize_name_for_fs("://baluga.?io"), Path::new("___baluga__io-7zPdDFu-AyMMKrFrpmY7BQ"));
80///
81/// // Limit absurdly long names.  Combining a bunch of these can still run into filesystem limits however.
82/// let a16   = std::iter::repeat("a").take(  16).collect::<String>();
83/// let a2048 = std::iter::repeat("a").take(2048).collect::<String>();
84/// let a2049 = std::iter::repeat("a").take(2049).collect::<String>();
85/// assert_eq!(sanitize_name_for_fs(a2048.as_str()).to_str().unwrap(), format!("{}-4iupJgrBwxluPQ8DRmrnXg", a16));
86/// assert_eq!(sanitize_name_for_fs(a2049.as_str()).to_str().unwrap(), format!("{}-VMRqy6kfWHPoPp1iKIGt1A", a16));
87/// ```
88#[must_use]
89pub fn sanitize_name_for_fs(s: &str) -> PathBuf {
90    let mut buffer = String::new();
91    for ch in s.chars().take(16) {
92        match ch {
93            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => buffer.push(ch),
94            _ => {
95                // Intentionally 'escaped' here:
96                //  '.' (path navigation attacks, and windows doesn't like leading/trailing '.'s)
97                //  ':' (windows reserves this for drive letters)
98                //  '/', '\\' (path navigation attacks)
99                // Unicode, Punctuation (out of an abundance of cross platform paranoia)
100                buffer.push('_');
101            }
102        }
103    }
104    buffer.push('-');
105    buffer.push_str(&base64_encode(&blake2b256sum(s.as_bytes())[..16]));
106    PathBuf::from(buffer)
107}
108
109/// Takes an url and converts it to something safe for use in paths etc.
110///
111/// # Examples
112///
113/// ```
114/// # use std::path::Path;
115/// # use crev_common::sanitize_url_for_fs;
116/// // Hash on escaped chars to avoid collisions
117/// assert_eq!(sanitize_url_for_fs("https://crates.io"), Path::new("crates_io-yTEHLALL07ZuqIYj8EHFkg"));
118///
119/// // Limit absurdly long names.  Combining a bunch of these can still run into filesystem limits however.
120/// let a48   = std::iter::repeat("a").take(  48).collect::<String>();
121/// let a2048 = std::iter::repeat("a").take(2048).collect::<String>();
122/// let a2049 = std::iter::repeat("a").take(2049).collect::<String>();
123/// assert_eq!(sanitize_url_for_fs(a2048.as_str()).to_str().unwrap(), format!("{}-4iupJgrBwxluPQ8DRmrnXg", a48));
124/// assert_eq!(sanitize_url_for_fs(a2049.as_str()).to_str().unwrap(), format!("{}-VMRqy6kfWHPoPp1iKIGt1A", a48));
125/// ```
126#[must_use]
127pub fn sanitize_url_for_fs(url: &str) -> PathBuf {
128    let mut buffer = String::new();
129
130    let trimmed = url.trim();
131
132    let stripped = if let Some(t) = trimmed.strip_prefix("http://") {
133        t
134    } else if let Some(t) = trimmed.strip_prefix("https://") {
135        t
136    } else {
137        trimmed
138    };
139
140    for ch in stripped.chars().take(48) {
141        match ch {
142            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => buffer.push(ch),
143            _ => {
144                // Intentionally 'escaped' here:
145                //  '.' (path navigation attacks, and windows doesn't like leading/trailing '.'s)
146                //  ':' (windows reserves this for drive letters)
147                //  '/', '\\' (path navigation attacks)
148                // Unicode, Punctuation (out of an abundance of cross platform paranoia)
149                buffer.push('_');
150            }
151        }
152    }
153    buffer.push('-');
154    buffer.push_str(&base64_encode(&blake2b256sum(trimmed.as_bytes())[..16]));
155    PathBuf::from(buffer)
156}
157
158pub fn is_equal_default<T: Default + PartialEq>(t: &T) -> bool {
159    *t == T::default()
160}
161
162pub fn is_vec_empty<T>(t: &[T]) -> bool {
163    t.is_empty()
164}
165
166#[must_use]
167pub fn is_set_empty<T>(t: &HashSet<T>) -> bool {
168    t.is_empty()
169}
170
171pub fn read_file_to_digest_input(
172    path: &Path,
173    input: &mut impl blake2::digest::Update,
174) -> io::Result<()> {
175    let file = std::fs::File::open(path)?;
176
177    let mut reader = io::BufReader::new(file);
178
179    loop {
180        let length = {
181            let buffer = reader.fill_buf()?;
182            input.update(buffer);
183            buffer.len()
184        };
185        if length == 0 {
186            break;
187        }
188        reader.consume(length);
189    }
190
191    Ok(())
192}
193
194#[derive(Debug, thiserror::Error)]
195pub enum CancelledError {
196    #[error("Cancelled by the user")]
197    ByUser,
198    #[error("Cancelled due to terminal I/O error")]
199    NoInput,
200}
201
202pub fn try_again_or_cancel() -> std::result::Result<(), CancelledError> {
203    if !yes_or_no_was_y("Try again (Y/n)")
204        .map_err(|_| CancelledError::NoInput)?
205        .unwrap_or(true)
206    {
207        return Err(CancelledError::ByUser);
208    }
209
210    Ok(())
211}
212
213pub fn yes_or_no_was_y(msg: &str) -> io::Result<Option<bool>> {
214    loop {
215        let reply = rprompt::prompt_reply_from_bufread(
216            &mut std::io::stdin().lock(),
217            &mut std::io::stderr(),
218            format!("{msg} "),
219        )?;
220
221        match reply.as_str() {
222            "y" | "Y" => return Ok(Some(true)),
223            "n" | "N" => return Ok(Some(false)),
224            "" => return Ok(None),
225            _ => {}
226        }
227    }
228}
229
230pub fn run_with_shell_cmd(cmd: &OsStr, arg: Option<&Path>) -> io::Result<std::process::ExitStatus> {
231    Ok(run_with_shell_cmd_custom(cmd, arg, false)?.status)
232}
233
234pub fn run_with_shell_cmd_capture_stdout(cmd: &OsStr, arg: Option<&Path>) -> io::Result<Vec<u8>> {
235    let output = run_with_shell_cmd_custom(cmd, arg, true)?;
236    if !output.status.success() {
237        return Err(std::io::Error::new(
238            io::ErrorKind::Other,
239            "command failed with non-zero status",
240        ));
241    }
242    Ok(output.stdout)
243}
244
245pub fn run_with_shell_cmd_custom(
246    cmd: &OsStr,
247    arg: Option<&Path>,
248    capture_stdout: bool,
249) -> io::Result<std::process::Output> {
250    if cfg!(windows) {
251        // cmd.exe /c "..." or cmd.exe /k "..." avoid unescaping "...", which makes .arg()'s built-in escaping problematic:
252        // https://github.com/rust-lang/rust/blob/379c380a60e7b3adb6c6f595222cbfa2d9160a20/src/libstd/sys/windows/process.rs#L488
253        // We can bypass this by (ab)using env vars.  Bonus points:  invalid unicode still works.
254        let mut proc = process::Command::new("cmd.exe");
255        if let Some(arg) = arg {
256            proc.arg("/c").arg("%CREV_CMD% %CREV_ARG%");
257            proc.env("CREV_CMD", cmd);
258            proc.env("CREV_ARG", arg);
259        } else {
260            proc.arg("/c").arg("%CREV_CMD%");
261            proc.env("CREV_CMD", cmd);
262        }
263        proc
264    } else if cfg!(unix) {
265        let mut proc = process::Command::new("/bin/sh");
266        if let Some(arg) = arg {
267            proc.arg("-c").arg(format!(
268                "{} {}",
269                cmd.to_str().ok_or_else(|| std::io::Error::new(
270                    io::ErrorKind::InvalidData,
271                    "not a valid unicode"
272                ))?,
273                shell_escape::escape(arg.display().to_string().into())
274            ));
275        } else {
276            proc.arg("-c").arg(cmd);
277        }
278        proc
279    } else {
280        panic!("What platform are you running this on? Please submit a PR!");
281    }
282    .stdin(process::Stdio::inherit())
283    .stderr(process::Stdio::inherit())
284    .stdout(if capture_stdout {
285        process::Stdio::piped()
286    } else {
287        process::Stdio::inherit()
288    })
289    .output()
290}
291
292pub fn save_to_yaml_file<T>(path: &Path, t: &T) -> Result<(), YAMLIOError>
293where
294    T: ::serde::Serialize,
295{
296    std::fs::create_dir_all(path.parent().ok_or(YAMLIOError::RootPath)?)?;
297    let text = serde_yaml::to_string(t)?;
298    store_str_to_file(path, &text)?;
299    Ok(())
300}
301
302pub fn read_from_yaml_file<T>(path: &Path) -> Result<T, YAMLIOError>
303where
304    T: ::serde::de::DeserializeOwned,
305{
306    let text = std::fs::read_to_string(path)?;
307
308    Ok(serde_yaml::from_str(&text)?)
309}
310
311#[inline]
312pub fn store_str_to_file(path: &Path, s: &str) -> io::Result<()> {
313    store_to_file_with(path, |f| f.write_all(s.as_bytes())).and_then(|res| res)
314}
315
316pub fn store_to_file_with<E, F>(path: &Path, f: F) -> io::Result<Result<(), E>>
317where
318    F: Fn(&mut dyn io::Write) -> Result<(), E>,
319{
320    std::fs::create_dir_all(path.parent().expect("Not a root path"))?;
321    let tmp_path = path.with_extension("tmp");
322    let mut file = std::fs::File::create(&tmp_path)?;
323    if let Err(e) = f(&mut file) {
324        return Ok(Err(e));
325    }
326    file.flush()?;
327    file.sync_data()?;
328    drop(file);
329    std::fs::rename(tmp_path, path)?;
330    Ok(Ok(()))
331}