From d59ecfba761f7a19437c2c028d82418e28983f88 Mon Sep 17 00:00:00 2001 From: Matt Mastracci Date: Tue, 31 Mar 2026 10:59:00 -0600 Subject: [PATCH] fix: handle directories in dep-info source file hashing When proc_macro::tracked::path() registers a directory as a dependency, rustc's dep-info output includes the directory path. sccache previously crashed with "Is a directory" when trying to hash these paths. Now recursively hashes all files within the directory (sorted for determinism), using relative paths as delimiters. This correctly captures directory dependencies so cache invalidation works when any file in the tracked directory changes. Fixes: https://github.com/mozilla/sccache/issues/2653 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/util.rs | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 133 insertions(+), 4 deletions(-) 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); + } }