distributed_lock_file/
name.rs

1//! File name validation and conversion utilities.
2
3use sha2::{Digest, Sha512};
4use std::path::{Path, PathBuf};
5
6use distributed_lock_core::error::{LockError, LockResult};
7
8/// Minimum file name length to avoid collisions.
9const MIN_FILE_NAME_LENGTH: usize = 12;
10
11/// Portable file name length (includes hash and extension).
12const PORTABLE_FILE_NAME_LENGTH: usize = 64;
13
14/// Hash length in Base32 characters (160 bits / 5 bits per char).
15const HASH_LENGTH_IN_CHARS: usize = 32;
16
17/// Base32 alphabet (RFC 4648).
18const BASE32_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
19
20/// Converts a lock name to a safe file name and constructs the full path.
21///
22/// # Rules
23///
24/// - Names with only alphanumeric, `-`, and `_` are used as-is (with prefix)
25/// - Other characters are replaced with underscores
26/// - A Base32 hash is appended to ensure uniqueness and case-sensitivity
27/// - The result is truncated to fit filesystem limits
28pub fn get_lock_file_name(directory: &Path, name: &str) -> LockResult<PathBuf> {
29    if name.is_empty() {
30        return Err(LockError::InvalidName(
31            "lock name cannot be empty".to_string(),
32        ));
33    }
34
35    let directory_path = directory
36        .canonicalize()
37        .or_else(|_| std::fs::create_dir_all(directory).map(|_| directory.to_path_buf()))
38        .map_err(|e| LockError::InvalidName(format!("failed to create directory: {e}")))?;
39
40    let directory_path_str = directory_path.to_string_lossy();
41    let directory_path_with_separator = if directory_path_str.ends_with('/') {
42        directory_path_str.to_string()
43    } else {
44        format!("{directory_path_str}/")
45    };
46
47    let base_name = convert_to_valid_base_name(name);
48    let name_hash = compute_hash(name.as_bytes());
49    const EXTENSION: &str = ".lock";
50
51    // First, try the full portable name format
52    let base_name_prefix_len = PORTABLE_FILE_NAME_LENGTH
53        .saturating_sub(name_hash.len())
54        .saturating_sub(EXTENSION.len())
55        .min(base_name.len());
56
57    let portable_lock_file_name = format!(
58        "{}{}{}{}",
59        directory_path_with_separator,
60        &base_name[..base_name_prefix_len],
61        name_hash,
62        EXTENSION
63    );
64
65    if !is_too_long(&portable_lock_file_name) {
66        return Ok(PathBuf::from(portable_lock_file_name));
67    }
68
69    // Next, try using just the hash as the name
70    let hash_only_file_name = format!("{directory_path_with_separator}{name_hash}");
71    if !is_too_long(&hash_only_file_name) {
72        return Ok(PathBuf::from(hash_only_file_name));
73    }
74
75    // Finally, try using just a portion of the hash
76    let minimum_length_file_name = format!(
77        "{}{}",
78        directory_path_with_separator,
79        &name_hash[..name_hash.len().min(MIN_FILE_NAME_LENGTH)]
80    );
81    if !is_too_long(&minimum_length_file_name) {
82        return Ok(PathBuf::from(minimum_length_file_name));
83    }
84
85    Err(LockError::InvalidName(format!(
86        "unable to construct lock file name: directory path too long (length = {})",
87        directory_path_with_separator.len()
88    )))
89}
90
91fn is_too_long(path: &str) -> bool {
92    Path::new(path).canonicalize().is_err() && path.len() > 255 // Conservative check
93}
94
95fn convert_to_valid_base_name(name: &str) -> String {
96    const REPLACEMENT_CHAR: char = '_';
97
98    let mut result = String::with_capacity(name.len());
99    let mut needs_replacement = false;
100
101    for ch in name.chars() {
102        if ch.is_alphanumeric() || ch == REPLACEMENT_CHAR {
103            result.push(ch);
104        } else {
105            if !needs_replacement {
106                needs_replacement = true;
107            }
108            result.push(REPLACEMENT_CHAR);
109        }
110    }
111
112    result
113}
114
115fn compute_hash(bytes: &[u8]) -> String {
116    let mut hasher = Sha512::new();
117    hasher.update(bytes);
118    let hash_bytes = hasher.finalize();
119
120    // Truncate to 160 bits (20 bytes) for Base32 encoding
121    let truncated: Vec<u8> = hash_bytes[..20].to_vec();
122
123    // Encode to Base32
124    let mut chars = Vec::with_capacity(HASH_LENGTH_IN_CHARS);
125    let mut bit_buffer = 0u32;
126    let mut bits_remaining = 0u32;
127
128    for byte in truncated {
129        bit_buffer |= (byte as u32) << bits_remaining;
130        bits_remaining += 8;
131
132        while bits_remaining >= 5 {
133            let index = (bit_buffer & 0x1f) as usize;
134            chars.push(BASE32_ALPHABET[index] as char);
135            bit_buffer >>= 5;
136            bits_remaining -= 5;
137        }
138    }
139
140    // Handle remaining bits
141    if bits_remaining > 0 {
142        let index = (bit_buffer & 0x1f) as usize;
143        chars.push(BASE32_ALPHABET[index] as char);
144    }
145
146    // Pad to exact length
147    while chars.len() < HASH_LENGTH_IN_CHARS {
148        chars.push('A'); // Padding character
149    }
150
151    chars.into_iter().take(HASH_LENGTH_IN_CHARS).collect()
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use tempfile::tempdir;
158
159    #[test]
160    fn test_valid_name() {
161        let dir = tempdir().unwrap();
162        let result = get_lock_file_name(dir.path(), "my-lock");
163        assert!(result.is_ok());
164        let path = result.unwrap();
165        // The path should contain the base name (possibly truncated) and end with .lock
166        let path_str = path.to_string_lossy();
167        assert!(path_str.contains("my-lock") || path_str.contains("my_lock"));
168        assert!(path_str.ends_with(".lock"));
169    }
170
171    #[test]
172    fn test_invalid_chars() {
173        let dir = tempdir().unwrap();
174        let result = get_lock_file_name(dir.path(), "foo/bar");
175        assert!(result.is_ok());
176        let path = result.unwrap();
177        assert!(path.to_string_lossy().contains("foo_bar"));
178    }
179}