1use 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#[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
31pub 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 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
58pub 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
65pub 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
75pub 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
88pub fn hash_folder(folder_path: impl AsRef<Path>) -> Result<IntegrityChecksum, std::io::Error> {
94 debug!(path:? = folder_path.as_ref(); "hashing folder");
95 let root_path = Arc::new(dunce::canonicalize(folder_path.as_ref())?);
97
98 let (tx, rx) = mpsc::channel::<[u8; 32]>();
99
100 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 Box::new(move |result| {
116 let Ok(entry) = result else {
117 return WalkState::Continue;
118 };
119 let path = entry.path();
120 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 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 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 let mut hashes = Vec::new();
149 while let Ok(msg) = rx.recv() {
150 hashes.push(msg);
151 }
152 hashes.par_sort_unstable();
154 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
164pub 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
175pub 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
208pub 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
235pub 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
265pub 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
274pub fn canonicalize_sync(path: impl AsRef<Path>) -> Result<PathBuf, std::io::Error> {
279 dunce::canonicalize(path)
280}
281
282pub 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 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(); 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 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}