From c7a3c7facdce145dd3ce751dbf5d2d25e552db39 Mon Sep 17 00:00:00 2001 From: Tripp Cashel Date: Tue, 18 Aug 2026 12:18:55 -0400 Subject: [PATCH 1/4] rust: normalize cache keys with SCCACHE_BASEDIRS --- README.md | 13 +- benches/sccache_bench.rs | 19 +- docs/Configuration.md | 2 +- docs/Rust.md | 1 + src/compiler/rust.rs | 443 +++++++++++++++++++++++++++++++-------- src/config.rs | 40 ++-- src/util.rs | 92 ++++++++ tests/sccache_cargo.rs | 84 +++++++- 8 files changed, 590 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 1e5c99df96..9628b15067 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ This is most useful when using sccache for Rust compilation, as rustc supports u --- -Normalizing Paths with `SCCACHE_BASEDIRS` +Normalizing paths with `SCCACHE_BASEDIRS` ----------------------------------------- By default, sccache requires absolute paths to match for cache hits. To enable cache sharing across different build directories, you can set `SCCACHE_BASEDIRS` to strip a base directory from paths before hashing: @@ -344,6 +344,11 @@ export SCCACHE_BASEDIRS="/home/user/project:/home/user/workspace" Path matching is **case-insensitive** on Windows and **case-sensitive** on other operating systems. +For Rust compilations, sccache normalizes matching absolute source arguments, +the source side of `--remap-path-prefix`, Cargo path variables, tracked +environment dependency values that are absolute paths, and the current working +directory before computing the cache key. + This is similar to ccache's `CCACHE_BASEDIR` and helps when: * Building the same project from different directories * Sharing cache between CI jobs with different checkout paths @@ -352,6 +357,12 @@ This is similar to ccache's `CCACHE_BASEDIR` and helps when: **Note:** Only absolute paths are supported. Relative paths will prevent server from starting. +**Rust note:** This setting normalizes cache-key inputs; it does not rewrite +paths embedded in compiled artifacts. If a crate deliberately embeds an +absolute path, for example with `env!("CARGO_MANIFEST_DIR")`, a cache hit from +another checkout can contain the path from the compilation that populated the +cache. Use this opt-in setting only when that behavior is acceptable. + You can also configure this in the sccache config file: ```toml diff --git a/benches/sccache_bench.rs b/benches/sccache_bench.rs index 1350b55b8e..3a745fd402 100644 --- a/benches/sccache_bench.rs +++ b/benches/sccache_bench.rs @@ -22,7 +22,7 @@ use divan::{Bencher, black_box}; use sccache::cache::{CacheRead, CacheWrite}; use sccache::lru_disk_cache::LruCache; -use sccache::util::{Digest, TimeMacroFinder, strip_basedirs}; +use sccache::util::{Digest, TimeMacroFinder, strip_basedirs, strip_path_basedirs}; use std::io::Cursor; // ============================================================================= @@ -867,6 +867,23 @@ fn strip_basedirs_multiple(bencher: Bencher) { bencher.bench(|| black_box(strip_basedirs(black_box(&output), black_box(&basedirs)))); } +#[divan::bench(args = [0, 1, 8, 32])] +fn rust_path_basedirs(bencher: Bencher, root_count: usize) { + let basedirs = (0..root_count) + .map(|index| { + format!("/Users/example/workspaces/project/checkouts/worktree-{index:02}/").into_bytes() + }) + .collect::>(); + let hit = b"/Users/example/workspaces/project/checkouts/worktree-00/src/lib.rs".as_slice(); + let miss = b"/Users/example/.cargo/registry/src/package/src/lib.rs".as_slice(); + bencher.bench(|| { + for index in 0..100 { + let value = if index % 10 == 0 { hit } else { miss }; + black_box(strip_path_basedirs(black_box(value), black_box(&basedirs))); + } + }); +} + fn main() { divan::main(); } diff --git a/docs/Configuration.md b/docs/Configuration.md index 1294e7fd2e..3080ff48e6 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -179,7 +179,7 @@ Note that some env variables may need sccache server restart to take effect. * `SCCACHE_ALLOW_CORE_DUMPS` to enable core dumps by the server * `SCCACHE_CONF` configuration file path -* `SCCACHE_BASEDIRS` base directory (or directories) to strip from paths for cache key computation. This is similar to ccache's `CCACHE_BASEDIR` and enables cache hits across different absolute paths when compiling the same source code. Multiple directories can be separated by `;` on Windows hosts and by `:` on any other operating system. When multiple directories are specified, the longest matching prefix is used. Path matching is **case-insensitive** on Windows and **case-sensitive** on other operating systems. Environment variable takes precedence over file configuration. Only absolute paths are supported; relative paths will cause an error and prevent the server from start. +* `SCCACHE_BASEDIRS` base directory (or directories) to strip from paths for cache key computation. This is similar to ccache's `CCACHE_BASEDIR` and enables cache hits across different absolute paths when compiling the same source code. Multiple directories can be separated by `;` on Windows hosts and by `:` on any other operating system. When multiple directories are specified, the longest matching prefix is used. Path matching is **case-insensitive** on Windows and **case-sensitive** on other operating systems. For Rust, sccache normalizes matching absolute source arguments, the source side of `--remap-path-prefix`, Cargo path variables, tracked environment dependency values that are absolute paths, and the current working directory. Environment variable takes precedence over file configuration. Only absolute paths are supported; relative paths will cause an error and prevent the server from starting. This setting changes cache keys but does not rewrite paths embedded in artifacts; a Rust artifact can retain an absolute path from the compilation that populated the cache. * `SCCACHE_CACHED_CONF` * `SCCACHE_IDLE_TIMEOUT` how long the local daemon process waits for more client requests before exiting, in seconds. Set to `0` to run sccache permanently * `SCCACHE_STARTUP_NOTIFY` specify a path to a socket which will be used for server completion notification diff --git a/docs/Rust.md b/docs/Rust.md index 5d6f98c3cf..a43b3997cc 100644 --- a/docs/Rust.md +++ b/docs/Rust.md @@ -9,5 +9,6 @@ sccache includes support for caching Rust compilation. This includes many caveat * Procedural macros that read files from the filesystem may not be cached properly. * `rustc`'s incremental compilation needs to be disabled. See [The Cargo Book](https://doc.rust-lang.org/cargo/reference/profiles.html#incremental) * Crates that invoke the system linker cannot be cached. Examples are `bin`, `dylib`, `cdylib`, and `proc-macro` crates. +* `SCCACHE_BASEDIRS` normalizes matching paths in cache-key inputs, but it does not rewrite paths embedded in artifacts. For example, a crate that uses `env!("CARGO_MANIFEST_DIR")` can retain the path from the compilation that populated a shared cache entry. If you are using Rust 1.18 or later, you can ask cargo to wrap all compilation with sccache by setting `RUSTC_WRAPPER=sccache` in your build environment. diff --git a/src/compiler/rust.rs b/src/compiler/rust.rs index 779e9dd79e..1acb0583bf 100644 --- a/src/compiler/rust.rs +++ b/src/compiler/rust.rs @@ -26,7 +26,10 @@ use crate::dist::pkg; #[cfg(feature = "dist-client")] use crate::lru_disk_cache::{LruCache, Meter}; use crate::mock_command::{CommandCreatorSync, RunCommand}; -use crate::util::{Digest, fmt_duration_as_secs, hash_all, hash_all_archives, run_input_output}; +use crate::util::{ + Digest, fmt_duration_as_secs, hash_all, hash_all_archives, run_input_output, + strip_path_basedirs, +}; use crate::util::{HashToDigest, OsStrExt}; use crate::{counted_array, dist}; use async_trait::async_trait; @@ -44,7 +47,7 @@ use std::collections::{HashMap, HashSet}; use std::env::consts::DLL_EXTENSION; #[cfg(feature = "dist-client")] use std::env::consts::{DLL_PREFIX, EXE_EXTENSION}; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::fmt; use std::future::Future; use std::hash::Hash; @@ -235,7 +238,90 @@ static ALLOWED_EMIT: LazyLock> = LazyLock::new(|| ["link", "metadata", "dep-info"].iter().copied().collect()); /// Version number for cache key. -const CACHE_VERSION: &[u8] = b"6"; +const CACHE_VERSION: &[u8] = b"7"; + +fn hash_rust_value(digest: &mut Digest, value: &OsStr, basedirs: Option<&[Vec]>) { + let encoded = encode_rust_value(value); + let normalized = basedirs.map_or(encoded, |basedirs| strip_path_basedirs(encoded, basedirs)); + digest.update(&(normalized.len() as u64).to_le_bytes()); + digest.update(normalized); +} + +fn hash_rust_argument( + digest: &mut Digest, + arg: &OsStr, + value: Option<&OsStr>, + basedirs: Option<&[Vec]>, +) { + let arg_normalizer = if value.is_none() && Path::new(arg).is_absolute() { + basedirs + } else { + None + }; + hash_rust_value(digest, arg, arg_normalizer); + + let Some(value) = value else { + return; + }; + if arg == "--remap-path-prefix" { + hash_rust_remap(digest, value, basedirs); + return; + } + hash_rust_value(digest, value, None); +} + +fn hash_rust_remap(digest: &mut Digest, value: &OsStr, basedirs: Option<&[Vec]>) { + let Some(basedirs) = basedirs else { + hash_rust_value(digest, value, None); + return; + }; + let encoded = encode_rust_value(value); + let Some(separator) = encoded.iter().rposition(|byte| *byte == b'=') else { + hash_rust_value(digest, value, None); + return; + }; + let source = strip_path_basedirs(&encoded[..separator], basedirs); + let replacement = &encoded[separator..]; + digest.update(&((source.len() + replacement.len()) as u64).to_le_bytes()); + digest.update(source); + digest.update(replacement); +} + +fn encode_rust_value(value: &OsStr) -> &[u8] { + value.as_encoded_bytes() +} + +fn sort_paths_for_hash(paths: &mut [PathBuf], basedirs: Option<&[Vec]>) { + let Some(basedirs) = basedirs else { + return; + }; + if !paths.iter().any(|path| { + let path = encode_rust_value(path.as_os_str()); + strip_path_basedirs(path, basedirs).len() != path.len() + }) { + return; + } + + paths.sort_by(|left, right| { + let left = strip_path_basedirs(encode_rust_value(left.as_os_str()), basedirs); + let right = strip_path_basedirs(encode_rust_value(right.as_os_str()), basedirs); + left.cmp(right) + }); +} + +fn is_path_cargo_env(var: &OsStr) -> bool { + matches!( + var.to_str(), + Some( + "CARGO_HOME" + | "CARGO_INSTALL_ROOT" + | "CARGO_MANIFEST_DIR" + | "CARGO_MANIFEST_PATH" + | "CARGO_TARGET_DIR" + | "CARGO_TARGET_TMPDIR" + ) + ) || var.as_encoded_bytes().starts_with(b"CARGO_BIN_EXE_") +} /// Get absolute paths for all source files and env-deps listed in rustc's dep-info output. async fn get_source_files_and_env_deps( @@ -1388,10 +1474,12 @@ where _may_dist: bool, pool: &tokio::runtime::Handle, _rewrite_includes_only: bool, - _storage: Arc, + storage: Arc, _cache_control: CacheControl, ) -> Result> { trace!("[{}]: generate_hash_key", self.parsed_args.crate_name); + let basedirs = storage.basedirs(); + let basedirs = (!basedirs.is_empty()).then_some(basedirs); // TODO: this doesn't produce correct arguments if they should be concatenated - should use iter_os_strings let os_string_arguments: Vec<(OsString, Option)> = self .parsed_args @@ -1422,7 +1510,7 @@ where // Find all the source files and hash them let source_hashes_pool = pool.clone(); let source_files_and_hashes_and_env_deps = async { - let (source_files, env_deps) = get_source_files_and_env_deps( + let (mut source_files, env_deps) = get_source_files_and_env_deps( creator, &self.parsed_args.crate_name, &self.executable, @@ -1432,6 +1520,7 @@ where pool, ) .await?; + sort_paths_for_hash(&mut source_files, basedirs); let source_hashes = hash_all(&source_files, &source_hashes_pool).await?; Ok((source_files, source_hashes, env_deps)) }; @@ -1442,12 +1531,13 @@ where self.parsed_args.crate_name, self.parsed_args.externs.len() ); - let abs_externs = self + let mut abs_externs = self .parsed_args .externs .iter() .map(|e| cwd.join(e)) .collect::>(); + sort_paths_for_hash(&mut abs_externs, basedirs); let extern_hashes = hash_all(&abs_externs, pool); // Hash the contents of the staticlibs listed on the commandline. trace!( @@ -1455,12 +1545,13 @@ where self.parsed_args.crate_name, self.parsed_args.staticlibs.len() ); - let abs_staticlibs = self + let mut abs_staticlibs = self .parsed_args .staticlibs .iter() .map(|s| cwd.join(s)) .collect::>(); + sort_paths_for_hash(&mut abs_staticlibs, basedirs); let staticlib_hashes = hash_all_archives(&abs_staticlibs, pool); // Hash the content of the specified target json file, if any. @@ -1501,64 +1592,63 @@ where } let weak_toolchain_key = m.clone().finish(); // 3. The full commandline (self.arguments) - // TODO: there will be full paths here, it would be nice to - // normalize them so we can get cross-machine cache hits. // A few argument types are not passed in a deterministic order // by cargo: --extern, -L, --cfg. We'll filter those out, sort them, // and append them to the rest of the arguments. - let args = { - let (mut sortables, rest): (Vec<_>, Vec<_>) = os_string_arguments - .iter() - // We exclude a few arguments from the hash: - // -L, --extern, --out-dir, --diagnostic-width - // These contain paths which aren't relevant to the output, and the compiler inputs - // in those paths (rlibs and static libs used in the compilation) are used as hash - // inputs below. - .filter(|&(arg, _)| { - !(arg == "--extern" - || arg == "-L" - || arg == "--check-cfg" - || arg == "--out-dir" - || arg == "--diagnostic-width") - }) - // We also exclude `--target` if it specifies a path to a .json file. The file content - // is used as hash input below. - // If `--target` specifies a string, it continues to be hashed as part of the arguments. - .filter(|&(arg, _)| self.parsed_args.target_json.is_none() || arg != "--target") - // A few argument types were not passed in a deterministic order - // by older versions of cargo: --extern, -L, --cfg. We'll filter the rest of those - // out, sort them, and append them to the rest of the arguments. - .partition(|&(arg, _)| arg == "--cfg"); - sortables.sort(); - rest.into_iter() - .chain(sortables) - .flat_map(|(arg, val)| iter::once(arg).chain(val.as_ref())) - .fold(OsString::new(), |mut a, b| { - a.push(b); - a - }) - }; - args.hash(&mut HashToDigest { digest: &mut m }); + let (mut sortable_args, remaining_args): (Vec<_>, Vec<_>) = os_string_arguments + .iter() + // We exclude a few arguments from the hash: + // -L, --extern, --out-dir, --diagnostic-width + // These contain paths which aren't relevant to the output, and the compiler inputs + // in those paths (rlibs and static libs used in the compilation) are used as hash + // inputs below. + .filter(|&(arg, _)| { + !(arg == "--extern" + || arg == "-L" + || arg == "--check-cfg" + || arg == "--out-dir" + || arg == "--diagnostic-width") + }) + // We also exclude `--target` if it specifies a path to a .json file. The file content + // is used as hash input below. + // If `--target` specifies a string, it continues to be hashed as part of the arguments. + .filter(|&(arg, _)| self.parsed_args.target_json.is_none() || arg != "--target") + // A few argument types were not passed in a deterministic order + // by older versions of cargo: --extern, -L, --cfg. We'll filter the rest of those + // out, sort them, and append them to the rest of the arguments. + .partition(|&(arg, _)| arg == "--cfg"); + sortable_args.sort(); + let argument_count = remaining_args + .iter() + .chain(&sortable_args) + .map(|(_, value)| 1 + usize::from(value.is_some())) + .sum::(); + m.delimiter(b"rust-arguments"); + m.update(&(argument_count as u64).to_le_bytes()); + for (arg, value) in remaining_args.into_iter().chain(sortable_args) { + hash_rust_argument(&mut m, arg, value.as_deref(), basedirs); + } // 4. The digest of all source files (this includes src file from cmdline). // 5. The digest of all files listed on the commandline (self.externs). // 6. The digest of all static libraries listed on the commandline (self.staticlibs). // 7. The digest of the content of the target json file specified via `--target` (if any). - for h in source_hashes + for hash in source_hashes .into_iter() .chain(extern_hashes) .chain(staticlib_hashes) .chain(target_json_hash) { - m.update(h.as_bytes()); + m.update(hash.as_bytes()); } // 8. Environment variables: Hash all environment variables listed in the rustc dep-info // output. Additionally also has all environment variables starting with `CARGO_`, // since those are not listed in dep-info but affect cacheability. env_deps.sort(); + m.delimiter(b"rust-env-deps"); + m.update(&(env_deps.len() as u64).to_le_bytes()); for (var, val) in env_deps.iter() { - var.hash(&mut HashToDigest { digest: &mut m }); - m.update(b"="); - val.hash(&mut HashToDigest { digest: &mut m }); + hash_rust_value(&mut m, var, None); + hash_rust_value(&mut m, val, basedirs); } let mut env_vars: Vec<_> = env_vars .iter() @@ -1568,31 +1658,34 @@ where .cloned() .collect(); env_vars.sort(); - for (var, val) in env_vars.iter() { - if !var.starts_with("CARGO_") { - continue; - } - - // CARGO_MAKEFLAGS will have jobserver info which is extremely non-cacheable. - // CARGO_REGISTRIES_*_TOKEN contains non-cacheable secrets. - // Registry override config doesn't need to be hashed, because deps' package IDs - // already uniquely identify the relevant registries. - // CARGO_BUILD_JOBS only affects Cargo's parallelism, not rustc output. - // CARGO_ENCODED_RUSTFLAGS is already cached in argument list - if var == "CARGO_MAKEFLAGS" - || var.starts_with("CARGO_REGISTRIES_") - || var == "CARGO_BUILD_JOBS" - || var == "CARGO_ENCODED_RUSTFLAGS" - { - continue; - } - - var.hash(&mut HashToDigest { digest: &mut m }); - m.update(b"="); - val.hash(&mut HashToDigest { digest: &mut m }); + let cargo_env_vars = env_vars.iter().filter(|(var, _)| { + var.starts_with("CARGO_") + // CARGO_MAKEFLAGS will have jobserver info which is extremely non-cacheable. + // CARGO_REGISTRIES_*_TOKEN contains non-cacheable secrets. + // Registry override config doesn't need to be hashed, because deps' package IDs + // already uniquely identify the relevant registries. + // CARGO_BUILD_JOBS only affects Cargo's parallelism, not rustc output. + // CARGO_ENCODED_RUSTFLAGS is already cached in argument list. + && var != "CARGO_MAKEFLAGS" + && !var.starts_with("CARGO_REGISTRIES_") + && var != "CARGO_BUILD_JOBS" + && var != "CARGO_ENCODED_RUSTFLAGS" + }); + let cargo_env_count = cargo_env_vars.clone().count(); + m.delimiter(b"rust-cargo-env"); + m.update(&(cargo_env_count as u64).to_le_bytes()); + for (var, val) in cargo_env_vars { + hash_rust_value(&mut m, var, None); + let normalizer = if is_path_cargo_env(var) { + basedirs + } else { + None + }; + hash_rust_value(&mut m, val, normalizer); } // 9. The cwd of the compile. This will wind up in the rlib. - cwd.hash(&mut HashToDigest { digest: &mut m }); + m.delimiter(b"rust-cwd"); + hash_rust_value(&mut m, cwd.as_os_str(), basedirs); // 10. The version of the compiler. self.version.hash(&mut HashToDigest { digest: &mut m }); @@ -2721,7 +2814,6 @@ mod test { use crate::test::utils::*; use fs::File; use itertools::Itertools; - use std::ffi::OsStr; use std::io::{self, Write}; use std::sync::{Arc, Mutex}; use test_case::test_case; @@ -3584,24 +3676,30 @@ proc_macro false // sysroot shlibs digests. m.update(FAKE_DIGEST.as_bytes()); // Arguments, with cfgs sorted at the end. - OsStr::new("ab--cfgabc--cfgxyz").hash(&mut HashToDigest { digest: &mut m }); - // bar.rs (source file, from dep-info) - m.update(empty_digest.as_bytes()); - // foo.rs (source file, from dep-info) - m.update(empty_digest.as_bytes()); - // bar.rlib (extern crate, from externs) - m.update(empty_digest.as_bytes()); - // libbaz.a (static library, from staticlibs), containing a single - // file, baz.o, consisting of 1024 bytes of zeroes. + let args = ["a", "b", "--cfg", "abc", "--cfg", "xyz"]; + m.delimiter(b"rust-arguments"); + m.update(&(args.len() as u64).to_le_bytes()); + for arg in args { + hash_rust_value(&mut m, OsStr::new(arg), None); + } + // bar.rs and foo.rs (source files), then bar.rlib (extern crate). + for _ in 0..3 { + m.update(empty_digest.as_bytes()); + } + // libbaz.a contains baz.o, consisting of 1024 bytes of zeroes. m.update(libbaz_a_digest.as_bytes()); - // Env vars - OsStr::new("CARGO_BLAH").hash(&mut HashToDigest { digest: &mut m }); - m.update(b"="); - OsStr::new("abc").hash(&mut HashToDigest { digest: &mut m }); - OsStr::new("CARGO_PKG_NAME").hash(&mut HashToDigest { digest: &mut m }); - m.update(b"="); - OsStr::new("foo").hash(&mut HashToDigest { digest: &mut m }); - f.tempdir.path().hash(&mut HashToDigest { digest: &mut m }); + // rustc environment dependencies. + m.delimiter(b"rust-env-deps"); + m.update(&0_u64.to_le_bytes()); + // Cargo environment variables. + m.delimiter(b"rust-cargo-env"); + m.update(&2_u64.to_le_bytes()); + hash_rust_value(&mut m, OsStr::new("CARGO_BLAH"), None); + hash_rust_value(&mut m, OsStr::new("abc"), None); + hash_rust_value(&mut m, OsStr::new("CARGO_PKG_NAME"), None); + hash_rust_value(&mut m, OsStr::new("foo"), None); + m.delimiter(b"rust-cwd"); + hash_rust_value(&mut m, f.tempdir.path().as_os_str(), None); TEST_RUSTC_VERSION.hash(&mut HashToDigest { digest: &mut m }); let digest = m.finish(); assert_eq!(res.key, digest); @@ -3675,6 +3773,175 @@ proc_macro false Ok(()) } + fn rust_argument_key(arg: &str, value: Option<&str>, basedirs: Option<&[Vec]>) -> String { + let mut digest = Digest::new(); + hash_rust_argument( + &mut digest, + OsStr::new(arg), + value.map(OsStr::new), + basedirs, + ); + digest.finish() + } + + #[test] + fn test_strip_rust_basedirs_paths() { + let basedirs = [b"/work/project/".to_vec(), b"/work/project/crate/".to_vec()]; + + for (value, expected) in [ + ( + b"/work/project/crate/src/lib.rs".as_slice(), + b"src/lib.rs".as_slice(), + ), + (b"/work/project", b""), + (b"/work/project/", b""), + ( + b"prefix=/work/project/src/lib.rs", + b"prefix=/work/project/src/lib.rs", + ), + ( + b"/work/project copy/src/lib.rs", + b"/work/project copy/src/lib.rs", + ), + (b"/work/project=copy", b"/work/project=copy"), + (b"/other/project/src/lib.rs", b"/other/project/src/lib.rs"), + ] { + assert_eq!(strip_path_basedirs(value, &basedirs), expected); + } + } + + #[cfg(unix)] + #[test] + fn test_strip_rust_basedirs_non_utf8() { + use std::os::unix::ffi::OsStrExt as _; + let key = |value: &[u8], basedir: &[u8]| { + let mut digest = Digest::new(); + hash_rust_value( + &mut digest, + OsStr::from_bytes(value), + Some(&[basedir.to_vec()]), + ); + digest.finish() + }; + + let first = key(b"/work/one/src/non-utf8-\xff.rs", b"/work/one/"); + let second = key(b"/work/two/src/non-utf8-\xff.rs", b"/work/two/"); + assert_eq!(first, second); + assert_ne!(first, key(b"/work/two/src/non-utf8-\xfe.rs", b"/work/two/")); + } + + #[cfg(unix)] + #[test] + fn test_sort_paths_for_hash_uses_normalized_paths() { + let first_basedirs = vec![b"/a/work/".to_vec()]; + let second_basedirs = vec![b"/z/work/".to_vec()]; + let mut first = vec![ + PathBuf::from("/a/work/src/lib.rs"), + PathBuf::from("/m/shared.rs"), + ]; + let mut second = vec![ + PathBuf::from("/m/shared.rs"), + PathBuf::from("/z/work/src/lib.rs"), + ]; + let keys = |paths: &[PathBuf], basedirs: &[Vec]| { + paths + .iter() + .map(|path| { + strip_path_basedirs(encode_rust_value(path.as_os_str()), basedirs).to_vec() + }) + .collect::>() + }; + + sort_paths_for_hash(&mut first, Some(&first_basedirs)); + sort_paths_for_hash(&mut second, Some(&second_basedirs)); + assert_eq!( + keys(&first, &first_basedirs), + keys(&second, &second_basedirs) + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_strip_rust_basedirs_windows_paths() { + let basedirs = [b"c:/work/project/".to_vec()]; + assert_eq!( + strip_path_basedirs(b"C:\\Work\\Project\\src\\lib.rs", &basedirs), + b"src\\lib.rs".as_slice() + ); + assert_eq!( + strip_path_basedirs(b"C:\\Work\\Project", &basedirs), + b"".as_slice() + ); + assert_eq!( + strip_path_basedirs(b"C:\\Work\\Project\\\xc4\xb0", &basedirs), + b"C:\\Work\\Project\\\xc4\xb0".as_slice() + ); + + assert_ne!( + rust_argument_key("package 😀", None, None), + rust_argument_key("package 😁", None, None) + ); + assert_eq!( + rust_argument_key( + r"C:\Work\one\src\lib.rs", + None, + Some(&[b"c:/work/one/".to_vec()]) + ), + rust_argument_key( + r"D:\Work\two\src\lib.rs", + None, + Some(&[b"d:/work/two/".to_vec()]) + ) + ); + } + + #[test] + fn test_hash_rust_inputs_normalize_only_paths() { + let first = [b"/work/one/".to_vec()]; + let second = [b"/work/two/".to_vec()]; + let value_key = |value: &str, basedirs: &[Vec]| { + let mut digest = Digest::new(); + hash_rust_value(&mut digest, OsStr::new(value), Some(basedirs)); + digest.finish() + }; + + #[cfg(not(target_os = "windows"))] + { + assert_eq!( + rust_argument_key("/work/one/src/lib.rs", None, Some(&first)), + rust_argument_key("/work/two/src/lib.rs", None, Some(&second)) + ); + assert_ne!( + rust_argument_key("/work/one/src/lib.rs", None, Some(&first)), + rust_argument_key("/work/two/other/lib.rs", None, Some(&second)) + ); + } + assert_ne!( + rust_argument_key("ab", Some("c"), None), + rust_argument_key("a", Some("bc"), None) + ); + assert_eq!( + rust_argument_key("--remap-path-prefix", Some("/work/one=/src"), Some(&first)), + rust_argument_key("--remap-path-prefix", Some("/work/two=/src"), Some(&second)) + ); + assert_ne!( + rust_argument_key("--remap-path-prefix", Some("/work/one=/src"), None), + rust_argument_key("--remap-path-prefix", Some("/work/two=/src"), None) + ); + assert_eq!( + value_key("/work/one/Cargo.toml", &first), + value_key("/work/two/Cargo.toml", &second) + ); + assert_ne!( + rust_argument_key("--cfg", Some("root=/work/one"), Some(&first)), + rust_argument_key("--cfg", Some("root=/work/two"), Some(&second)) + ); + assert_eq!( + rust_argument_key("/other/src/lib.rs", None, None), + rust_argument_key("/other/src/lib.rs", None, Some(&first)) + ); + } + #[test_case(true ; "with preprocessor cache")] #[test_case(false ; "without preprocessor cache")] fn test_equal_hashes_externs(preprocessor_cache_mode: bool) { diff --git a/src/config.rs b/src/config.rs index 8895a65441..329f1583c0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,7 +14,7 @@ use crate::cache::CacheMode; #[cfg(target_os = "windows")] -use crate::util::normalize_win_path; +use crate::util::{normalize_win_path, strip_windows_verbatim_prefix}; use directories::ProjectDirs; use fs::File; use fs_err as fs; @@ -1369,17 +1369,15 @@ impl Config { // Normalize basedir: // remove double separators, cur_dirs, parent_dirs, trailing slashes let p_norm = p.normalize(); - let mut bytes = p_norm.to_string().into_bytes(); - - // Always add a trailing `/` to basedirs to ensure we only match complete path - // components - bytes.push(b'/'); + let bytes = p_norm.to_string().into_bytes(); // normalize windows paths: use slashes and lowercase - let normalized = { + let mut normalized = { #[cfg(target_os = "windows")] { - normalize_win_path(&bytes) + let mut normalized = normalize_win_path(&bytes); + strip_windows_verbatim_prefix(&mut normalized); + normalized } #[cfg(not(target_os = "windows"))] @@ -1387,6 +1385,10 @@ impl Config { bytes } }; + // End basedirs with a separator to ensure we only match complete path components. + if !normalized.ends_with(b"/") { + normalized.push(b'/'); + } // push only if not already present if !basedirs.contains(&normalized) { basedirs.push(normalized); @@ -3075,14 +3077,25 @@ fn test_integration_normalized_path_with_double_slashes() { cache: Default::default(), dist: Default::default(), server_startup_timeout_ms: None, - basedirs: vec!["/home//user///project/".to_string()], + basedirs: vec![ + "/home//user///project/".to_string(), + "/".to_string(), + "/home/user/project\\".to_string(), + ], client_side_mode: false, }; let config = Config::from_env_and_file_configs(env_conf, file_conf).unwrap(); // Config should normalize to single slashes with one trailing slash - assert_eq!(config.basedirs, vec![b"/home/user/project/"]); + assert_eq!( + config.basedirs, + vec![ + b"/home/user/project/".to_vec(), + b"/".to_vec(), + b"/home/user/project\\/".to_vec(), + ] + ); // Verify it works with strip_basedirs let input = b"# 1 \"/home/user/project/src/main.c\""; @@ -3106,14 +3119,17 @@ fn test_integration_windows_path_normalization() { cache: Default::default(), dist: Default::default(), server_startup_timeout_ms: None, - basedirs: vec!["C:\\Users\\Test\\Project".to_string()], + basedirs: vec!["C:\\Users\\Test\\Project".to_string(), "C:\\".to_string()], client_side_mode: false, }; let config = Config::from_env_and_file_configs(env_conf, file_conf).unwrap(); // Should be normalized to lowercase with forward slashes - assert_eq!(config.basedirs, vec![b"c:/users/test/project/"]); + assert_eq!( + config.basedirs, + vec![b"c:/users/test/project/".to_vec(), b"c:/".to_vec()] + ); // Test with mixed case preprocessor output let input = b"# 1 \"C:\\Users\\Test\\Project\\src\\main.c\""; diff --git a/src/util.rs b/src/util.rs index 408ae8a553..e4583c7828 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1209,6 +1209,59 @@ pub fn strip_basedirs<'a>(preprocessor_output: &'a [u8], basedirs: &[Vec]) - Cow::Owned(result) } +/// Strips the longest configured base directory from a complete path. +/// Configured base directories must be normalized and end with `/`. +#[doc(hidden)] +pub fn strip_path_basedirs<'a>(value: &'a [u8], basedirs: &[Vec]) -> &'a [u8] { + if basedirs.is_empty() || value.is_empty() { + return value; + } + + // Unicode case folding can change byte lengths. Keep non-ASCII values + // unchanged so offsets into the original value remain valid. + #[cfg(target_os = "windows")] + if !value.is_ascii() { + return value; + } + + #[cfg(target_os = "windows")] + let starts_with = |prefix: &[u8]| { + value.len() >= prefix.len() + && value.iter().zip(prefix).all(|(&actual, &expected)| { + let actual = match actual { + b'A'..=b'Z' => actual + (b'a' - b'A'), + b'\\' => b'/', + _ => actual, + }; + actual == expected + }) + }; + + let mut longest = None; + for basedir in basedirs.iter().filter(|basedir| basedir.ends_with(b"/")) { + let root = &basedir[..basedir.len() - 1]; + #[cfg(target_os = "windows")] + let (exact, nested) = ( + value.len() == root.len() && starts_with(root), + starts_with(basedir), + ); + #[cfg(not(target_os = "windows"))] + let (exact, nested) = (value == root, value.starts_with(basedir)); + let match_len = if exact { + Some(root.len()) + } else if nested { + Some(basedir.len()) + } else { + None + }; + if match_len > longest { + longest = match_len; + } + } + + longest.map_or(value, |length| &value[length..]) +} + /// Double every `/` in a normalized path. /// /// Paths inside preprocessor output are C string literals, so on Windows @@ -1292,6 +1345,23 @@ pub fn normalize_win_path(path: &[u8]) -> Vec { result } +/// Remove the Win32 verbatim prefix from normalized drive and UNC paths. +/// +/// `std::fs::canonicalize` adds this prefix on Windows, while compiler +/// arguments commonly use the equivalent path without it. +#[cfg(any(target_os = "windows", test))] +pub(crate) fn strip_windows_verbatim_prefix(path: &mut Vec) { + match path.as_slice() { + [b'/', b'/', b'?', b'/', drive, b':', b'/', ..] if drive.is_ascii_alphabetic() => { + path.drain(..4); + } + [b'/', b'/', b'?', b'/', b'u', b'n', b'c', b'/', ..] => { + path.drain(2..8); + } + _ => {} + } +} + /// Resolve the compiler executable, avoiding ccache/sccache wrappers. /// /// This function handles scenarios where ccache/sccache might interfere: @@ -1833,6 +1903,28 @@ mod tests { assert_eq!(normalized, b"c:/users/test/project"); } + #[test] + fn test_strip_windows_verbatim_prefix() { + for (input, expected) in [ + ( + b"\\\\?\\C:\\Users\\Test\\Project".as_slice(), + b"c:/users/test/project".as_slice(), + ), + ( + b"\\\\?\\UNC\\Server\\Share\\Project".as_slice(), + b"//server/share/project".as_slice(), + ), + ( + b"\\\\?\\Volume{1234}\\Project".as_slice(), + b"//?/volume{1234}/project".as_slice(), + ), + ] { + let mut normalized = super::normalize_win_path(input); + super::strip_windows_verbatim_prefix(&mut normalized); + assert_eq!(normalized, expected); + } + } + #[test] fn test_normalize_win_path_utf8() { // Test with UTF-8 characters (e.g., German umlauts) diff --git a/tests/sccache_cargo.rs b/tests/sccache_cargo.rs index 6734a33f5b..0423eb6d74 100644 --- a/tests/sccache_cargo.rs +++ b/tests/sccache_cargo.rs @@ -13,7 +13,8 @@ use fs_err as fs; use helpers::{SCCACHE_BIN, SccacheTest}; use predicates::prelude::*; use serial_test::serial; -use std::path::Path; +use std::env; +use std::path::{Path, PathBuf}; use std::process::Command; #[macro_use] @@ -43,6 +44,87 @@ fn test_rust_cargo_build_readonly() -> Result<()> { test_rust_cargo_cmd_readonly("build", SccacheTest::new(None)?) } +#[test] +#[serial] +fn test_rust_cargo_build_across_basedirs() -> Result<()> { + let test_info = SccacheTest::new(None)?; + let first = test_info.tempdir.path().join("first"); + let second = test_info.tempdir.path().join("second"); + + for root in [&first, &second] { + fs::create_dir_all(root.join("src"))?; + fs::write( + root.join("Cargo.toml"), + b"[package]\nname = \"basedirs-test\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + )?; + fs::write( + root.join("src/lib.rs"), + b"pub fn manifest() -> &'static str { env!(\"CARGO_MANIFEST_DIR\") }\npub fn answer() -> u32 { 42 }\n", + )?; + fs::write( + root.join("src/main.rs"), + b"fn main() { println!(\"{}\", basedirs_test::manifest()); }\n", + )?; + } + let first = fs::canonicalize(first)?; + let second = fs::canonicalize(second)?; + + stop_sccache()?; + let basedirs = env::join_paths([&first, &second])? + .into_string() + .map_err(|_| anyhow::anyhow!("basedir list is not valid UTF-8"))?; + restart_sccache( + &test_info, + Some(vec![("SCCACHE_BASEDIRS".into(), basedirs)]), + )?; + + let build = |root: &Path| -> Result<()> { + Command::new(CARGO.as_os_str()) + .args(["build", "--color=never"]) + .envs(test_info.env.iter().cloned()) + .env("CARGO_TARGET_DIR", root.join("target")) + .current_dir(root) + .assert() + .try_success()?; + Ok(()) + }; + build(&first)?; + build(&second)?; + + let stdout = Command::new(CARGO.as_os_str()) + .args(["run", "--quiet", "--color=never"]) + .envs(test_info.env.iter().cloned()) + .env("CARGO_TARGET_DIR", second.join("target")) + .current_dir(&second) + .assert() + .try_success()? + .get_output() + .stdout + .clone(); + let stdout = + std::str::from_utf8(&stdout).context("cached Cargo binary output was not UTF-8")?; + let reported = PathBuf::from(stdout.trim_end_matches(&['\r', '\n'][..])); + let reported = fs::canonicalize(&reported) + .with_context(|| format!("failed to canonicalize reported path {reported:?}"))?; + assert_eq!(reported, first); + + fs::write( + second.join("src/lib.rs"), + b"pub fn manifest() -> &'static str { env!(\"CARGO_MANIFEST_DIR\") }\npub fn answer() -> u32 { 43 }\n", + )?; + build(&second)?; + + test_info + .show_stats()? + .try_stdout(predicates::str::contains(r#""cache_hits":{"counts":{"Rust":1}"#).from_utf8())? + .try_stdout( + predicates::str::contains(r#""cache_misses":{"counts":{"Rust":2}"#).from_utf8(), + )? + .try_success()?; + + Ok(()) +} + #[test] #[serial] #[cfg(unix)] From 93ce0bf3628d239bcea89cf1303d5bd48f894e84 Mon Sep 17 00:00:00 2001 From: Tripp Cashel Date: Wed, 26 Aug 2026 06:42:36 -0400 Subject: [PATCH 2/4] fix(windows): match verbatim paths against basedirs --- src/util.rs | 146 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 120 insertions(+), 26 deletions(-) diff --git a/src/util.rs b/src/util.rs index e4583c7828..c478dd4c30 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1209,47 +1209,104 @@ pub fn strip_basedirs<'a>(preprocessor_output: &'a [u8], basedirs: &[Vec]) - Cow::Owned(result) } +#[cfg(any(target_os = "windows", test))] +enum WindowsVerbatimPrefix { + Drive, + Unc, +} + +#[cfg(any(target_os = "windows", test))] +fn windows_path_starts_with(value: &[u8], prefix: &[u8]) -> bool { + value.len() >= prefix.len() + && value.iter().zip(prefix).all(|(&actual, &expected)| { + let actual = match actual { + b'A'..=b'Z' => actual + (b'a' - b'A'), + b'\\' => b'/', + _ => actual, + }; + actual == expected + }) +} + +#[cfg(any(target_os = "windows", test))] +fn windows_verbatim_prefix(value: &[u8]) -> Option { + if value.len() >= 7 + && windows_path_starts_with(value, b"//?/") + && value[4].is_ascii_alphabetic() + && value[5] == b':' + && matches!(value[6], b'/' | b'\\') + { + Some(WindowsVerbatimPrefix::Drive) + } else if windows_path_starts_with(value, b"//?/unc/") { + Some(WindowsVerbatimPrefix::Unc) + } else { + None + } +} + /// Strips the longest configured base directory from a complete path. -/// Configured base directories must be normalized and end with `/`. +/// Configured base directories must be normalized and end with a slash. #[doc(hidden)] pub fn strip_path_basedirs<'a>(value: &'a [u8], basedirs: &[Vec]) -> &'a [u8] { if basedirs.is_empty() || value.is_empty() { return value; } + #[cfg(target_os = "windows")] + { + strip_windows_path_basedirs(value, basedirs) + } + + #[cfg(not(target_os = "windows"))] + { + let mut longest = None; + for basedir in basedirs.iter().filter(|basedir| basedir.ends_with(b"/")) { + let root = &basedir[..basedir.len() - 1]; + let match_len = if value == root { + Some(root.len()) + } else if value.starts_with(basedir) { + Some(basedir.len()) + } else { + None + }; + if match_len > longest { + longest = match_len; + } + } + + longest.map_or(value, |length| &value[length..]) + } +} + +#[cfg(any(target_os = "windows", test))] +fn strip_windows_path_basedirs<'a>(value: &'a [u8], basedirs: &[Vec]) -> &'a [u8] { // Unicode case folding can change byte lengths. Keep non-ASCII values // unchanged so offsets into the original value remain valid. - #[cfg(target_os = "windows")] if !value.is_ascii() { return value; } - #[cfg(target_os = "windows")] + // A verbatim drive prefix has no logical bytes; a verbatim UNC prefix + // replaces //?/UNC/ with the two leading separators of a UNC path. + let (logical_prefix, tail, removed_len): (&[u8], &[u8], usize) = + match windows_verbatim_prefix(value) { + Some(WindowsVerbatimPrefix::Drive) => (b"", &value[4..], 4), + Some(WindowsVerbatimPrefix::Unc) => (b"//", &value[8..], 6), + None => (b"", value, 0), + }; + let logical_len = logical_prefix.len() + tail.len(); let starts_with = |prefix: &[u8]| { - value.len() >= prefix.len() - && value.iter().zip(prefix).all(|(&actual, &expected)| { - let actual = match actual { - b'A'..=b'Z' => actual + (b'a' - b'A'), - b'\\' => b'/', - _ => actual, - }; - actual == expected - }) + prefix + .strip_prefix(logical_prefix) + .is_some_and(|prefix| windows_path_starts_with(tail, prefix)) }; let mut longest = None; for basedir in basedirs.iter().filter(|basedir| basedir.ends_with(b"/")) { let root = &basedir[..basedir.len() - 1]; - #[cfg(target_os = "windows")] - let (exact, nested) = ( - value.len() == root.len() && starts_with(root), - starts_with(basedir), - ); - #[cfg(not(target_os = "windows"))] - let (exact, nested) = (value == root, value.starts_with(basedir)); - let match_len = if exact { + let match_len = if logical_len == root.len() && starts_with(root) { Some(root.len()) - } else if nested { + } else if starts_with(basedir) { Some(basedir.len()) } else { None @@ -1259,7 +1316,7 @@ pub fn strip_path_basedirs<'a>(value: &'a [u8], basedirs: &[Vec]) -> &'a [u8 } } - longest.map_or(value, |length| &value[length..]) + longest.map_or(value, |length| &value[length + removed_len..]) } /// Double every `/` in a normalized path. @@ -1351,14 +1408,14 @@ pub fn normalize_win_path(path: &[u8]) -> Vec { /// arguments commonly use the equivalent path without it. #[cfg(any(target_os = "windows", test))] pub(crate) fn strip_windows_verbatim_prefix(path: &mut Vec) { - match path.as_slice() { - [b'/', b'/', b'?', b'/', drive, b':', b'/', ..] if drive.is_ascii_alphabetic() => { + match windows_verbatim_prefix(path) { + Some(WindowsVerbatimPrefix::Drive) => { path.drain(..4); } - [b'/', b'/', b'?', b'/', b'u', b'n', b'c', b'/', ..] => { + Some(WindowsVerbatimPrefix::Unc) => { path.drain(2..8); } - _ => {} + None => {} } } @@ -1903,6 +1960,43 @@ mod tests { assert_eq!(normalized, b"c:/users/test/project"); } + #[test] + fn test_strip_windows_path_basedirs_verbatim_paths() { + let basedirs = vec![ + b"c:/users/test/".to_vec(), + b"c:/users/test/project/".to_vec(), + b"//server/share/project/".to_vec(), + ]; + + for (input, expected) in [ + ( + b"\\\\?\\C:\\Users\\Test\\Project\\src\\lib.rs".as_slice(), + b"src\\lib.rs".as_slice(), + ), + ( + b"//?/c:/users/test/project".as_slice(), + b"".as_slice(), + ), + ( + b"\\\\?\\UNC\\Server\\Share\\Project\\src\\lib.rs".as_slice(), + b"src\\lib.rs".as_slice(), + ), + ( + b"C:/Users/Test/Project/src/lib.rs".as_slice(), + b"src/lib.rs".as_slice(), + ), + ( + b"\\\\?\\Volume{1234}\\Project\\src\\lib.rs".as_slice(), + b"\\\\?\\Volume{1234}\\Project\\src\\lib.rs".as_slice(), + ), + ] { + assert_eq!( + super::strip_windows_path_basedirs(input, &basedirs), + expected + ); + } + } + #[test] fn test_strip_windows_verbatim_prefix() { for (input, expected) in [ From 46a580cd0d8a8cb2fdda56576eef69ccbdee2468 Mon Sep 17 00:00:00 2001 From: Tripp Cashel Date: Wed, 26 Aug 2026 09:10:26 -0400 Subject: [PATCH 3/4] fix(windows): match escaped path separators --- src/util.rs | 92 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 67 insertions(+), 25 deletions(-) diff --git a/src/util.rs b/src/util.rs index c478dd4c30..92d4dd0c70 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1228,6 +1228,49 @@ fn windows_path_starts_with(value: &[u8], prefix: &[u8]) -> bool { }) } +#[cfg(any(target_os = "windows", test))] +/// Match a normalized Windows prefix while treating separator runs as one. +/// Rust dep-info escapes backslashes, so a separator can occupy multiple bytes. +/// The returned offset still indexes the original value, avoiding an allocation. +fn windows_path_prefix_len(value: &[u8], prefix: &[u8]) -> Option { + let mut value_index = 0; + let mut prefix_index = 0; + + while prefix_index < prefix.len() { + if matches!(prefix[prefix_index], b'/' | b'\\') { + let prefix_start = prefix_index; + while prefix_index < prefix.len() && matches!(prefix[prefix_index], b'/' | b'\\') { + prefix_index += 1; + } + + let value_start = value_index; + while value_index < value.len() && matches!(value[value_index], b'/' | b'\\') { + value_index += 1; + } + if value_index == value_start + || (prefix_start == 0 + && prefix_index - prefix_start >= 2 + && value_index - value_start < 2) + { + return None; + } + } else { + let actual = *value.get(value_index)?; + let actual = match actual { + b'A'..=b'Z' => actual + (b'a' - b'A'), + _ => actual, + }; + if actual != prefix[prefix_index] { + return None; + } + value_index += 1; + prefix_index += 1; + } + } + + Some(value_index) +} + #[cfg(any(target_os = "windows", test))] fn windows_verbatim_prefix(value: &[u8]) -> Option { if value.len() >= 7 @@ -1286,37 +1329,31 @@ fn strip_windows_path_basedirs<'a>(value: &'a [u8], basedirs: &[Vec]) -> &'a return value; } - // A verbatim drive prefix has no logical bytes; a verbatim UNC prefix - // replaces //?/UNC/ with the two leading separators of a UNC path. - let (logical_prefix, tail, removed_len): (&[u8], &[u8], usize) = + let (basedir_prefix, tail, removed_len): (&[u8], &[u8], usize) = match windows_verbatim_prefix(value) { Some(WindowsVerbatimPrefix::Drive) => (b"", &value[4..], 4), - Some(WindowsVerbatimPrefix::Unc) => (b"//", &value[8..], 6), + Some(WindowsVerbatimPrefix::Unc) => (b"//", &value[8..], 8), None => (b"", value, 0), }; - let logical_len = logical_prefix.len() + tail.len(); - let starts_with = |prefix: &[u8]| { - prefix - .strip_prefix(logical_prefix) - .is_some_and(|prefix| windows_path_starts_with(tail, prefix)) - }; let mut longest = None; for basedir in basedirs.iter().filter(|basedir| basedir.ends_with(b"/")) { - let root = &basedir[..basedir.len() - 1]; - let match_len = if logical_len == root.len() && starts_with(root) { - Some(root.len()) - } else if starts_with(basedir) { - Some(basedir.len()) - } else { - None - }; - if match_len > longest { - longest = match_len; + let configured_len = basedir.len(); + let root = basedir[..configured_len - 1].strip_prefix(basedir_prefix); + let prefix = basedir.strip_prefix(basedir_prefix); + let exact_match = root.and_then(|root| { + windows_path_prefix_len(tail, root).filter(|length| *length == tail.len()) + }); + let match_end = + exact_match.or_else(|| prefix.and_then(|prefix| windows_path_prefix_len(tail, prefix))); + if let Some(match_end) = match_end + && longest.is_none_or(|(length, _)| configured_len > length) + { + longest = Some((configured_len, match_end)); } } - longest.map_or(value, |length| &value[length + removed_len..]) + longest.map_or(value, |(_, match_end)| &value[removed_len + match_end..]) } /// Double every `/` in a normalized path. @@ -1973,10 +2010,7 @@ mod tests { b"\\\\?\\C:\\Users\\Test\\Project\\src\\lib.rs".as_slice(), b"src\\lib.rs".as_slice(), ), - ( - b"//?/c:/users/test/project".as_slice(), - b"".as_slice(), - ), + (b"//?/c:/users/test/project".as_slice(), b"".as_slice()), ( b"\\\\?\\UNC\\Server\\Share\\Project\\src\\lib.rs".as_slice(), b"src\\lib.rs".as_slice(), @@ -1985,6 +2019,14 @@ mod tests { b"C:/Users/Test/Project/src/lib.rs".as_slice(), b"src/lib.rs".as_slice(), ), + ( + b"C:\\\\Users\\\\Test\\\\Project\\\\src\\\\lib.rs".as_slice(), + b"src\\\\lib.rs".as_slice(), + ), + ( + b"\\\\?\\C:\\\\Users\\\\Test\\\\Project".as_slice(), + b"".as_slice(), + ), ( b"\\\\?\\Volume{1234}\\Project\\src\\lib.rs".as_slice(), b"\\\\?\\Volume{1234}\\Project\\src\\lib.rs".as_slice(), From 2b9a18f78fa4928d3d8d6ad9d8e706927db82b4a Mon Sep 17 00:00:00 2001 From: Tripp Cashel Date: Wed, 26 Aug 2026 11:07:38 -0400 Subject: [PATCH 4/4] bench: move basedirs benchmark to separate PR [skip ci] --- benches/sccache_bench.rs | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/benches/sccache_bench.rs b/benches/sccache_bench.rs index 3a745fd402..1350b55b8e 100644 --- a/benches/sccache_bench.rs +++ b/benches/sccache_bench.rs @@ -22,7 +22,7 @@ use divan::{Bencher, black_box}; use sccache::cache::{CacheRead, CacheWrite}; use sccache::lru_disk_cache::LruCache; -use sccache::util::{Digest, TimeMacroFinder, strip_basedirs, strip_path_basedirs}; +use sccache::util::{Digest, TimeMacroFinder, strip_basedirs}; use std::io::Cursor; // ============================================================================= @@ -867,23 +867,6 @@ fn strip_basedirs_multiple(bencher: Bencher) { bencher.bench(|| black_box(strip_basedirs(black_box(&output), black_box(&basedirs)))); } -#[divan::bench(args = [0, 1, 8, 32])] -fn rust_path_basedirs(bencher: Bencher, root_count: usize) { - let basedirs = (0..root_count) - .map(|index| { - format!("/Users/example/workspaces/project/checkouts/worktree-{index:02}/").into_bytes() - }) - .collect::>(); - let hit = b"/Users/example/workspaces/project/checkouts/worktree-00/src/lib.rs".as_slice(); - let miss = b"/Users/example/.cargo/registry/src/package/src/lib.rs".as_slice(); - bencher.bench(|| { - for index in 0..100 { - let value = if index % 10 == 0 { hit } else { miss }; - black_box(strip_path_basedirs(black_box(value), black_box(&basedirs))); - } - }); -} - fn main() { divan::main(); }