pub fn cmd_search(
pattern: &str,
dir: &str,
max: usize,
locale: crate::locale::Locale,
) -> anyhow::Result<()> {
anyhow::ensure!(
cfg!(unix),
"{}",
crate::locale::native_message(locale, crate::locale::NativeMessage::SearchUnavailable)
);
let root = std::fs::canonicalize(dir).map_err(|error| match locale {
crate::locale::Locale::English => error.into(),
crate::locale::Locale::Chinese | crate::locale::Locale::Japanese => anyhow::anyhow!(
"{} {dir}: {error}",
crate::locale::native_message(
locale,
crate::locale::NativeMessage::CannotAccessSearchDirectory
)
),
})?;
let reader = crate::file_security::Root::new(&root);
let files = collect_parallel(&root);
let nfiles = files.len();
let per_file: Vec<Vec<String>> = match reader {
Some(reader) if nfiles >= 32 => grep_parallel(pattern, &files, &reader, max),
Some(reader) => grep_chunk(pattern, &files, &reader, max),
None => vec![],
};
use std::io::Write;
let stdout = std::io::stdout();
let mut out = std::io::BufWriter::new(stdout.lock());
let mut shown = 0usize;
let mut outer_done = false;
for (path, fh) in files.iter().zip(&per_file) {
if outer_done {
break;
}
if fh.is_empty() {
continue;
}
let prefix = format!("{}:", path.display());
let display_path = std::path::Path::new(dir).join(path.strip_prefix(&root)?);
for line in fh {
if shown >= max {
outer_done = true;
break;
}
if let Some(hit) = line.strip_prefix(&prefix) {
let _ = writeln!(
out,
"{}:{}",
crate::terminal_safe::strip_control(&display_path.display().to_string()),
crate::terminal_safe::strip_control(hit)
);
}
shown += 1;
}
}
let _ = out.flush();
match locale {
crate::locale::Locale::English => eprintln!("[rdsh] {shown} hit(s) in {nfiles} file(s)"),
crate::locale::Locale::Chinese => {
eprintln!("[rdsh] 在 {nfiles} 个文件中找到 {shown} 个匹配")
}
crate::locale::Locale::Japanese => {
eprintln!("[rdsh] {nfiles} 個のファイルで {shown} 件一致")
}
}
Ok(())
}
const SKIP: &[&str] = &[
".git",
"node_modules",
"target",
".venv",
"dist",
"build",
".next",
];
fn collect_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for e in entries.filter_map(Result::ok) {
let ft = match e.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if ft.is_file() {
out.push(e.path());
continue;
}
if !ft.is_dir() {
continue;
}
if is_skip_dir_name(&e.file_name()) {
continue;
}
let p = e.path();
collect_files(&p, out);
}
}
#[derive(PartialEq)]
enum EntryKind {
Dir,
File,
Skip,
}
fn is_skip_dir_name(name: &std::ffi::OsStr) -> bool {
let b = name.as_encoded_bytes();
if b.first() == Some(&b'.') {
return true;
}
for s in SKIP {
if b == s.as_bytes() {
return true;
}
}
false
}
fn entry_kind(e: &std::fs::DirEntry) -> EntryKind {
let ft = match e.file_type() {
Ok(t) => t,
Err(_) => return EntryKind::Skip,
};
if ft.is_file() {
return EntryKind::File;
}
if !ft.is_dir() {
return EntryKind::Skip;
}
if is_skip_dir_name(&e.file_name()) {
return EntryKind::Skip;
}
EntryKind::Dir
}
fn collect_parallel(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let entries: Vec<std::fs::DirEntry> = match std::fs::read_dir(dir) {
Ok(e) => e.filter_map(Result::ok).collect(),
Err(_) => return vec![],
};
let mut subdirs = vec![];
for e in &entries {
if entry_kind(e) == EntryKind::Dir {
subdirs.push(e.path());
}
}
if subdirs.len() < 2 {
let mut out = vec![];
collect_files(dir, &mut out);
return out;
}
enum Seg {
Files(Vec<std::path::PathBuf>),
Sub(std::path::PathBuf),
}
let mut segs: Vec<Seg> = Vec::new();
let mut pending: Vec<std::path::PathBuf> = Vec::new();
for e in &entries {
match entry_kind(e) {
EntryKind::Dir => {
if !pending.is_empty() {
segs.push(Seg::Files(std::mem::take(&mut pending)));
}
segs.push(Seg::Sub(e.path()));
}
EntryKind::File => pending.push(e.path()),
EntryKind::Skip => {}
}
}
if !pending.is_empty() {
segs.push(Seg::Files(pending));
}
let sub_idx: Vec<usize> = segs
.iter()
.enumerate()
.filter(|(_, s)| matches!(s, Seg::Sub(_)))
.map(|(i, _)| i)
.collect();
let mut resolved: Vec<Vec<std::path::PathBuf>> = Vec::new();
resolved.resize_with(sub_idx.len(), Vec::new);
let width = crate::inspect::parallelism().max(1);
for (batch_no, batch) in sub_idx.chunks(width).enumerate() {
std::thread::scope(|s| {
let mut handles = vec![];
for (k, seg_i) in batch.iter().enumerate() {
if let Seg::Sub(p) = &segs[*seg_i] {
let pos = batch_no * width + k;
handles.push((
pos,
s.spawn(move || {
let mut v = vec![];
collect_files(p, &mut v);
v
}),
));
}
}
for (pos, h) in handles {
resolved[pos] = h.join().unwrap_or_default();
}
});
}
let mut files = vec![];
let mut ri = 0;
for seg in segs {
match seg {
Seg::Files(v) => files.extend(v),
Seg::Sub(_) => {
files.extend(std::mem::take(&mut resolved[ri]));
ri += 1;
}
}
}
files
}
fn read_capped(path: &std::path::Path, root: &crate::file_security::Root) -> Option<String> {
use std::io::Read;
let f = root.open(path)?;
let len = f.metadata().ok()?.len();
if len > 2_000_000 {
return None;
}
let mut buf = Vec::with_capacity(len as usize);
f.take(2_000_001).read_to_end(&mut buf).ok()?;
if buf.len() > 2_000_000 {
return None;
}
String::from_utf8(buf).ok()
}
fn grep_one(
pattern: &str,
path: &std::path::Path,
root: &crate::file_security::Root,
max: usize,
) -> Vec<String> {
let mut hits = vec![];
if max == 0 {
return hits;
}
if let Some(text) = read_capped(path, root) {
for (i, line) in text.lines().enumerate() {
if line.contains(pattern) {
hits.push(format!(
"{}:{}: {}",
path.display(),
i + 1,
truncate(line, 240)
));
if hits.len() == max {
break;
}
}
}
}
hits
}
fn grep_chunk(
pattern: &str,
files: &[std::path::PathBuf],
root: &crate::file_security::Root,
mut remaining: usize,
) -> Vec<Vec<String>> {
files
.iter()
.map(|path| {
let hits = grep_one(pattern, path, root, remaining);
remaining -= hits.len();
hits
})
.collect()
}
fn grep_parallel(
pattern: &str,
files: &[std::path::PathBuf],
root: &crate::file_security::Root,
max: usize,
) -> Vec<Vec<String>> {
let threads = crate::inspect::parallelism();
if threads <= 1 {
return grep_chunk(pattern, files, root, max);
}
let chunk = files.len().div_ceil(threads);
let mut out: Vec<Vec<Vec<String>>> = Vec::new();
std::thread::scope(|s| {
let mut handles = vec![];
for c in files.chunks(chunk) {
handles.push(s.spawn(move || grep_chunk(pattern, c, root, max)));
}
for h in handles {
out.push(h.join().unwrap_or_default());
}
});
out.into_iter().flatten().collect()
}
fn truncate(s: &str, n: usize) -> String {
let mut rest = s.chars();
let mut t: String = rest.by_ref().take(n).collect();
if rest.next().is_some() {
t.push('>');
}
t
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_marks_only_when_chars_are_actually_cut() {
let cjk = "あ".repeat(81);
assert_eq!(truncate(&cjk, 240), cjk);
assert_eq!(
truncate(&"a".repeat(241), 240),
format!("{}>", "a".repeat(240))
);
assert_eq!(
truncate(&"あ".repeat(241), 240),
format!("{}>", "あ".repeat(240))
);
}
#[test]
fn worker_cap() {
assert!((1..=8).contains(&crate::inspect::parallelism()));
}
#[cfg(unix)]
#[test]
fn bounded_workers_preserve_global_prefix_and_skip_reads_at_zero() {
let temporary = std::env::temp_dir().join(format!(
"rdsh-search-limit-{}",
crate::local_http::random_token().unwrap()
));
std::fs::create_dir(&temporary).unwrap();
let root = std::fs::canonicalize(&temporary).unwrap();
let reader = crate::file_security::Root::new(&root).unwrap();
let mut files = Vec::new();
let mut expected = Vec::new();
for i in 0..65 {
let path = root.join(format!("file-{i}.txt"));
let text = if i % 4 == 0 {
"ordinary\n"
} else {
"match A\nordinary\nmatch B\n"
};
std::fs::write(&path, text).unwrap();
if i % 4 != 0 {
expected.push(format!("{}:1: match A", path.display()));
expected.push(format!("{}:3: match B", path.display()));
}
files.push(path);
}
for max in [0, 1, 3, 19, 300] {
let hits = grep_parallel("match", &files, &reader, max)
.into_iter()
.flatten()
.collect::<Vec<_>>();
assert!(hits.len() <= crate::inspect::parallelism() * max);
assert_eq!(
hits.into_iter().take(max).collect::<Vec<_>>(),
expected.iter().take(max).cloned().collect::<Vec<_>>()
);
}
assert!(grep_one("match", &root.join("missing"), &reader, 0).is_empty());
std::fs::remove_dir_all(temporary).unwrap();
}
#[cfg(unix)]
#[test]
fn recursive_search_does_not_follow_repository_symlinks() {
use std::os::unix::fs::symlink;
let root = std::env::temp_dir().join(format!(
"rdsh-search-{}",
crate::local_http::random_token().unwrap()
));
let repo = root.join("repo");
let outside = root.join("outside");
std::fs::create_dir_all(&repo).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("credentials.json"), "DUMMY_SECRET").unwrap();
std::fs::write(repo.join("README.md"), "safe").unwrap();
symlink(&outside, repo.join("linked-dir")).unwrap();
symlink(outside.join("credentials.json"), repo.join("linked.json")).unwrap();
let files = collect_parallel(&repo);
assert_eq!(files, vec![repo.join("README.md")]);
std::fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn search_rejects_files_replaced_after_collection() {
let temporary = std::env::temp_dir().join(format!(
"rdsh-search-swap-{}",
crate::local_http::random_token().unwrap()
));
let project = temporary.join("project");
std::fs::create_dir_all(&project).unwrap();
let root = std::fs::canonicalize(&project).unwrap();
let slot = root.join("slot.txt");
let outside = temporary.join("secret.txt");
std::fs::write(&slot, "safe").unwrap();
std::fs::write(&outside, "DUMMY_SEARCH_SECRET").unwrap();
let collected = collect_parallel(&root);
let reader = crate::file_security::Root::new(&root).unwrap();
assert_eq!(collected, vec![slot.clone()]);
std::fs::remove_file(&slot).unwrap();
std::os::unix::fs::symlink(&outside, &slot).unwrap();
assert!(grep_one("DUMMY_SEARCH_SECRET", &collected[0], &reader, 10).is_empty());
std::fs::remove_file(&slot).unwrap();
std::fs::hard_link(&outside, &slot).unwrap();
assert!(
grep_parallel("DUMMY_SEARCH_SECRET", &collected, &reader, 10)
.into_iter()
.flatten()
.next()
.is_none()
);
std::fs::remove_dir_all(temporary).unwrap();
}
#[cfg(unix)]
#[test]
fn capped_boundary() {
let dir = std::env::temp_dir().join(format!(
"rdsh-search-cap-{}",
crate::local_http::random_token().unwrap()
));
std::fs::create_dir(&dir).unwrap();
let dir = std::fs::canonicalize(dir).unwrap();
let reader = crate::file_security::Root::new(&dir).unwrap();
let exact = dir.join("rdsh-cap-exact.txt");
let over = dir.join("rdsh-cap-over.txt");
let bin = dir.join("rdsh-cap-bin.bin");
std::fs::write(&exact, vec![120u8; 2_000_000]).unwrap();
let mut big = vec![120u8; 2_000_001];
big.push(121);
std::fs::write(&over, big).unwrap();
std::fs::write(&bin, [0u8, 159, 146, 150]).unwrap();
assert!(read_capped(&exact, &reader).is_some());
assert!(read_capped(&over, &reader).is_none());
assert!(read_capped(&bin, &reader).is_none());
assert!(read_capped(&dir.join("rdsh-cap-missing.txt"), &reader).is_none());
std::fs::remove_dir_all(dir).unwrap();
}
}