1use 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
12pub 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
20pub fn sha256(data: &[u8]) -> [u8; 32] {
22 let mut hasher = Sha256::new();
23 hasher.update(data);
24 hasher.finalize().into()
25}
26
27pub 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 fs::write(&temp_path, content).map_err(VoidError::Io)?;
37
38 fs::rename(&temp_path, path).map_err(VoidError::Io)?;
40
41 Ok(())
42}
43
44pub fn atomic_write_str(path: impl AsRef<Path>, content: &str) -> Result<()> {
46 atomic_write(path, content.as_bytes())
47}
48
49pub 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
62pub 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 content.last() == Some(&b'\n') {
76 newlines as u32
77 } else {
78 (newlines + 1) as u32
79 }
80}
81
82pub 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
88pub fn open_store(void_dir: &Utf8Path) -> Result<FsStore> {
90 FsStore::new(void_dir.join("objects"))
91}
92
93pub fn safe_join(root: impl AsRef<Path>, rel: &str) -> Result<PathBuf> {
111 let root = root.as_ref();
112 if rel.is_empty() {
114 return Err(VoidError::PathTraversal {
115 path: rel.to_string(),
116 reason: "empty path".to_string(),
117 });
118 }
119
120 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 let normalized = rel.replace('\\', "/");
130
131 if normalized.starts_with('/') {
133 return Err(VoidError::PathTraversal {
134 path: rel.to_string(),
135 reason: "absolute path".to_string(),
136 });
137 }
138
139 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 => {} 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 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 let final_path = root.join(&sanitized);
173
174 check_symlink_escape(root, &sanitized)?;
176
177 Ok(final_path)
178}
179
180fn 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 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 }
208 Err(e) => {
209 return Err(VoidError::Io(e));
212 }
213 }
214 }
215 }
216 Ok(())
217}
218
219pub fn validate_nix_store_path(path: &Path) -> Result<PathBuf> {
227 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 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 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 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 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 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 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 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 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 assert_eq!(count_lines(b"line1\nline2\n"), 2);
408 }
409
410 #[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()); }
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 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 let symlink_path = root.join("broken_link");
459 std::os::unix::fs::symlink("/nonexistent/path", &symlink_path).unwrap();
460
461 assert!(symlink_path.symlink_metadata().is_ok());
463 assert!(!symlink_path.exists()); 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 let symlink_path = root.join("escape");
485 std::os::unix::fs::symlink("/etc", &symlink_path).unwrap();
486
487 let result = safe_join(root, "escape/passwd");
489 assert!(result.is_err());
490 }
491
492 #[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 let path = Path::new("/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-name");
533 let result = validate_nix_store_path(path);
534 assert!(result.is_err());
535 }
536}