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`, files from `.gitignore`) and
68/// lux-generated files (e.g. `.lux`, `lux.lock`, `.luarc.json`) from the NAR, as their contents are
69/// not deterministic and are not part of a package's sources.
70struct 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    /// Compute nix-hash --sri --type sha256 .
133    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    /// Compute nix-hash --sri --type sha256 --flat .
145    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}