Skip to main content

void_core/support/
util.rs

1//! Shared utility functions
2
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5
6use camino::{Utf8Path, Utf8PathBuf};
7use sha2::{Digest, Sha256};
8
9use super::error::{Result, VoidError};
10use crate::store::FsStore;
11
12/// Serialize a value to CBOR bytes.
13pub fn cbor_to_vec<T: serde::Serialize>(val: &T) -> Result<Vec<u8>> {
14    let mut buf = Vec::new();
15    ciborium::into_writer(val, &mut buf)
16        .map_err(|e| VoidError::Serialization(e.to_string()))?;
17    Ok(buf)
18}
19
20/// Compute SHA-256 hash of data.
21pub fn sha256(data: &[u8]) -> [u8; 32] {
22    let mut hasher = Sha256::new();
23    hasher.update(data);
24    hasher.finalize().into()
25}
26
27/// Atomically write content to a file using temp + rename.
28///
29/// This ensures that readers either see the old content or the new content,
30/// never a partially written file.
31pub fn atomic_write(path: impl AsRef<Path>, content: &[u8]) -> Result<()> {
32    let path = path.as_ref();
33    let temp_path = path.with_extension("tmp");
34
35    // Write to temp file
36    fs::write(&temp_path, content).map_err(VoidError::Io)?;
37
38    // Atomic rename
39    fs::rename(&temp_path, path).map_err(VoidError::Io)?;
40
41    Ok(())
42}
43
44/// Atomically write string content to a file.
45pub fn atomic_write_str(path: impl AsRef<Path>, content: &str) -> Result<()> {
46    atomic_write(path, content.as_bytes())
47}
48
49/// Configure a WalkBuilder for void's ignore semantics.
50///
51/// void respects `.ignore` files (the ignore crate default) but not git-specific
52/// ignore sources (global gitconfig, .git/info/exclude, etc.) since void repos
53/// don't have a .git directory.
54pub fn configure_walker(builder: &mut ignore::WalkBuilder) -> &mut ignore::WalkBuilder {
55    builder
56        .hidden(false)
57        .git_ignore(false)
58        .git_global(false)
59        .git_exclude(false)
60}
61
62/// Count the number of lines in a byte slice (SIMD-accelerated via memchr).
63///
64/// Matches `wc -l` behavior: counts newlines, plus 1 if file doesn't end with newline.
65/// Returns 0 for empty content.
66pub fn count_lines(content: &[u8]) -> u32 {
67    if content.is_empty() {
68        return 0;
69    }
70
71    let newlines = memchr::memchr_iter(b'\n', content).count();
72
73    // If file ends with newline, that's the line count
74    // If not, add 1 for the final line without newline
75    if content.last() == Some(&b'\n') {
76        newlines as u32
77    } else {
78        (newlines + 1) as u32
79    }
80}
81
82/// Convert a `Path` to a `Utf8PathBuf`, returning `VoidError::InvalidPath` on non-UTF-8.
83pub fn to_utf8(path: impl AsRef<Path>) -> Result<Utf8PathBuf> {
84    Utf8PathBuf::from_path_buf(path.as_ref().to_path_buf())
85        .map_err(|p| VoidError::InvalidPath(p.display().to_string()))
86}
87
88/// Open the object store under `void_dir/objects`.
89pub fn open_store(void_dir: &Utf8Path) -> Result<FsStore> {
90    FsStore::new(void_dir.join("objects"))
91}
92
93/// Safely join a relative path to a root directory, preventing path traversal.
94///
95/// Rejects:
96/// - Empty paths
97/// - Paths containing null bytes
98/// - Absolute paths (starting with `/` or Windows drive letters)
99/// - Parent directory references (`..`)
100/// - Paths that normalize to empty (e.g., ".", "./")
101/// - Any symlink in the path (including broken symlinks)
102///
103/// Note: This function rejects ALL symlinks in the path, not just those pointing
104/// outside the root. This reduces the symlink-based attack surface. However, this
105/// does NOT eliminate TOCTOU races (an attacker can still swap a component to a
106/// symlink between check and write). True TOCTOU protection requires OS-level
107/// openat/O_NOFOLLOW-style APIs.
108///
109/// Returns the joined path if safe, or `VoidError::PathTraversal` if unsafe.
110pub fn safe_join(root: impl AsRef<Path>, rel: &str) -> Result<PathBuf> {
111    let root = root.as_ref();
112    // Reject empty paths
113    if rel.is_empty() {
114        return Err(VoidError::PathTraversal {
115            path: rel.to_string(),
116            reason: "empty path".to_string(),
117        });
118    }
119
120    // Reject null bytes
121    if rel.contains('\0') {
122        return Err(VoidError::PathTraversal {
123            path: rel.to_string(),
124            reason: "null byte in path".to_string(),
125        });
126    }
127
128    // Normalize separators
129    let normalized = rel.replace('\\', "/");
130
131    // Reject absolute paths
132    if normalized.starts_with('/') {
133        return Err(VoidError::PathTraversal {
134            path: rel.to_string(),
135            reason: "absolute path".to_string(),
136        });
137    }
138
139    // Parse and validate components
140    let path = Path::new(&normalized);
141    let mut sanitized = PathBuf::new();
142
143    for component in path.components() {
144        match component {
145            Component::Normal(c) => sanitized.push(c),
146            Component::CurDir => {} // Skip `.`
147            Component::ParentDir => {
148                return Err(VoidError::PathTraversal {
149                    path: rel.to_string(),
150                    reason: "parent directory reference".to_string(),
151                });
152            }
153            Component::RootDir | Component::Prefix(_) => {
154                return Err(VoidError::PathTraversal {
155                    path: rel.to_string(),
156                    reason: "absolute path component".to_string(),
157                });
158            }
159        }
160    }
161
162    // Reject paths that normalize to empty (e.g., ".", "./")
163    // This prevents attempts to write/remove the root directory itself
164    if sanitized.as_os_str().is_empty() {
165        return Err(VoidError::PathTraversal {
166            path: rel.to_string(),
167            reason: "path normalizes to empty".to_string(),
168        });
169    }
170
171    // Build final path
172    let final_path = root.join(&sanitized);
173
174    // Check for symlinks in existing components (including broken symlinks)
175    check_symlink_escape(root, &sanitized)?;
176
177    Ok(final_path)
178}
179
180/// Walk path components and reject if any existing component is a symlink.
181///
182/// Uses `symlink_metadata()` which does NOT follow symlinks, allowing us to
183/// detect both valid and broken symlinks. This is important because `Path::exists()`
184/// follows symlinks and returns false for broken symlinks, which would bypass
185/// the security check.
186fn check_symlink_escape(root: &Path, rel: &Path) -> Result<()> {
187    use std::io::ErrorKind;
188
189    let mut current = root.to_path_buf();
190
191    for component in rel.components() {
192        if let Component::Normal(c) = component {
193            current.push(c);
194            // Use symlink_metadata() unconditionally - it doesn't follow symlinks,
195            // so it correctly detects both valid AND broken symlinks.
196            match current.symlink_metadata() {
197                Ok(meta) => {
198                    if meta.file_type().is_symlink() {
199                        return Err(VoidError::PathTraversal {
200                            path: rel.to_string_lossy().to_string(),
201                            reason: format!("symlink in path: {}", current.display()),
202                        });
203                    }
204                }
205                Err(e) if e.kind() == ErrorKind::NotFound => {
206                    // Path doesn't exist yet - that's fine, continue checking
207                }
208                Err(e) => {
209                    // Propagate unexpected errors (PermissionDenied, etc.)
210                    // Don't silently proceed if we can't verify the path
211                    return Err(VoidError::Io(e));
212                }
213            }
214        }
215    }
216    Ok(())
217}
218
219/// Validate that a path is a legitimate Nix store path.
220///
221/// Checks:
222/// - Starts with `/nix/store/`
223/// - Has a valid 32-char nix base32 hash followed by `-name`
224/// - `canonicalize()` resolves to still be under `/nix/store/`
225/// - Path exists and is a directory (not a symlink)
226pub fn validate_nix_store_path(path: &Path) -> Result<PathBuf> {
227    // Nix base32 alphabet (note: no e, o, t, u)
228    const NIX_BASE32: &[u8] = b"0123456789abcdfghjklmnpqrsvwxyz";
229
230    let path_str = path.to_str().ok_or_else(|| VoidError::PathTraversal {
231        path: path.display().to_string(),
232        reason: "non-UTF8 store path".to_string(),
233    })?;
234
235    // Must start with /nix/store/
236    let remainder =
237        path_str
238            .strip_prefix("/nix/store/")
239            .ok_or_else(|| VoidError::PathTraversal {
240                path: path_str.to_string(),
241                reason: "not under /nix/store/".to_string(),
242            })?;
243
244    // Remainder must be exactly `<32-char-hash>-<name>` with no slashes
245    if remainder.contains('/') {
246        return Err(VoidError::PathTraversal {
247            path: path_str.to_string(),
248            reason: "store path contains subdirectory".to_string(),
249        });
250    }
251
252    // Find the hyphen separating hash from name
253    let hyphen_pos = remainder
254        .find('-')
255        .filter(|&pos| pos == 32)
256        .ok_or_else(|| VoidError::PathTraversal {
257            path: path_str.to_string(),
258            reason: "invalid store path format: expected 32-char hash followed by hyphen"
259                .to_string(),
260        })?;
261
262    let hash_part = &remainder[..hyphen_pos];
263    let name_part = &remainder[hyphen_pos + 1..];
264
265    // Validate hash characters
266    if !hash_part.bytes().all(|b| NIX_BASE32.contains(&b)) {
267        return Err(VoidError::PathTraversal {
268            path: path_str.to_string(),
269            reason: "invalid nix base32 hash characters".to_string(),
270        });
271    }
272
273    // Name must be non-empty
274    if name_part.is_empty() {
275        return Err(VoidError::PathTraversal {
276            path: path_str.to_string(),
277            reason: "empty store path name".to_string(),
278        });
279    }
280
281    // Canonicalize and verify still under /nix/store/
282    let canonical = path.canonicalize().map_err(|e| VoidError::PathTraversal {
283        path: path_str.to_string(),
284        reason: format!("canonicalize failed: {}", e),
285    })?;
286
287    if !canonical.starts_with("/nix/store/") {
288        return Err(VoidError::PathTraversal {
289            path: path_str.to_string(),
290            reason: format!(
291                "path escapes /nix/store/ after canonicalization: {}",
292                canonical.display()
293            ),
294        });
295    }
296
297    // Verify it's a directory (not a symlink) via symlink_metadata
298    let meta = path
299        .symlink_metadata()
300        .map_err(|e| VoidError::PathTraversal {
301            path: path_str.to_string(),
302            reason: format!("symlink_metadata failed: {}", e),
303        })?;
304
305    if !meta.is_dir() {
306        return Err(VoidError::PathTraversal {
307            path: path_str.to_string(),
308            reason: "store path is not a directory".to_string(),
309        });
310    }
311
312    Ok(canonical)
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use tempfile::TempDir;
319
320    #[test]
321    fn sha256_deterministic() {
322        let data = b"hello world";
323        let hash1 = sha256(data);
324        let hash2 = sha256(data);
325        assert_eq!(hash1, hash2);
326    }
327
328    #[test]
329    fn sha256_different_input() {
330        let hash1 = sha256(b"hello");
331        let hash2 = sha256(b"world");
332        assert_ne!(hash1, hash2);
333    }
334
335    #[test]
336    fn sha256_known_value() {
337        // SHA-256 of empty string
338        let hash = sha256(b"");
339        let expected =
340            hex::decode("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
341                .unwrap();
342        assert_eq!(hash.as_slice(), expected.as_slice());
343    }
344
345    #[test]
346    fn atomic_write_creates_file() {
347        let temp = TempDir::new().unwrap();
348        let path = temp.path().join("test.txt");
349
350        atomic_write(&path, b"hello").unwrap();
351
352        assert!(path.exists());
353        assert_eq!(fs::read(&path).unwrap(), b"hello");
354    }
355
356    #[test]
357    fn atomic_write_overwrites_existing() {
358        let temp = TempDir::new().unwrap();
359        let path = temp.path().join("test.txt");
360
361        fs::write(&path, b"old content").unwrap();
362        atomic_write(&path, b"new content").unwrap();
363
364        assert_eq!(fs::read(&path).unwrap(), b"new content");
365    }
366
367    #[test]
368    fn atomic_write_no_temp_file_remains() {
369        let temp = TempDir::new().unwrap();
370        let path = temp.path().join("test.txt");
371        let temp_path = temp.path().join("test.tmp");
372
373        atomic_write(&path, b"content").unwrap();
374
375        assert!(path.exists());
376        assert!(!temp_path.exists());
377    }
378
379    #[test]
380    fn atomic_write_str_works() {
381        let temp = TempDir::new().unwrap();
382        let path = temp.path().join("test.txt");
383
384        atomic_write_str(&path, "hello string").unwrap();
385
386        assert_eq!(fs::read_to_string(&path).unwrap(), "hello string");
387    }
388
389    #[test]
390    fn count_lines_empty() {
391        assert_eq!(count_lines(b""), 0);
392    }
393
394    #[test]
395    fn count_lines_single_line() {
396        assert_eq!(count_lines(b"hello"), 1);
397    }
398
399    #[test]
400    fn count_lines_multiple_lines() {
401        assert_eq!(count_lines(b"line1\nline2\nline3"), 3);
402    }
403
404    #[test]
405    fn count_lines_trailing_newline() {
406        // Matches wc -l: trailing newline doesn't add extra line
407        assert_eq!(count_lines(b"line1\nline2\n"), 2);
408    }
409
410    // === Path traversal tests ===
411
412    #[test]
413    fn safe_join_accepts_normal_paths() {
414        let root = Path::new("/tmp/output");
415        assert!(safe_join(root, "src/lib.rs").is_ok());
416        assert!(safe_join(root, "README.md").is_ok());
417        assert!(safe_join(root, "./src/lib.rs").is_ok());
418        assert!(safe_join(root, "a//b").is_ok()); // double slash normalized
419    }
420
421    #[test]
422    fn safe_join_rejects_parent_traversal() {
423        let root = Path::new("/tmp/output");
424        assert!(safe_join(root, "../escape.txt").is_err());
425        assert!(safe_join(root, "a/../../escape.txt").is_err());
426        assert!(safe_join(root, "a/b/../../../escape.txt").is_err());
427    }
428
429    #[test]
430    fn safe_join_rejects_absolute_paths() {
431        let root = Path::new("/tmp/output");
432        assert!(safe_join(root, "/etc/passwd").is_err());
433        assert!(safe_join(root, "/abs/path").is_err());
434    }
435
436    #[test]
437    fn safe_join_rejects_empty_path() {
438        let root = Path::new("/tmp/output");
439        assert!(safe_join(root, "").is_err());
440    }
441
442    #[test]
443    fn safe_join_rejects_paths_normalizing_to_empty() {
444        let root = Path::new("/tmp/output");
445        // Paths like "." or "./" normalize to empty and should be rejected
446        assert!(safe_join(root, ".").is_err());
447        assert!(safe_join(root, "./").is_err());
448        assert!(safe_join(root, "./.").is_err());
449    }
450
451    #[cfg(unix)]
452    #[test]
453    fn safe_join_rejects_broken_symlinks() {
454        let temp = TempDir::new().unwrap();
455        let root = temp.path();
456
457        // Create a broken symlink (target doesn't exist)
458        let symlink_path = root.join("broken_link");
459        std::os::unix::fs::symlink("/nonexistent/path", &symlink_path).unwrap();
460
461        // Verify it's broken: symlink exists, but target doesn't
462        assert!(symlink_path.symlink_metadata().is_ok());
463        assert!(!symlink_path.exists()); // Path::exists() follows symlinks
464
465        // Should still be rejected
466        let result = safe_join(root, "broken_link");
467        assert!(result.is_err());
468        assert!(result.unwrap_err().to_string().contains("symlink"));
469    }
470
471    #[test]
472    fn safe_join_rejects_null_bytes() {
473        let root = Path::new("/tmp/output");
474        assert!(safe_join(root, "file\0.txt").is_err());
475    }
476
477    #[cfg(unix)]
478    #[test]
479    fn safe_join_rejects_symlink_escape() {
480        let temp = TempDir::new().unwrap();
481        let root = temp.path();
482
483        // Create a symlink pointing outside
484        let symlink_path = root.join("escape");
485        std::os::unix::fs::symlink("/etc", &symlink_path).unwrap();
486
487        // Should reject path through symlink
488        let result = safe_join(root, "escape/passwd");
489        assert!(result.is_err());
490    }
491
492    // === Nix store path validation tests ===
493
494    #[test]
495    fn validate_nix_store_path_rejects_traversal() {
496        let path = Path::new("/nix/store/../../etc/evil");
497        let result = validate_nix_store_path(path);
498        assert!(result.is_err());
499    }
500
501    #[test]
502    fn validate_nix_store_path_rejects_non_store() {
503        let path = Path::new("/tmp/something");
504        let result = validate_nix_store_path(path);
505        assert!(result.is_err());
506    }
507
508    #[test]
509    fn validate_nix_store_path_rejects_short_hash() {
510        let path = Path::new("/nix/store/abc123-short");
511        let result = validate_nix_store_path(path);
512        assert!(result.is_err());
513    }
514
515    #[test]
516    fn validate_nix_store_path_rejects_subdirectory() {
517        let path = Path::new("/nix/store/00000000000000000000000000000000-name/sub");
518        let result = validate_nix_store_path(path);
519        assert!(result.is_err());
520    }
521
522    #[test]
523    fn validate_nix_store_path_rejects_missing_name() {
524        let path = Path::new("/nix/store/00000000000000000000000000000000-");
525        let result = validate_nix_store_path(path);
526        assert!(result.is_err());
527    }
528
529    #[test]
530    fn validate_nix_store_path_rejects_invalid_base32() {
531        // 'e', 'o', 't', 'u' are not in nix base32
532        let path = Path::new("/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-name");
533        let result = validate_nix_store_path(path);
534        assert!(result.is_err());
535    }
536}