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 {
71 gitignore: Gitignore,
72}
73
74impl VcsExcludingFileSystem {
75 fn new(root: &Path) -> Self {
76 let (gitignore, err) = Gitignore::new(root.join(".gitignore"));
77 if let Some(err) = err {
78 tracing::debug!(
79 message =
80 format!("failed to parse `.gitignore` in {}: {err}", root.display()).as_str()
81 );
82 }
83 Self { gitignore }
84 }
85}
86
87fn is_vcs_dir(name: &str) -> bool {
88 matches!(name, ".git" | ".jj" | ".hg" | "_darcs" | ".svn" | ".bzr")
89}
90
91fn is_lux_generated(name: &str) -> bool {
92 matches!(
93 name,
94 crate::workspace::LUX_DIR_NAME | "lux.lock" | ".luarc.json" | ".emmyrc.json"
95 )
96}
97
98impl FileSystem for VcsExcludingFileSystem {
99 type File = std::fs::File;
100
101 fn open(&self, path: &Utf8Path) -> io::Result<Self::File> {
102 FileSystem::open(&NativeFileSystem {}, path)
103 }
104
105 fn read_dir(&self, path: &Utf8Path) -> io::Result<Vec<String>> {
106 Ok(FileSystem::read_dir(&NativeFileSystem {}, path)?
107 .into_iter()
108 .filter(|name| !is_vcs_dir(name) && !is_lux_generated(name))
109 .filter(|name| {
110 let full = path.join(name);
111 let is_dir = full.as_std_path().is_dir();
112 !self
113 .gitignore
114 .matched(full.as_std_path(), is_dir)
115 .is_ignore()
116 })
117 .collect())
118 }
119
120 fn metadata(&self, path: &Utf8Path) -> io::Result<FileSystemMetadata> {
121 FileSystem::metadata(&NativeFileSystem {}, path)
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use assert_fs::prelude::*;
129 use std::{fs::write, process::Command};
130
131 #[cfg(unix)]
132 fn nix_hash(path: &Path) -> Integrity {
134 let ssri_str = Command::new("nix-hash")
135 .args(vec!["--sri", "--type", "sha256"])
136 .arg(path)
137 .output()
138 .unwrap()
139 .stdout;
140 String::from_utf8_lossy(&ssri_str).parse().unwrap()
141 }
142
143 #[cfg(unix)]
144 fn nix_hash_file(path: &Path) -> Integrity {
146 let ssri_str = Command::new("nix-hash")
147 .args(vec!["--sri", "--type", "sha256", "--flat"])
148 .arg(path)
149 .output()
150 .unwrap()
151 .stdout;
152 String::from_utf8_lossy(&ssri_str).parse().unwrap()
153 }
154
155 #[tokio::test]
156 async fn test_hash_empty_dir() {
157 let temp = assert_fs::TempDir::new().unwrap();
158 let hash1 = temp.path().to_path_buf().hash().await.unwrap();
159 let hash2 = temp.path().to_path_buf().hash().await.unwrap();
160 assert_eq!(hash1, hash2);
161 let nix_hash = nix_hash(temp.path());
162 assert_eq!(hash1, nix_hash);
163 }
164
165 #[tokio::test]
166 #[cfg(unix)]
167 async fn test_hash_file() {
168 let temp = assert_fs::TempDir::new().unwrap();
169 let file = temp.child("test.txt");
170 file.write_str("test content").unwrap();
171
172 let hash = file.path().to_path_buf().hash().await.unwrap();
173 let nix_hash = nix_hash_file(file.path());
174 assert_eq!(hash, nix_hash);
175 }
176
177 #[tokio::test]
178 async fn test_hash_dir_with_single_file() {
179 let temp = assert_fs::TempDir::new().unwrap();
180 let file = temp.child("test.txt");
181 file.write_str("test content").unwrap();
182
183 let hash1 = temp.path().to_path_buf().hash().await.unwrap();
184 let hash2 = temp.path().to_path_buf().hash().await.unwrap();
185 assert_eq!(hash1, hash2);
186
187 #[cfg(unix)]
188 {
189 let nix_hash = nix_hash(temp.path());
190 assert_eq!(hash1, nix_hash);
191 }
192 }
193
194 #[tokio::test]
195 async fn test_hash_multiple_files_different_creation_order() {
196 let temp = assert_fs::TempDir::new().unwrap();
197
198 write(temp.child("a.txt").path(), "content a").unwrap();
199 write(temp.child("b.txt").path(), "content b").unwrap();
200 write(temp.child("c.txt").path(), "content c").unwrap();
201 let hash1 = temp.path().to_path_buf().hash().await.unwrap();
202
203 let temp2 = assert_fs::TempDir::new().unwrap();
204 write(temp2.child("c.txt").path(), "content c").unwrap();
205 write(temp2.child("a.txt").path(), "content a").unwrap();
206 write(temp2.child("b.txt").path(), "content b").unwrap();
207 let hash2 = temp2.path().to_path_buf().hash().await.unwrap();
208
209 assert_eq!(hash1, hash2);
210
211 #[cfg(unix)]
212 {
213 let nix_hash = nix_hash(temp.path());
214 assert_eq!(hash1, nix_hash);
215 }
216 }
217
218 #[tokio::test]
219 async fn test_hash_nested_directories_different_creation_order() {
220 let temp = assert_fs::TempDir::new().unwrap();
221
222 temp.child("a/b").create_dir_all().unwrap();
223 temp.child("b").create_dir_all().unwrap();
224 write(temp.child("a/b/file1.txt").path(), "content 1").unwrap();
225 write(temp.child("a/file2.txt").path(), "content 2").unwrap();
226 write(temp.child("b/file3.txt").path(), "content 3").unwrap();
227 let hash1 = temp.path().to_path_buf().hash().await.unwrap();
228
229 let temp2 = assert_fs::TempDir::new().unwrap();
230 temp2.child("a/b").create_dir_all().unwrap();
231 temp2.child("b").create_dir_all().unwrap();
232 write(temp2.child("b/file3.txt").path(), "content 3").unwrap();
233 write(temp2.child("a/file2.txt").path(), "content 2").unwrap();
234 write(temp2.child("a/b/file1.txt").path(), "content 1").unwrap();
235 let hash2 = temp2.path().to_path_buf().hash().await.unwrap();
236
237 assert_eq!(hash1, hash2);
238 }
239
240 #[tokio::test]
241 async fn test_hash_with_different_line_endings() {
242 let temp = assert_fs::TempDir::new().unwrap();
243 write(temp.child("unix.txt").path(), "line1\nline2\n").unwrap();
244 let hash1 = temp.path().to_path_buf().hash().await.unwrap();
245
246 let temp2 = assert_fs::TempDir::new().unwrap();
247 write(temp2.child("windows.txt").path(), "line1\r\nline2\r\n").unwrap();
248 let hash2 = temp2.path().to_path_buf().hash().await.unwrap();
249
250 assert_ne!(hash1, hash2);
251 }
252
253 #[tokio::test]
254 async fn test_hash_ignores_vcs_directories() {
255 let temp = assert_fs::TempDir::new().unwrap();
256 write(temp.child("src.lua").path(), "return true").unwrap();
257 temp.child(".git").create_dir_all().unwrap();
258 write(temp.child(".git/config").path(), "nondeterministic").unwrap();
259 temp.child(".jj").create_dir_all().unwrap();
260 write(temp.child(".jj/repo").path(), "nondeterministic").unwrap();
261 temp.child(".lux").create_dir_all().unwrap();
262 write(temp.child(".lux/tree").path(), "nondeterministic").unwrap();
263 write(temp.child("lux.lock").path(), "nondeterministic").unwrap();
264 write(temp.child(".luarc.json").path(), "nondeterministic").unwrap();
265
266 let hash_with_vcs = temp.path().to_path_buf().hash().await.unwrap();
267
268 let temp2 = assert_fs::TempDir::new().unwrap();
269 write(temp2.child("src.lua").path(), "return true").unwrap();
270
271 let hash_without_vcs = temp2.path().to_path_buf().hash().await.unwrap();
272
273 assert_eq!(hash_with_vcs, hash_without_vcs);
274 }
275
276 #[tokio::test]
277 async fn test_hash_ignores_gitignored_but_not_other_ignored_files() {
278 let temp = assert_fs::TempDir::new().unwrap();
279 write(temp.child("src.lua").path(), "return true").unwrap();
280 temp.child(".gitignore").write_str(".foo/\n").unwrap();
281 temp.child(".ignore").write_str("target/\n").unwrap();
282 temp.child(".foo").create_dir_all().unwrap();
283 write(temp.child(".foo/env").path(), "bar").unwrap();
284 temp.child("target").create_dir_all().unwrap();
285 write(temp.child("target/build.o").path(), "blob").unwrap();
286
287 let filtered = temp.path().to_path_buf().hash().await.unwrap();
288
289 let expected_tree = assert_fs::TempDir::new().unwrap();
290 write(expected_tree.child("src.lua").path(), "return true").unwrap();
291 expected_tree
292 .child(".gitignore")
293 .write_str(".foo/\n")
294 .unwrap();
295 expected_tree
296 .child(".ignore")
297 .write_str("target/\n")
298 .unwrap();
299 expected_tree.child("target").create_dir_all().unwrap();
300 write(expected_tree.child("target/build.o").path(), "blob").unwrap();
301 let expected = expected_tree.path().to_path_buf().hash().await.unwrap();
302
303 assert_eq!(filtered, expected);
304 }
305
306 #[tokio::test]
307 async fn test_hash_matches_plain_nar_without_vcs_dirs() {
308 let temp = assert_fs::TempDir::new().unwrap();
309 write(temp.child("src.lua").path(), "return true").unwrap();
310 temp.child("lua").create_dir_all().unwrap();
311 write(temp.child("lua/foo.lua").path(), "return 1").unwrap();
312
313 let filtered = temp.path().to_path_buf().hash().await.unwrap();
314
315 let mut opts = IntegrityOpts::new().algorithm(Algorithm::Sha256);
316 let mut enc = Encoder::new(temp.path()).unwrap();
317 io::copy(&mut enc, &mut opts).unwrap();
318 let plain = opts.result();
319
320 assert_eq!(filtered, plain);
321 }
322
323 #[tokio::test]
324 async fn test_hash_with_symlinks() {
325 let temp = assert_fs::TempDir::new().unwrap();
326
327 write(temp.child("target.txt").path(), "content").unwrap();
328
329 #[cfg(target_family = "unix")]
330 std::os::unix::fs::symlink(
331 temp.child("target.txt").path(),
332 temp.child("link.txt").path(),
333 )
334 .unwrap();
335 #[cfg(target_family = "windows")]
336 std::os::windows::fs::symlink_file(
337 temp.child("target.txt").path(),
338 temp.child("link.txt").path(),
339 )
340 .unwrap();
341
342 let hash1 = temp.path().to_path_buf().hash().await.unwrap();
343
344 let temp2 = assert_fs::TempDir::new().unwrap();
345 write(temp2.child("target.txt").path(), "content").unwrap();
346 let hash2 = temp2.path().to_path_buf().hash().await.unwrap();
347
348 assert_ne!(hash1, hash2);
349
350 #[cfg(unix)]
351 {
352 let nix_hash = nix_hash(temp.path());
353 assert_eq!(hash1, nix_hash);
354 }
355 }
356
357 #[tokio::test]
358 #[cfg(unix)]
359 async fn test_hash_dereferences_symlinked_directory() {
360 let outer = assert_fs::TempDir::new().unwrap();
361 let real = assert_fs::TempDir::new().unwrap();
362 write(real.child("src.lua").path(), "return true").unwrap();
363
364 let link = outer.path().join("link");
365 std::os::unix::fs::symlink(real.path(), &link).unwrap();
366
367 let hash_via_link = link.hash().await.unwrap();
368 let hash_real = real.path().to_path_buf().hash().await.unwrap();
369
370 assert_eq!(hash_via_link, hash_real);
371 }
372}