diff --git a/src/util.rs b/src/util.rs index 2720c1fa6..c47315414 100644 --- a/src/util.rs +++ b/src/util.rs @@ -99,13 +99,55 @@ impl Digest { /// the actual hash computation on a background thread in `pool`. pub async fn reader(path: PathBuf, pool: &tokio::runtime::Handle) -> Result { pool.spawn_blocking(move || { - let reader = File::open(&path) - .with_context(|| format!("Failed to open file for hashing: {:?}", path))?; - Digest::reader_sync(reader) + if path.is_dir() { + // For directories (e.g., from proc_macro::tracked::path()), + // recursively hash all file contents in sorted order. + let mut entries = Vec::new(); + Self::collect_dir_entries(&path, &mut entries)?; + entries.sort(); + let mut digest = Digest::new(); + for entry in &entries { + // Hash the relative path as a delimiter + let rel = entry.strip_prefix(&path).unwrap_or(entry); + digest.update(rel.to_string_lossy().as_bytes()); + digest.update(b"\0"); + let mut file = File::open(entry) + .with_context(|| format!("Failed to open file for hashing: {:?}", entry))?; + let mut buf = vec![0u8; HASH_BUFFER_SIZE]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + digest.update(&buf[..n]); + } + } + Ok(digest.finish()) + } else { + let reader = File::open(&path) + .with_context(|| format!("Failed to open file for hashing: {:?}", path))?; + Digest::reader_sync(reader) + } }) .await? } + /// Recursively collect all file paths within a directory. + fn collect_dir_entries(dir: &Path, entries: &mut Vec) -> Result<()> { + for entry in std::fs::read_dir(dir) + .with_context(|| format!("Failed to read directory for hashing: {:?}", dir))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + Self::collect_dir_entries(&path, entries)?; + } else { + entries.push(path); + } + } + Ok(()) + } + pub fn update(&mut self, bytes: &[u8]) { self.inner.update(bytes); } @@ -1248,7 +1290,7 @@ pub fn resolve_compiler_avoiding_ccache( #[cfg(test)] mod tests { - use super::{OsStrExt, TimeMacroFinder, resolve_compiler_avoiding_ccache}; + use super::{Digest, OsStrExt, TimeMacroFinder, resolve_compiler_avoiding_ccache}; use std::ffi::{OsStr, OsString}; use std::path::Path; @@ -1705,4 +1747,91 @@ mod tests { let normalized = super::normalize_win_path(input); assert_eq!(normalized, b""); } + + #[tokio::test] + async fn test_digest_reader_hashes_file() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("hello.txt"); + std::fs::write(&path, b"hello, world").unwrap(); + let pool = tokio::runtime::Handle::current(); + let hash = Digest::reader(path, &pool).await.unwrap(); + assert_eq!(hash.len(), 64, "blake3 hex digest is 64 chars"); + } + + // Regression for https://github.com/mozilla/sccache/issues/2653: rustc's + // dep-info may list a directory path (e.g. from a proc macro that calls + // `proc_macro::tracked_path::path()` on a directory). The four tests below + // exercise Digest::reader against directories. + + #[tokio::test] + async fn test_digest_reader_hashes_directory() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path(); + std::fs::create_dir_all(dir.join("nested/deeper")).unwrap(); + std::fs::write(dir.join("root.txt"), b"root").unwrap(); + std::fs::write(dir.join("nested/inner.txt"), b"inner").unwrap(); + std::fs::write(dir.join("nested/deeper/deep.txt"), b"deep").unwrap(); + + let pool = tokio::runtime::Handle::current(); + let hash = Digest::reader(dir.to_path_buf(), &pool).await.unwrap(); + assert_eq!(hash.len(), 64); + } + + #[tokio::test] + async fn test_digest_reader_directory_is_deterministic() { + fn populate(dir: &std::path::Path) { + std::fs::create_dir_all(dir.join("a/b")).unwrap(); + std::fs::create_dir_all(dir.join("c")).unwrap(); + std::fs::write(dir.join("a/b/one.txt"), b"one").unwrap(); + std::fs::write(dir.join("a/two.txt"), b"two").unwrap(); + std::fs::write(dir.join("c/three.txt"), b"three").unwrap(); + std::fs::write(dir.join("four.txt"), b"four").unwrap(); + } + let t1 = tempfile::tempdir().unwrap(); + let t2 = tempfile::tempdir().unwrap(); + populate(t1.path()); + populate(t2.path()); + let pool = tokio::runtime::Handle::current(); + let h1 = Digest::reader(t1.path().to_path_buf(), &pool) + .await + .unwrap(); + let h2 = Digest::reader(t2.path().to_path_buf(), &pool) + .await + .unwrap(); + assert_eq!(h1, h2); + } + + #[tokio::test] + async fn test_digest_reader_directory_detects_content_change() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path(); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::write(dir.join("sub/data.txt"), b"before").unwrap(); + let pool = tokio::runtime::Handle::current(); + let before = Digest::reader(dir.to_path_buf(), &pool).await.unwrap(); + std::fs::write(dir.join("sub/data.txt"), b"after").unwrap(); + let after = Digest::reader(dir.to_path_buf(), &pool).await.unwrap(); + assert_ne!(before, after); + } + + #[tokio::test] + async fn test_digest_reader_directory_detects_path_change() { + // Two dirs with identical file *contents* but different file *names* + // must hash to different values, because collect_dir_entries mixes + // the relative path into the digest as a delimiter. + let t1 = tempfile::tempdir().unwrap(); + let t2 = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(t1.path().join("sub")).unwrap(); + std::fs::create_dir_all(t2.path().join("sub")).unwrap(); + std::fs::write(t1.path().join("sub/a.txt"), b"x").unwrap(); + std::fs::write(t2.path().join("sub/b.txt"), b"x").unwrap(); + let pool = tokio::runtime::Handle::current(); + let h1 = Digest::reader(t1.path().to_path_buf(), &pool) + .await + .unwrap(); + let h2 = Digest::reader(t2.path().to_path_buf(), &pool) + .await + .unwrap(); + assert_ne!(h1, h2); + } }