1use bytes::Bytes;
2use camino::Utf8Path;
3use ignore::gitignore::Gitignore;
4use nix_nar::{Encoder, FileSystem, FileSystemMetadata, NativeFileSystem};
5use ssri::{Algorithm, Integrity, IntegrityOpts};
6use std::fs::File;
7use std::future::Future;
8use std::io;
9use std::path::{Path, PathBuf};
10
11pub trait HasIntegrity {
12 fn hash(&self) -> impl Future<Output = io::Result<Integrity>> + Send;
13}
14
15impl HasIntegrity for PathBuf {
16 #[tracing::instrument(level = "trace", skip_all)]
17 async fn hash(&self) -> io::Result<Integrity> {
18 let path = std::fs::canonicalize(self)?;
21 tokio::task::spawn_blocking(move || {
22 let mut integrity_opts = IntegrityOpts::new().algorithm(Algorithm::Sha256);
23 if path.is_dir() {
24 let filesystem = VcsExcludingFileSystem::new(&path);
27 let mut enc =
28 Encoder::new_with_filesystem(path, filesystem).map_err(io::Error::other)?;
29 io::copy(&mut enc, &mut integrity_opts)?;
30 } else if path.is_file() {
31 hash_file(&path, &mut integrity_opts)?;
32 }
33 Ok(integrity_opts.result())
34 })
35 .await
36 .map_err(io::Error::other)?
37 }
38}
39
40impl HasIntegrity for Path {
41 async fn hash(&self) -> io::Result<Integrity> {
42 let path_buf: PathBuf = self.into();
43 path_buf.hash().await
44 }
45}
46
47impl HasIntegrity for Bytes {
48 #[tracing::instrument(level = "trace", skip_all)]
49 async fn hash(&self) -> io::Result<Integrity> {
50 let bytes = self.clone();
51 tokio::task::spawn_blocking(move || {
52 let mut integrity_opts = IntegrityOpts::new().algorithm(Algorithm::Sha256);
53 integrity_opts.input(&bytes);
54 Ok(integrity_opts.result())
55 })
56 .await
57 .map_err(io::Error::other)?
58 }
59}
60
61fn hash_file(path: &Path, integrity_opts: &mut IntegrityOpts) -> io::Result<()> {
62 let mut file = File::open(path)?;
63 io::copy(&mut file, integrity_opts)?;
64 Ok(())
65}
66
67struct VcsExcludingFileSystem {
72 gitignore: Gitignore,
73}
74
75impl VcsExcludingFileSystem {
76 fn new(root: &Path) -> Self {
77 let (gitignore, err) = Gitignore::new(root.join(".gitignore"));
78 if let Some(err) = err {
79 tracing::debug!(
80 message =
81 format!("failed to parse `.gitignore` in {}: {err}", root.display()).as_str()
82 );
83 }
84 Self { gitignore }
85 }
86}
87
88fn is_vcs_dir(name: &str) -> bool {
89 matches!(name, ".git" | ".jj" | ".hg" | "_darcs" | ".svn" | ".bzr")
90}
91
92impl FileSystem for VcsExcludingFileSystem {
93 type File = std::fs::File;
94
95 fn open(&self, path: &Utf8Path) -> io::Result<Self::File> {
96 FileSystem::open(&NativeFileSystem {}, path)
97 }
98
99 fn read_dir(&self, path: &Utf8Path) -> io::Result<Vec<String>> {
100 Ok(FileSystem::read_dir(&NativeFileSystem {}, path)?
101 .into_iter()
102 .filter(|name| !is_vcs_dir(name))
103 .filter(|name| {
104 let full = path.join(name);
105 let is_dir = full.as_std_path().is_dir();
106 !self
107 .gitignore
108 .matched(full.as_std_path(), is_dir)
109 .is_ignore()
110 })
111 .collect())
112 }
113
114 fn metadata(&self, path: &Utf8Path) -> io::Result<FileSystemMetadata> {
115 FileSystem::metadata(&NativeFileSystem {}, path)
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use assert_fs::prelude::*;
123 use std::{fs::write, process::Command};
124
125 #[cfg(unix)]
126 fn nix_hash(path: &Path) -> Integrity {
128 let ssri_str = Command::new("nix-hash")
129 .args(vec!["--sri", "--type", "sha256"])
130 .arg(path)
131 .output()
132 .unwrap()
133 .stdout;
134 String::from_utf8_lossy(&ssri_str).parse().unwrap()
135 }
136
137 #[cfg(unix)]
138 fn nix_hash_file(path: &Path) -> Integrity {
140 let ssri_str = Command::new("nix-hash")
141 .args(vec!["--sri", "--type", "sha256", "--flat"])
142 .arg(path)
143 .output()
144 .unwrap()
145 .stdout;
146 String::from_utf8_lossy(&ssri_str).parse().unwrap()
147 }
148
149 #[tokio::test]
150 async fn test_hash_empty_dir() {
151 let temp = assert_fs::TempDir::new().unwrap();
152 let hash1 = temp.path().to_path_buf().hash().await.unwrap();
153 let hash2 = temp.path().to_path_buf().hash().await.unwrap();
154 assert_eq!(hash1, hash2);
155 let nix_hash = nix_hash(temp.path());
156 assert_eq!(hash1, nix_hash);
157 }
158
159 #[tokio::test]
160 #[cfg(unix)]
161 async fn test_hash_file() {
162 let temp = assert_fs::TempDir::new().unwrap();
163 let file = temp.child("test.txt");
164 file.write_str("test content").unwrap();
165
166 let hash = file.path().to_path_buf().hash().await.unwrap();
167 let nix_hash = nix_hash_file(file.path());
168 assert_eq!(hash, nix_hash);
169 }
170
171 #[tokio::test]
172 async fn test_hash_dir_with_single_file() {
173 let temp = assert_fs::TempDir::new().unwrap();
174 let file = temp.child("test.txt");
175 file.write_str("test content").unwrap();
176
177 let hash1 = temp.path().to_path_buf().hash().await.unwrap();
178 let hash2 = temp.path().to_path_buf().hash().await.unwrap();
179 assert_eq!(hash1, hash2);
180
181 #[cfg(unix)]
182 {
183 let nix_hash = nix_hash(temp.path());
184 assert_eq!(hash1, nix_hash);
185 }
186 }
187
188 #[tokio::test]
189 async fn test_hash_multiple_files_different_creation_order() {
190 let temp = assert_fs::TempDir::new().unwrap();
191
192 write(temp.child("a.txt").path(), "content a").unwrap();
193 write(temp.child("b.txt").path(), "content b").unwrap();
194 write(temp.child("c.txt").path(), "content c").unwrap();
195 let hash1 = temp.path().to_path_buf().hash().await.unwrap();
196
197 let temp2 = assert_fs::TempDir::new().unwrap();
198 write(temp2.child("c.txt").path(), "content c").unwrap();
199 write(temp2.child("a.txt").path(), "content a").unwrap();
200 write(temp2.child("b.txt").path(), "content b").unwrap();
201 let hash2 = temp2.path().to_path_buf().hash().await.unwrap();
202
203 assert_eq!(hash1, hash2);
204
205 #[cfg(unix)]
206 {
207 let nix_hash = nix_hash(temp.path());
208 assert_eq!(hash1, nix_hash);
209 }
210 }
211
212 #[tokio::test]
213 async fn test_hash_nested_directories_different_creation_order() {
214 let temp = assert_fs::TempDir::new().unwrap();
215
216 temp.child("a/b").create_dir_all().unwrap();
217 temp.child("b").create_dir_all().unwrap();
218 write(temp.child("a/b/file1.txt").path(), "content 1").unwrap();
219 write(temp.child("a/file2.txt").path(), "content 2").unwrap();
220 write(temp.child("b/file3.txt").path(), "content 3").unwrap();
221 let hash1 = temp.path().to_path_buf().hash().await.unwrap();
222
223 let temp2 = assert_fs::TempDir::new().unwrap();
224 temp2.child("a/b").create_dir_all().unwrap();
225 temp2.child("b").create_dir_all().unwrap();
226 write(temp2.child("b/file3.txt").path(), "content 3").unwrap();
227 write(temp2.child("a/file2.txt").path(), "content 2").unwrap();
228 write(temp2.child("a/b/file1.txt").path(), "content 1").unwrap();
229 let hash2 = temp2.path().to_path_buf().hash().await.unwrap();
230
231 assert_eq!(hash1, hash2);
232 }
233
234 #[tokio::test]
235 async fn test_hash_with_different_line_endings() {
236 let temp = assert_fs::TempDir::new().unwrap();
237 write(temp.child("unix.txt").path(), "line1\nline2\n").unwrap();
238 let hash1 = temp.path().to_path_buf().hash().await.unwrap();
239
240 let temp2 = assert_fs::TempDir::new().unwrap();
241 write(temp2.child("windows.txt").path(), "line1\r\nline2\r\n").unwrap();
242 let hash2 = temp2.path().to_path_buf().hash().await.unwrap();
243
244 assert_ne!(hash1, hash2);
245 }
246
247 #[tokio::test]
248 async fn test_hash_ignores_vcs_directories() {
249 let temp = assert_fs::TempDir::new().unwrap();
250 write(temp.child("src.lua").path(), "return true").unwrap();
251 temp.child(".git").create_dir_all().unwrap();
252 write(temp.child(".git/config").path(), "nondeterministic").unwrap();
253 temp.child(".jj").create_dir_all().unwrap();
254 write(temp.child(".jj/repo").path(), "nondeterministic").unwrap();
255
256 let hash_with_vcs = temp.path().to_path_buf().hash().await.unwrap();
257
258 let temp2 = assert_fs::TempDir::new().unwrap();
259 write(temp2.child("src.lua").path(), "return true").unwrap();
260
261 let hash_without_vcs = temp2.path().to_path_buf().hash().await.unwrap();
262
263 assert_eq!(hash_with_vcs, hash_without_vcs);
264 }
265
266 #[tokio::test]
267 async fn test_hash_ignores_gitignored_but_not_other_ignored_files() {
268 let temp = assert_fs::TempDir::new().unwrap();
269 write(temp.child("src.lua").path(), "return true").unwrap();
270 temp.child(".gitignore").write_str(".foo/\n").unwrap();
271 temp.child(".ignore").write_str("target/\n").unwrap();
272 temp.child(".foo").create_dir_all().unwrap();
273 write(temp.child(".foo/env").path(), "bar").unwrap();
274 temp.child("target").create_dir_all().unwrap();
275 write(temp.child("target/build.o").path(), "blob").unwrap();
276
277 let filtered = temp.path().to_path_buf().hash().await.unwrap();
278
279 let expected_tree = assert_fs::TempDir::new().unwrap();
280 write(expected_tree.child("src.lua").path(), "return true").unwrap();
281 expected_tree
282 .child(".gitignore")
283 .write_str(".foo/\n")
284 .unwrap();
285 expected_tree
286 .child(".ignore")
287 .write_str("target/\n")
288 .unwrap();
289 expected_tree.child("target").create_dir_all().unwrap();
290 write(expected_tree.child("target/build.o").path(), "blob").unwrap();
291 let expected = expected_tree.path().to_path_buf().hash().await.unwrap();
292
293 assert_eq!(filtered, expected);
294 }
295
296 #[tokio::test]
297 async fn test_hash_matches_plain_nar_without_vcs_dirs() {
298 let temp = assert_fs::TempDir::new().unwrap();
299 write(temp.child("src.lua").path(), "return true").unwrap();
300 temp.child("lua").create_dir_all().unwrap();
301 write(temp.child("lua/foo.lua").path(), "return 1").unwrap();
302
303 let filtered = temp.path().to_path_buf().hash().await.unwrap();
304
305 let mut opts = IntegrityOpts::new().algorithm(Algorithm::Sha256);
306 let mut enc = Encoder::new(temp.path()).unwrap();
307 io::copy(&mut enc, &mut opts).unwrap();
308 let plain = opts.result();
309
310 assert_eq!(filtered, plain);
311 }
312
313 #[tokio::test]
314 async fn test_hash_with_symlinks() {
315 let temp = assert_fs::TempDir::new().unwrap();
316
317 write(temp.child("target.txt").path(), "content").unwrap();
318
319 #[cfg(target_family = "unix")]
320 std::os::unix::fs::symlink(
321 temp.child("target.txt").path(),
322 temp.child("link.txt").path(),
323 )
324 .unwrap();
325 #[cfg(target_family = "windows")]
326 std::os::windows::fs::symlink_file(
327 temp.child("target.txt").path(),
328 temp.child("link.txt").path(),
329 )
330 .unwrap();
331
332 let hash1 = temp.path().to_path_buf().hash().await.unwrap();
333
334 let temp2 = assert_fs::TempDir::new().unwrap();
335 write(temp2.child("target.txt").path(), "content").unwrap();
336 let hash2 = temp2.path().to_path_buf().hash().await.unwrap();
337
338 assert_ne!(hash1, hash2);
339
340 #[cfg(unix)]
341 {
342 let nix_hash = nix_hash(temp.path());
343 assert_eq!(hash1, nix_hash);
344 }
345 }
346
347 #[tokio::test]
348 #[cfg(unix)]
349 async fn test_hash_dereferences_symlinked_directory() {
350 let outer = assert_fs::TempDir::new().unwrap();
351 let real = assert_fs::TempDir::new().unwrap();
352 write(real.child("src.lua").path(), "return true").unwrap();
353
354 let link = outer.path().join("link");
355 std::os::unix::fs::symlink(real.path(), &link).unwrap();
356
357 let hash_via_link = link.hash().await.unwrap();
358 let hash_real = real.path().to_path_buf().hash().await.unwrap();
359
360 assert_eq!(hash_via_link, hash_real);
361 }
362}