Skip to content

Commit f83ad78

Browse files
authored
fix(source): stream wordlist file instead of loading into memory (#76)
1 parent afb45b5 commit f83ad78

1 file changed

Lines changed: 174 additions & 50 deletions

File tree

‎src/source/wordlist.rs‎

Lines changed: 174 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,39 @@
11
//! Wordlist source - generate keys from file of passphrases.
2+
//!
3+
//! Streams the file in chunks to avoid loading entire wordlists into memory.
24
35
use anyhow::Result;
46
use indicatif::ProgressBar;
57
use rayon::prelude::*;
6-
use std::fs::File;
7-
use std::io::{BufRead, BufReader};
8-
use std::path::Path;
8+
use std::fs;
9+
use std::io::{BufRead, BufReader, Seek};
10+
use std::path::{Path, PathBuf};
911

1012
use super::{ProcessStats, Source};
1113
use crate::derive::KeyDeriver;
1214
use crate::matcher::Matcher;
1315
use crate::output::Output;
1416
use crate::transform::{Input, Transform};
1517

18+
const CHUNK_SIZE: usize = 100_000;
19+
const BATCH_SIZE: usize = 1000;
20+
1621
/// Generate keys from a wordlist file
1722
pub struct WordlistSource {
18-
lines: Vec<String>,
23+
path: PathBuf,
1924
}
2025

2126
impl WordlistSource {
2227
pub fn from_file(path: &Path) -> Result<Self> {
23-
let file = File::open(path)?;
24-
let reader = BufReader::new(file);
25-
let lines: Vec<String> = reader
26-
.lines()
27-
.filter_map(|line| match line {
28-
Ok(s) => {
29-
let trimmed = s.trim().to_string();
30-
if trimmed.is_empty() { None } else { Some(Ok(trimmed)) }
31-
}
32-
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => None,
33-
Err(e) => Some(Err(e)),
34-
})
35-
.collect::<std::io::Result<Vec<_>>>()?;
36-
37-
Ok(Self { lines })
28+
if !path.exists() {
29+
anyhow::bail!("Wordlist file not found: {}", path.display());
30+
}
31+
if !path.is_file() {
32+
anyhow::bail!("Not a file: {}", path.display());
33+
}
34+
Ok(Self {
35+
path: path.to_path_buf(),
36+
})
3837
}
3938
}
4039

@@ -46,51 +45,176 @@ impl Source for WordlistSource {
4645
matcher: Option<&Matcher>,
4746
output: &dyn Output,
4847
) -> Result<ProcessStats> {
49-
let pb = ProgressBar::new(self.lines.len() as u64);
48+
let file_size = fs::metadata(&self.path)?.len();
49+
let pb = ProgressBar::new(file_size);
5050
pb.set_style(crate::default_progress_style());
5151

52-
5352
let stats = std::sync::atomic::AtomicU64::new(0);
5453
let matches = std::sync::atomic::AtomicU64::new(0);
54+
let mut inputs_processed = 0u64;
55+
let mut bytes_consumed = 0u64;
56+
57+
let file = std::fs::File::open(&self.path)?;
58+
let mut reader = BufReader::new(file);
59+
let mut chunk = Vec::with_capacity(CHUNK_SIZE);
60+
let mut line_buf = String::new();
61+
62+
loop {
63+
line_buf.clear();
64+
let bytes_read = match reader.read_line(&mut line_buf) {
65+
Ok(0) => break,
66+
Ok(n) => n as u64,
67+
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
68+
// read_line() already consumed the bytes, sync position from reader
69+
bytes_consumed = reader.stream_position().unwrap_or(bytes_consumed);
70+
pb.set_position(bytes_consumed);
71+
continue;
72+
}
73+
Err(e) => return Err(e.into()),
74+
};
5575

56-
self.lines.par_chunks(1000).for_each(|chunk| {
57-
let inputs: Vec<Input> = chunk
58-
.iter()
59-
.map(|s| Input::from_string(s.clone()))
60-
.collect();
61-
let mut buffer = Vec::with_capacity(inputs.len() * 2);
62-
63-
for transform in transforms {
64-
buffer.clear();
65-
transform.apply_batch(&inputs, &mut buffer);
66-
67-
for (source, key) in &buffer {
68-
let derived = deriver.derive(key);
69-
70-
if let Some(m) = matcher {
71-
if let Some(match_info) = m.check(&derived) {
72-
output
73-
.hit(source, transform.name(), &derived, &match_info)
74-
.ok();
75-
matches.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
76-
}
77-
} else {
78-
output.key(source, transform.name(), &derived).ok();
79-
}
76+
bytes_consumed += bytes_read;
77+
pb.set_position(bytes_consumed);
8078

81-
stats.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
82-
}
79+
let trimmed = line_buf.trim().to_string();
80+
if trimmed.is_empty() {
81+
continue;
8382
}
8483

85-
pb.inc(chunk.len() as u64);
86-
});
84+
chunk.push(trimmed);
85+
inputs_processed += 1;
86+
87+
if chunk.len() >= CHUNK_SIZE {
88+
process_chunk(
89+
&chunk, transforms, deriver, matcher, output, &stats, &matches,
90+
);
91+
chunk.clear();
92+
}
93+
}
94+
95+
if !chunk.is_empty() {
96+
process_chunk(
97+
&chunk, transforms, deriver, matcher, output, &stats, &matches,
98+
);
99+
}
87100

88101
pb.finish_and_clear();
89102

90103
Ok(ProcessStats {
91-
inputs_processed: self.lines.len() as u64,
104+
inputs_processed,
92105
keys_generated: stats.load(std::sync::atomic::Ordering::Relaxed),
93106
matches_found: matches.load(std::sync::atomic::Ordering::Relaxed),
94107
})
95108
}
96109
}
110+
111+
fn process_chunk(
112+
lines: &[String],
113+
transforms: &[Box<dyn Transform>],
114+
deriver: &KeyDeriver,
115+
matcher: Option<&Matcher>,
116+
output: &dyn Output,
117+
stats: &std::sync::atomic::AtomicU64,
118+
matches: &std::sync::atomic::AtomicU64,
119+
) {
120+
lines.par_chunks(BATCH_SIZE).for_each(|batch| {
121+
let inputs: Vec<Input> = batch
122+
.iter()
123+
.map(|s| Input::from_string(s.clone()))
124+
.collect();
125+
let mut buffer = Vec::with_capacity(inputs.len() * 2);
126+
127+
for transform in transforms {
128+
buffer.clear();
129+
transform.apply_batch(&inputs, &mut buffer);
130+
131+
for (source, key) in &buffer {
132+
let derived = deriver.derive(key);
133+
134+
if let Some(m) = matcher {
135+
if let Some(match_info) = m.check(&derived) {
136+
output
137+
.hit(source, transform.name(), &derived, &match_info)
138+
.ok();
139+
matches.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
140+
}
141+
} else {
142+
output.key(source, transform.name(), &derived).ok();
143+
}
144+
145+
stats.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
146+
}
147+
}
148+
});
149+
}
150+
151+
#[cfg(test)]
152+
mod tests {
153+
use super::*;
154+
use crate::derive::KeyDeriver;
155+
use crate::output::ConsoleOutput;
156+
use std::io::Write;
157+
use tempfile::NamedTempFile;
158+
159+
#[test]
160+
fn from_file_not_found() {
161+
let result = WordlistSource::from_file(Path::new("/nonexistent/path/file.txt"));
162+
assert!(result.is_err());
163+
}
164+
165+
#[test]
166+
fn from_file_not_a_file() {
167+
let dir = tempfile::tempdir().unwrap();
168+
let result = WordlistSource::from_file(dir.path());
169+
assert!(result.is_err());
170+
}
171+
172+
#[test]
173+
fn process_empty_file() {
174+
let mut file = NamedTempFile::new().unwrap();
175+
file.write_all(b"").unwrap();
176+
177+
let source = WordlistSource::from_file(file.path()).unwrap();
178+
let deriver = KeyDeriver::new();
179+
let output = ConsoleOutput::new();
180+
let transforms: Vec<Box<dyn Transform>> = Vec::new();
181+
182+
let stats = source
183+
.process(&transforms, &deriver, None, &output)
184+
.unwrap();
185+
assert_eq!(stats.inputs_processed, 0);
186+
}
187+
188+
#[test]
189+
fn process_skips_blank_lines() {
190+
let mut file = NamedTempFile::new().unwrap();
191+
file.write_all(b"hello\n\n \nworld\n").unwrap();
192+
193+
let source = WordlistSource::from_file(file.path()).unwrap();
194+
let deriver = KeyDeriver::new();
195+
let output = ConsoleOutput::new();
196+
let transforms: Vec<Box<dyn Transform>> = Vec::new();
197+
198+
let stats = source
199+
.process(&transforms, &deriver, None, &output)
200+
.unwrap();
201+
assert_eq!(stats.inputs_processed, 2);
202+
}
203+
204+
#[test]
205+
fn process_skips_invalid_utf8() {
206+
let mut file = NamedTempFile::new().unwrap();
207+
file.write_all(b"valid\n\xff\xfe\ninvalid bytes\ntest\n")
208+
.unwrap();
209+
210+
let source = WordlistSource::from_file(file.path()).unwrap();
211+
let deriver = KeyDeriver::new();
212+
let output = ConsoleOutput::new();
213+
let transforms: Vec<Box<dyn Transform>> = Vec::new();
214+
215+
let stats = source
216+
.process(&transforms, &deriver, None, &output)
217+
.unwrap();
218+
assert_eq!(stats.inputs_processed, 3);
219+
}
220+
}

0 commit comments

Comments
 (0)