use sha2::{Digest, Sha256};
pub const DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH: usize = 120;
pub fn dep_path_to_filename(dep_path: &str, max_length: usize) -> String {
let mut filename = escape_unescaped(dep_path);
if filename.contains('(') {
if filename.ends_with(')') {
filename.pop();
}
filename = filename.replace(")(", "_").replace(['(', ')'], "_");
}
let has_upper = filename.chars().any(|c| c.is_ascii_uppercase());
let needs_hash = filename.len() > max_length || (has_upper && !filename.starts_with("file+"));
if needs_hash {
debug_assert!(
max_length > 33,
"virtual-store-dir-max-length ({max_length}) must be > 33 to fit the hash suffix"
);
let prefix_len = max_length.saturating_sub(33);
let short = short_hash(&filename);
let mut out = String::with_capacity(max_length);
let prefix_end = floor_char_boundary(&filename, prefix_len);
out.push_str(&filename[..prefix_end]);
out.push('_');
out.push_str(&short);
return out;
}
filename
}
fn escape_unescaped(dep_path: &str) -> String {
let mut out = String::with_capacity(dep_path.len());
let rest: &str = if let Some(r) = dep_path.strip_prefix("file:") {
out.push_str("file+");
r
} else {
dep_path.strip_prefix('/').unwrap_or(dep_path)
};
for ch in rest.chars() {
match ch {
'\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '#' => out.push('+'),
c => out.push(c),
}
}
out
}
fn short_hash(input: &str) -> String {
let digest = Sha256::digest(input.as_bytes());
hex::encode(digest)[..32].to_string()
}
fn floor_char_boundary(s: &str, mut idx: usize) -> usize {
if idx >= s.len() {
return s.len();
}
while !s.is_char_boundary(idx) {
idx -= 1;
}
idx
}
#[cfg(test)]
mod tests {
use super::*;
const MAX: usize = DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH;
#[test]
fn short_path_passes_through() {
assert_eq!(dep_path_to_filename("foo@1.0.0", MAX), "foo@1.0.0");
}
#[test]
fn scope_slash_becomes_plus() {
assert_eq!(
dep_path_to_filename("@scope/foo@1.0.0", MAX),
"@scope+foo@1.0.0"
);
}
#[test]
fn parens_flatten_to_underscore() {
assert_eq!(
dep_path_to_filename("foo@1.0.0(peer@2.0.0)", MAX),
"foo@1.0.0_peer@2.0.0"
);
}
#[test]
fn nested_parens_flatten() {
assert_eq!(dep_path_to_filename("a@1(b@2(c@3))", MAX), "a@1_b@2_c@3_");
}
#[test]
fn long_path_is_truncated_and_hashed() {
let long = format!("foo@1.0.0({})", "a@1.0.0".repeat(60));
let got = dep_path_to_filename(&long, MAX);
assert_eq!(got.len(), MAX);
assert!(got.as_bytes()[MAX - 33] == b'_');
}
#[test]
fn long_path_is_deterministic() {
let long = format!("foo@1.0.0({})", "a@1.0.0".repeat(60));
assert_eq!(
dep_path_to_filename(&long, MAX),
dep_path_to_filename(&long, MAX)
);
}
#[test]
fn different_long_paths_produce_different_hashes() {
let a = format!("foo@1.0.0({})", "a@1.0.0".repeat(60));
let b = format!("foo@1.0.0({})", "b@1.0.0".repeat(60));
assert_ne!(dep_path_to_filename(&a, MAX), dep_path_to_filename(&b, MAX));
}
#[test]
fn uppercase_forces_hash_unless_file_prefix() {
let got = dep_path_to_filename("Foo@1.0.0", MAX);
assert!(got.contains('_'));
assert!(got.len() > "Foo@1.0.0".len());
let got = dep_path_to_filename("file:../Foo", MAX);
assert_eq!(got, "file+..+Foo");
}
#[test]
fn fig_eslint_config_autocomplete_fits_in_255_bytes() {
let dep_path = "@fig/eslint-config-autocomplete@2.0.0(@typescript-eslint+eslint-plugin@7.18.0(@typescript-eslint+parser@7.18.0(eslint@8.57.1))(eslint@8.57.1))(@typescript-eslint+parser@7.18.0(eslint@8.57.1))(@withfig+eslint-plugin-fig-linter@1.4.1)(eslint@8.57.1)(eslint-plugin-compat@4.2.0(eslint@8.57.1))(typescript@5.9.3)";
let got = dep_path_to_filename(dep_path, MAX);
assert!(got.len() <= MAX, "got {} bytes: {got}", got.len());
}
#[test]
fn multi_byte_chars_do_not_split() {
let s: String = "π".repeat(200);
let got = dep_path_to_filename(&s, MAX);
assert!(got.len() <= MAX);
}
}