Skip to main content

soldeer_core/
utils.rs

1//! Utility functions used throughout the codebase.
2use crate::{
3    config::Dependency,
4    errors::{DownloadError, InstallError},
5    registry::parse_version_req,
6};
7use derive_more::derive::{Display, From};
8use ignore::{WalkBuilder, WalkState};
9use log::{debug, warn};
10use path_slash::PathExt as _;
11use rayon::prelude::*;
12use semver::Version;
13use sha2::{Digest as _, Sha256};
14use std::{
15    borrow::Cow,
16    env,
17    ffi::OsStr,
18    fs,
19    io::Read,
20    path::{Path, PathBuf},
21    sync::{Arc, mpsc},
22};
23use tokio::process::Command;
24
25/// Newtype for the string representation of an integrity checksum (SHA256).
26#[derive(Debug, Clone, PartialEq, Eq, Hash, From, Display)]
27#[from(Cow<'static, str>, String, &'static str)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub struct IntegrityChecksum(pub String);
30
31/// Get the location where the token file is stored or read from.
32///
33/// The token file is stored in the home directory of the user, or in the current directory
34/// if the home cannot be found, in a hidden folder called `.soldeer`. The token file is called
35/// `.soldeer_login`.
36///
37/// The path can be overridden by setting the `SOLDEER_LOGIN_FILE` environment variable.
38pub fn login_file_path() -> Result<PathBuf, std::io::Error> {
39    if let Ok(file_path) = env::var("SOLDEER_LOGIN_FILE") &&
40        !file_path.is_empty()
41    {
42        debug!("using soldeer login file defined in environment variable");
43        return Ok(file_path.into());
44    }
45
46    // if home dir cannot be found, use the current dir
47    let dir = home::home_dir().unwrap_or(env::current_dir()?);
48    let security_directory = dir.join(".soldeer");
49    if !security_directory.exists() {
50        debug!(dir:?; ".soldeer folder does not exist, creating it");
51        fs::create_dir(&security_directory)?;
52    }
53    let login_file = security_directory.join(".soldeer_login");
54    debug!(login_file:?; "path to login file");
55    Ok(login_file)
56}
57
58/// Check if any filename in the list of paths starts with a period.
59pub fn check_dotfiles(files: &[PathBuf]) -> bool {
60    files
61        .par_iter()
62        .any(|file| file.file_name().unwrap_or_default().to_string_lossy().starts_with('.'))
63}
64
65/// Sanitize a filename by replacing invalid characters with a dash.
66pub fn sanitize_filename(dependency_name: &str) -> String {
67    let options =
68        sanitize_filename::Options { truncate: true, windows: cfg!(windows), replacement: "-" };
69
70    let filename = sanitize_filename::sanitize_with_options(dependency_name, options);
71    debug!(filename; "sanitized filename");
72    filename
73}
74
75/// Hash the contents of a Reader with SHA256
76pub fn hash_content<R: Read>(content: &mut R) -> [u8; 32] {
77    let mut hasher = Sha256::new();
78    let mut buf = [0; 1024];
79    while let Ok(size) = content.read(&mut buf) {
80        if size == 0 {
81            break;
82        }
83        hasher.update(&buf[0..size]);
84    }
85    hasher.finalize().into()
86}
87
88/// Walk a folder and compute the SHA256 hash of all non-hidden and non-ignored files inside the
89/// dir, combining them into a single hash.
90///
91/// The paths of the folders and files are hashes too, so we can the integrity of their names and
92/// location can be checked.
93pub fn hash_folder(folder_path: impl AsRef<Path>) -> Result<IntegrityChecksum, std::io::Error> {
94    debug!(path:? = folder_path.as_ref(); "hashing folder");
95    // a list of hashes, one for each DirEntry
96    let root_path = Arc::new(dunce::canonicalize(folder_path.as_ref())?);
97
98    let (tx, rx) = mpsc::channel::<[u8; 32]>();
99
100    // we use a parallel walker to speed things up
101    let walker = WalkBuilder::new(&folder_path)
102        .filter_entry(|entry| {
103            !(entry.path().is_dir() && entry.path().file_name().unwrap_or_default() == ".git")
104        })
105        .hidden(false)
106        .require_git(false)
107        .parents(false)
108        .git_global(false)
109        .git_exclude(false)
110        .build_parallel();
111    walker.run(|| {
112        let tx = tx.clone();
113        let root_path = Arc::clone(&root_path);
114        // function executed for each DirEntry
115        Box::new(move |result| {
116            let Ok(entry) = result else {
117                return WalkState::Continue;
118            };
119            let path = entry.path();
120            // first hash the filename/dirname to make sure it can't be renamed or removed
121            let mut hasher = Sha256::new();
122            hasher.update(
123                path.strip_prefix(root_path.as_ref())
124                    .expect("path should be a child of root")
125                    .to_slash_lossy()
126                    .as_bytes(),
127            );
128            // for files, also hash the contents
129            if let Some(true) = entry.file_type().map(|t| t.is_file()) {
130                if let Ok(file) = fs::File::open(path) {
131                    let mut reader = std::io::BufReader::new(file);
132                    let hash = hash_content(&mut reader);
133                    hasher.update(hash);
134                } else {
135                    warn!(path:?; "could not read file while hashing folder");
136                }
137            }
138            // record the hash for that file/folder in the list
139            let hash: [u8; 32] = hasher.finalize().into();
140            tx.send(hash)
141                .expect("Channel receiver should never be dropped before end of function scope");
142            WalkState::Continue
143        })
144    });
145    drop(tx);
146    let mut hasher = Sha256::new();
147    // this cannot happen before tx is dropped safely
148    let mut hashes = Vec::new();
149    while let Ok(msg) = rx.recv() {
150        hashes.push(msg);
151    }
152    // sort hashes
153    hashes.par_sort_unstable();
154    // hash the hashes (yo dawg...)
155    for hash in hashes.iter() {
156        hasher.update(hash);
157    }
158    let hash: [u8; 32] = hasher.finalize().into();
159    let hash = const_hex::encode(hash);
160    debug!(path:? = folder_path.as_ref(), hash; "folder hash was computed");
161    Ok(hash.into())
162}
163
164/// Compute the SHA256 hash of the contents of a file
165pub fn hash_file(path: impl AsRef<Path>) -> Result<IntegrityChecksum, std::io::Error> {
166    debug!(path:? = path.as_ref(); "hashing file");
167    let file = fs::File::open(&path)?;
168    let mut reader = std::io::BufReader::new(file);
169    let bytes = hash_content(&mut reader);
170    let hash = const_hex::encode(bytes);
171    debug!(path:? = path.as_ref(), hash; "file hash was computed");
172    Ok(hash.into())
173}
174
175/// Run a `git` command with the given arguments in the given directory.
176///
177/// The function output is parsed as a UTF-8 string and returned.
178pub async fn run_git_command<I, S>(
179    args: I,
180    current_dir: Option<&PathBuf>,
181) -> Result<String, DownloadError>
182where
183    I: IntoIterator<Item = S> + Clone,
184    S: AsRef<OsStr>,
185{
186    let mut git = Command::new("git");
187    git.args(args.clone()).env("GIT_TERMINAL_PROMPT", "0");
188    if let Some(current_dir) = current_dir {
189        git.current_dir(
190            canonicalize(current_dir)
191                .await
192                .map_err(|e| DownloadError::IOError { path: current_dir.clone(), source: e })?,
193        );
194    }
195    let git = git.output().await.map_err(|e| DownloadError::GitError {
196        message: e.to_string(),
197        args: args.clone().into_iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect(),
198    })?;
199    if !git.status.success() {
200        return Err(DownloadError::GitError {
201            message: String::from_utf8(git.stderr).unwrap_or_default(),
202            args: args.into_iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect(),
203        });
204    }
205    Ok(String::from_utf8(git.stdout).expect("git command output should be valid utf-8"))
206}
207
208/// Run a `forge` command with the given arguments in the given directory.
209///
210/// The function output is parsed as a UTF-8 string and returned.
211pub async fn run_forge_command<I, S>(
212    args: I,
213    current_dir: Option<&PathBuf>,
214) -> Result<String, InstallError>
215where
216    I: IntoIterator<Item = S>,
217    S: AsRef<OsStr>,
218{
219    let mut forge = Command::new("forge");
220    forge.args(args);
221    if let Some(current_dir) = current_dir {
222        forge.current_dir(
223            canonicalize(current_dir)
224                .await
225                .map_err(|e| InstallError::IOError { path: current_dir.clone(), source: e })?,
226        );
227    }
228    let forge = forge.output().await.map_err(|e| InstallError::ForgeError(e.to_string()))?;
229    if !forge.status.success() {
230        return Err(InstallError::ForgeError(String::from_utf8(forge.stderr).unwrap_or_default()));
231    }
232    Ok(String::from_utf8(forge.stdout).expect("forge command output should be valid utf-8"))
233}
234
235/// Remove/uninstall the `forge-std` library installed as a git submodule in a foundry project.
236///
237/// This function removes the `forge-std` submodule, the `.gitmodules` file and the `lib` directory
238/// from the project.
239pub async fn remove_forge_lib(root: impl AsRef<Path>) -> Result<(), InstallError> {
240    debug!("removing forge-std installed as a git submodule");
241    let gitmodules_path = root.as_ref().join(".gitmodules");
242    let lib_dir = root.as_ref().join("lib");
243    let forge_std_dir = lib_dir.join("forge-std");
244    if forge_std_dir.exists() {
245        run_git_command(
246            &["rm", &forge_std_dir.to_string_lossy()],
247            Some(&root.as_ref().to_path_buf()),
248        )
249        .await?;
250        debug!("removed lib/forge-std");
251    }
252    if lib_dir.exists() {
253        fs::remove_dir_all(&lib_dir)
254            .map_err(|e| InstallError::IOError { path: lib_dir.clone(), source: e })?;
255        debug!("removed lib dir");
256    }
257    if gitmodules_path.exists() {
258        fs::remove_file(&gitmodules_path)
259            .map_err(|e| InstallError::IOError { path: lib_dir, source: e })?;
260        debug!("removed .gitmodules file");
261    }
262    Ok(())
263}
264
265/// Canonicalize a path, resolving symlinks and relative paths.
266///
267/// This function also normalizes paths on Windows to use the MS-DOS format (as opposed to UNC)
268/// whenever possible.
269pub async fn canonicalize(path: impl AsRef<Path>) -> Result<PathBuf, std::io::Error> {
270    let path = path.as_ref().to_path_buf();
271    tokio::task::spawn_blocking(move || dunce::canonicalize(&path)).await?
272}
273
274/// Canonicalize a path, resolving symlinks and relative paths, synchronously.
275///
276/// This function also normalizes paths on Windows to use the MS-DOS format (as opposed to UNC)
277/// whenever possible.
278pub fn canonicalize_sync(path: impl AsRef<Path>) -> Result<PathBuf, std::io::Error> {
279    dunce::canonicalize(path)
280}
281
282/// Check if a path corresponds to the provided dependency.
283///
284/// The folder does not need to exist. The folder name must start with the dependency name
285/// (sanitized). For dependencies with a semver-compliant version requirement, any folder with a
286/// version that matches will give a result of `true`. Otherwise, the folder name must contain the
287/// version requirement string after the dependency name.
288pub fn path_matches(dependency: &Dependency, path: impl AsRef<Path>) -> bool {
289    let path = path.as_ref();
290    let Some(dir_name) = path.file_name() else {
291        return false;
292    };
293    let dir_name = dir_name.to_string_lossy();
294    let prefix = format!("{}-", sanitize_filename(dependency.name()));
295    if !dir_name.starts_with(&prefix) {
296        return false;
297    }
298    match (
299        parse_version_req(dependency.version_req()),
300        Version::parse(dir_name.strip_prefix(&prefix).expect("prefix should be present")),
301    ) {
302        (None, _) | (Some(_), Err(_)) => {
303            // not semver compliant
304            dir_name == format!("{prefix}{}", sanitize_filename(dependency.version_req()))
305        }
306        (Some(version_req), Ok(version)) => version_req.matches(&version),
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use std::fs;
314    use testdir::testdir;
315
316    fn create_test_folder(name: Option<&str>) -> PathBuf {
317        let dir = testdir!();
318        let named_dir = match name {
319            None => dir,
320            Some(name) => {
321                let d = dir.join(name);
322                fs::create_dir(&d).unwrap();
323                d
324            }
325        };
326        fs::write(named_dir.join("a.txt"), "this is a test file").unwrap();
327        fs::write(named_dir.join("b.txt"), "this is a second test file").unwrap();
328        fs::write(named_dir.join("ignored.txt"), "this file should be ignored").unwrap();
329        fs::write(named_dir.join(".gitignore"), "ignored.txt\n").unwrap();
330        fs::write(
331            named_dir.parent().unwrap().join(".gitignore"),
332            format!("{}/a.txt", named_dir.file_name().unwrap().to_string_lossy()),
333        )
334        .unwrap(); // this file should be ignored because it's in the parent dir
335        dunce::canonicalize(named_dir).unwrap()
336    }
337
338    #[test]
339    fn test_hash_content() {
340        let mut content = "this is a test file".as_bytes();
341        let hash = hash_content(&mut content);
342        assert_eq!(
343            const_hex::encode(hash),
344            "5881707e54b0112f901bc83a1ffbacac8fab74ea46a6f706a3efc5f7d4c1c625".to_string()
345        );
346    }
347
348    #[test]
349    fn test_hash_content_content_sensitive() {
350        let mut content = "foobar".as_bytes();
351        let hash = hash_content(&mut content);
352        let mut content2 = "baz".as_bytes();
353        let hash2 = hash_content(&mut content2);
354        assert_ne!(hash, hash2);
355    }
356
357    #[test]
358    fn test_hash_file() {
359        let path = testdir!().join("test.txt");
360        fs::write(&path, "this is a test file").unwrap();
361        let hash = hash_file(&path).unwrap();
362        assert_eq!(hash, "5881707e54b0112f901bc83a1ffbacac8fab74ea46a6f706a3efc5f7d4c1c625".into());
363    }
364
365    #[test]
366    fn test_hash_folder_abs_path_insensitive() {
367        let folder1 = create_test_folder(Some("dir1"));
368        let folder2 = create_test_folder(Some("dir2"));
369        let hash1 = hash_folder(&folder1).unwrap();
370        let hash2 = hash_folder(&folder2).unwrap();
371        assert_eq!(
372            hash1.to_string(),
373            "c5328a2c3db7582b9074d5f5263ef111b496bbf9cda9b6c5fb0f97f1dc17b766"
374        );
375        assert_eq!(hash1, hash2);
376        // ignored.txt should be ignored in the checksum calculation, so removing it should yield
377        // the same checksum
378        fs::remove_file(folder1.join("ignored.txt")).unwrap();
379        let hash1 = hash_folder(&folder1).unwrap();
380        assert_eq!(hash1, hash2);
381    }
382
383    #[test]
384    fn test_hash_folder_rel_path_sensitive() {
385        let folder = create_test_folder(Some("dir"));
386        let hash1 = hash_folder(&folder).unwrap();
387        fs::rename(folder.join("a.txt"), folder.join("c.txt")).unwrap();
388        let hash2 = hash_folder(&folder).unwrap();
389        assert_ne!(hash1, hash2);
390    }
391
392    #[test]
393    fn test_hash_folder_content_sensitive() {
394        let folder = create_test_folder(Some("dir"));
395        let hash1 = hash_folder(&folder).unwrap();
396        fs::create_dir(folder.join("test")).unwrap();
397        let hash2 = hash_folder(&folder).unwrap();
398        assert_ne!(hash1, hash2);
399        fs::write(folder.join("test/c.txt"), "this is a third test file").unwrap();
400        let hash3 = hash_folder(&folder).unwrap();
401        assert_ne!(hash2, hash3);
402        assert_ne!(hash1, hash3);
403    }
404}