Skip to main content

lux_lib/
hash.rs

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        // Canonicalising ensures a symlinked directory
19        // is hashed by its contents rather than as a symlink.
20        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                // NOTE: To ensure our source hashes are compatible with Nix,
25                // we encode the path to the Nix Archive (NAR) format.
26                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
67/// A [`FileSystem`] that excludes VCS directories (e.g. `.git`, `.jj`) and
68/// files ignored by the project's `.gitignore` from the encoded
69/// NAR, as their contents are not deterministic and are not part of a
70/// package's sources.
71struct 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    /// Compute nix-hash --sri --type sha256 .
127    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    /// Compute nix-hash --sri --type sha256 --flat .
139    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}