use std::collections::HashMap;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::indexing::files::get_max_file_bytes;
#[derive(Debug, Default)]
pub struct FileSizes {
captured: HashMap<String, u64>,
lazy_root: Option<PathBuf>,
memo: Mutex<HashMap<String, Option<u64>>>,
}
impl FileSizes {
pub fn empty() -> Self {
Self::default()
}
pub fn captured(sizes: HashMap<String, u64>) -> Self {
Self {
captured: sizes,
..Self::default()
}
}
pub fn lazy(root: PathBuf) -> Self {
let root = root.canonicalize().unwrap_or(root);
Self {
lazy_root: Some(root),
..Self::default()
}
}
pub fn get(&self, file_path: &str) -> Option<u64> {
if let Some(size) = self.captured.get(file_path) {
return Some(*size);
}
let root = self.lazy_root.as_deref()?;
if let Some(size) = self.lock_memo().get(file_path) {
return *size;
}
let size = read_file_chars(root, file_path);
self.lock_memo().insert(file_path.to_string(), size);
size
}
pub fn is_available(&self) -> bool {
!self.captured.is_empty() || self.lazy_root.is_some()
}
fn lock_memo(&self) -> std::sync::MutexGuard<'_, HashMap<String, Option<u64>>> {
self.memo.lock().unwrap_or_else(|e| e.into_inner())
}
}
pub(crate) fn read_file_chars(root: &Path, file_path: &str) -> Option<u64> {
let rel = Path::new(file_path);
if !is_safe_relative_path(rel) {
return None;
}
let full = root.join(rel);
if std::fs::symlink_metadata(&full).ok()?.is_symlink() {
return None;
}
let canonical = full.canonicalize().ok()?;
if !canonical.starts_with(root) {
return None;
}
if !std::fs::symlink_metadata(&canonical).ok()?.is_file() {
return None;
}
let file = std::fs::File::open(&canonical).ok()?;
let meta = file.metadata().ok()?;
let max_file_bytes = get_max_file_bytes();
if !meta.is_file() || meta.len() > max_file_bytes {
return None;
}
let mut bytes = Vec::with_capacity(meta.len() as usize);
file.take(max_file_bytes.saturating_add(1))
.read_to_end(&mut bytes)
.ok()?;
if bytes.len() as u64 > max_file_bytes {
return None;
}
Some(String::from_utf8_lossy(&bytes).encode_utf16().count() as u64)
}
fn is_safe_relative_path(path: &Path) -> bool {
use std::path::Component;
!path.is_absolute()
&& !path.components().any(|c| {
matches!(
c,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn lazy_reads_and_memoizes_regular_files() {
let root = tempdir().unwrap();
std::fs::write(root.path().join("a.ts"), "abcd").unwrap();
let sizes = FileSizes::lazy(root.path().to_path_buf());
assert!(sizes.is_available());
assert_eq!(sizes.get("a.ts"), Some(4));
std::fs::remove_file(root.path().join("a.ts")).unwrap();
assert_eq!(sizes.get("a.ts"), Some(4));
}
#[test]
fn lazy_returns_none_for_unreadable_paths() {
let outer = tempdir().unwrap();
let root = outer.path().join("repo");
std::fs::create_dir(&root).unwrap();
std::fs::write(outer.path().join("secret.txt"), "top secret").unwrap();
std::fs::write(root.join("real.ts"), "abcd").unwrap();
std::fs::create_dir(root.join("dir.ts")).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(outer.path().join("secret.txt"), root.join("link.ts")).unwrap();
let abs = root.join("real.ts").to_string_lossy().into_owned();
let sizes = FileSizes::lazy(root.clone());
assert_eq!(sizes.get("../secret.txt"), None);
assert_eq!(sizes.get(&abs), None);
assert_eq!(sizes.get("missing.ts"), None);
assert_eq!(sizes.get("dir.ts"), None);
#[cfg(unix)]
assert_eq!(sizes.get("link.ts"), None);
}
#[cfg(unix)]
#[test]
fn lazy_rejects_symlinked_intermediate_directory() {
let outer = tempdir().unwrap();
let root = outer.path().join("repo");
std::fs::create_dir(&root).unwrap();
let outside = outer.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("leak.ts"), "top secret").unwrap();
std::os::unix::fs::symlink(&outside, root.join("vendor")).unwrap();
let sizes = FileSizes::lazy(root);
assert_eq!(sizes.get("vendor/leak.ts"), None);
}
#[cfg(unix)]
#[test]
fn lazy_rejects_fifo_without_blocking() {
let root = tempdir().unwrap();
let fifo = root.path().join("pipe.ts");
let status = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.unwrap();
assert!(status.success());
let sizes = FileSizes::lazy(root.path().to_path_buf());
assert_eq!(sizes.get("pipe.ts"), None);
}
#[test]
fn lazy_memoizes_misses_so_they_are_read_once() {
let root = tempdir().unwrap();
let sizes = FileSizes::lazy(root.path().to_path_buf());
assert_eq!(sizes.get("later.ts"), None);
std::fs::write(root.path().join("later.ts"), "abcd").unwrap();
assert_eq!(sizes.get("later.ts"), None);
}
#[test]
fn lazy_sizes_non_utf8_files_lossily_like_the_indexer() {
let root = tempdir().unwrap();
std::fs::write(root.path().join("legacy.js"), b"ab\xffcd").unwrap();
let sizes = FileSizes::lazy(root.path().to_path_buf());
assert_eq!(sizes.get("legacy.js"), Some(5));
}
#[test]
fn lazy_skips_files_larger_than_the_indexing_ceiling() {
let root = tempdir().unwrap();
let big = vec![b'a'; get_max_file_bytes() as usize + 1];
std::fs::write(root.path().join("grown.ts"), &big).unwrap();
let sizes = FileSizes::lazy(root.path().to_path_buf());
assert_eq!(sizes.get("grown.ts"), None);
}
#[test]
fn captured_serves_known_paths_only() {
let sizes = FileSizes::captured([("a.ts".to_string(), 7u64)].into_iter().collect());
assert!(sizes.is_available());
assert_eq!(sizes.get("a.ts"), Some(7));
assert_eq!(sizes.get("b.ts"), None);
}
#[test]
fn empty_is_not_available() {
let sizes = FileSizes::empty();
assert!(!sizes.is_available());
assert_eq!(sizes.get("a.ts"), None);
}
}