Skip to content

Commit 1cc886d

Browse files
committed
fix: NUL-delimit check-ignore; count untracked lines in chunks
1 parent 46b3676 commit 1cc886d

2 files changed

Lines changed: 135 additions & 47 deletions

File tree

‎crates/diffctx-native/src/git.rs‎

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -831,10 +831,18 @@ pub fn find_ignored_paths(repo_root: &Path, rel_paths: &[String]) -> FxHashSet<S
831831
// below turned that into "nothing is ignored" — silently disabling the
832832
// `.diffctx/ignore` contract exactly when the repo is large enough for it
833833
// to matter. stdin has no such ceiling.
834+
// `-z` on BOTH sides. Line-delimited input splits a path containing a
835+
// newline into two phantom queries: git then answers about `secret` and
836+
// `name.py` while the real path `secret\nname.py` never gets a verdict, so
837+
// the lookup below misses it and a file the user declared ignored is
838+
// published. Verified against git directly — line mode reports the
839+
// truncated stem, NUL mode reports the whole path. The rest of this module
840+
// is already `-z` throughout.
834841
let mut args: Vec<String> = vec![
835842
"check-ignore".into(),
836843
"--no-index".into(),
837844
"-v".into(),
845+
"-z".into(),
838846
"--stdin".into(),
839847
];
840848
if let Some(ref path) = temp_excludes {
@@ -848,7 +856,7 @@ pub fn find_ignored_paths(repo_root: &Path, rel_paths: &[String]) -> FxHashSet<S
848856
// matches that nobody is draining, we block on the write, and the pipeline
849857
// hangs until the git timeout fires. A file hands the feeding to the kernel,
850858
// which needs no second thread to stay correct.
851-
let query_file = write_private_temp_file(&queries.join("\n"));
859+
let query_file = write_private_temp_file(&format!("{}\0", queries.join("\0")));
852860

853861
let result = (|| -> Result<FxHashSet<String>> {
854862
let Some(ref query_path) = query_file else {
@@ -875,12 +883,7 @@ pub fn find_ignored_paths(repo_root: &Path, rel_paths: &[String]) -> FxHashSet<S
875883
let stdout = String::from_utf8_lossy(&output.stdout);
876884
let excludes_source = temp_excludes.as_ref().map(|p| p.display().to_string());
877885

878-
let mut rules: rustc_hash::FxHashMap<String, String> = rustc_hash::FxHashMap::default();
879-
for line in stdout.lines() {
880-
if let Some((rule, path)) = parse_verbose_ignore_match(line) {
881-
rules.insert(path, rule);
882-
}
883-
}
886+
let rules = parse_verbose_ignore_records(&stdout);
884887

885888
Ok(rel_paths
886889
.iter()
@@ -949,9 +952,32 @@ pub fn find_ignored_paths(repo_root: &Path, rel_paths: &[String]) -> FxHashSet<S
949952
/// containing a tab, so the last tab is always the separator. Splitting from
950953
/// the left mis-parses a pattern that itself contains a tab, and the resulting
951954
/// lookup miss reports an ignored file as not ignored — i.e. it leaks.
952-
fn parse_verbose_ignore_match(line: &str) -> Option<(String, String)> {
953-
let (rule, path) = line.rsplit_once('\t')?;
954-
Some((rule.to_string(), unquote_c_style(path)))
955+
/// `check-ignore -v -z` emits four NUL-separated fields per match —
956+
/// `<source>\0<line>\0<pattern>\0<path>\0` — and never quotes the path,
957+
/// because NUL cannot appear in one.
958+
///
959+
/// The text format this replaced (`<source>:<line>:<pattern>\t<path>`) had to
960+
/// guess where the pattern ended and the path began, and C-quoting made a path
961+
/// containing a tab or a newline ambiguous. Field-delimited records remove the
962+
/// guess entirely.
963+
fn parse_verbose_ignore_records(stdout: &str) -> rustc_hash::FxHashMap<String, String> {
964+
let mut rules: rustc_hash::FxHashMap<String, String> = rustc_hash::FxHashMap::default();
965+
let fields: Vec<&str> = stdout.split('\0').collect();
966+
// A trailing NUL leaves an empty final element; chunks of four skip it.
967+
for record in fields.chunks(4) {
968+
if record.len() < 4 {
969+
break;
970+
}
971+
let (source, line, pattern, path) = (record[0], record[1], record[2], record[3]);
972+
if path.is_empty() {
973+
continue;
974+
}
975+
// The rule identity keeps the text format's shape: callers compare it
976+
// against `format!("{excludes_source}:")` to tell a diffctx-declared
977+
// exclusion from a gitignore one.
978+
rules.insert(path.to_string(), format!("{source}:{line}:{pattern}"));
979+
}
980+
rules
955981
}
956982

957983
fn ancestor_dirs(rel: &str) -> Vec<String> {
@@ -1276,25 +1302,52 @@ mod tests {
12761302
}
12771303
}
12781304

1279-
// --- parse_verbose_ignore_match: last-tab split survives a tab in the pattern ---
1305+
// --- parse_verbose_ignore_records: NUL fields remove every delimiter guess ---
1306+
1307+
#[test]
1308+
fn a_tab_in_the_pattern_no_longer_needs_disambiguating() {
1309+
// The text format put the pattern and the path on one line separated by
1310+
// a tab, so a pattern containing a tab had to be split from the right
1311+
// and hoped for. Fields make it unambiguous.
1312+
let stdout = ".gitignore\x003\x00foo\tbar\x00some/real/path.txt\x00";
1313+
let rules = parse_verbose_ignore_records(stdout);
1314+
assert_eq!(
1315+
rules.get("some/real/path.txt").map(String::as_str),
1316+
Some(".gitignore:3:foo\tbar")
1317+
);
1318+
}
1319+
1320+
/// The leak that forced `-z`. A newline in a filename split the query into
1321+
/// two phantom paths, git answered about the stem, the real path never got
1322+
/// a verdict, and a file the user declared ignored was published.
1323+
#[test]
1324+
fn a_newline_in_the_path_survives_as_one_record() {
1325+
let stdout = "excl\x001\x00secret*\x00secret\nname.py\x00";
1326+
let rules = parse_verbose_ignore_records(stdout);
1327+
assert_eq!(rules.len(), 1);
1328+
assert_eq!(
1329+
rules.get("secret\nname.py").map(String::as_str),
1330+
Some("excl:1:secret*")
1331+
);
1332+
}
12801333

12811334
#[test]
1282-
fn parse_verbose_ignore_match_splits_on_last_tab_not_first() {
1283-
// The pattern itself ("foo\tbar") contains a literal tab. Splitting
1284-
// from the left would take "foo" as the path, silently reporting an
1285-
// ignored file as not ignored.
1286-
let line = ".gitignore:3:foo\tbar\tsome/real/path.txt";
1287-
let (rule, path) = parse_verbose_ignore_match(line).expect("parse");
1288-
assert_eq!(path, "some/real/path.txt");
1289-
assert_eq!(rule, ".gitignore:3:foo\tbar");
1335+
fn several_records_and_a_trailing_nul_parse_cleanly() {
1336+
let stdout = ".gitignore\x001\x00*.log\x00a.log\x00.gitignore\x002\x00*.tmp\x00b/c.tmp\x00";
1337+
let rules = parse_verbose_ignore_records(stdout);
1338+
assert_eq!(rules.len(), 2);
1339+
assert!(rules.contains_key("a.log"));
1340+
assert!(rules.contains_key("b/c.tmp"));
12901341
}
12911342

12921343
#[test]
1293-
fn parse_verbose_ignore_match_unquotes_c_style_path() {
1294-
let line = ".gitignore:1:*.log\t\"weird\\tfile.log\"";
1295-
let (rule, path) = parse_verbose_ignore_match(line).expect("parse");
1296-
assert_eq!(path, "weird\tfile.log");
1297-
assert_eq!(rule, ".gitignore:1:*.log");
1344+
fn a_truncated_final_record_is_dropped_not_half_read() {
1345+
// Killed mid-write, the last record is short. Reading three fields as
1346+
// four would key a rule under a pattern.
1347+
let stdout = ".gitignore\x001\x00*.log\x00a.log\x00.gitignore\x002\x00*.tmp\x00";
1348+
let rules = parse_verbose_ignore_records(stdout);
1349+
assert_eq!(rules.len(), 1);
1350+
assert!(rules.contains_key("a.log"));
12981351
}
12991352

13001353
// --- anchor_diffctx_ignore_line: 4 reachable outputs, root vs nested, negation ---

‎crates/diffctx-native/src/pipeline.rs‎

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -122,33 +122,68 @@ pub fn build_diff_context_locate(
122122
/// Line count for an untracked file, or `None` when it is not readable UTF-8
123123
/// text — the same rejection `read_to_string` gave, so binaries stay excluded.
124124
///
125-
/// Counted through a reader rather than by materialising the file. Untracked
126-
/// files are scanned before any size filter applies (`max_changed_file_size` is
127-
/// enforced later, in fragmentation), so a dirty tree holding one multi-GB log
128-
/// allocated the whole thing here just to reach `.lines().count()`. Memory is
129-
/// now bounded by the buffer instead of the file size, and the count is
130-
/// unchanged: `str::lines` and `BufRead::lines` agree on `\n` splitting, on
131-
/// stripping a trailing `\r`, and on not counting a trailing newline as a line.
125+
/// Untracked files are scanned before any size filter applies
126+
/// (`max_changed_file_size` is enforced later, in fragmentation), so a dirty
127+
/// tree holding one multi-GB log used to allocate all of it here just to reach
128+
/// `.lines().count()`.
129+
///
130+
/// Counted over fixed byte chunks rather than by line. `BufReader::lines()`
131+
/// bounds nothing on its own: it allocates each line, and a minified bundle or
132+
/// a single-line JSON dump is one line hundreds of megabytes long — exactly the
133+
/// shape this was supposed to stop loading. The buffer is the only allocation
134+
/// that scales.
135+
///
136+
/// UTF-8 is validated as it streams, with the incomplete tail of one chunk
137+
/// carried into the next, so a multi-byte character split across a chunk
138+
/// boundary is not mistaken for the invalid byte that rejects a binary.
132139
///
133140
/// Counting rather than size-gating keeps this bit-identical: an oversized file
134-
/// still gets the same hunk it always did. Skipping it outright would also be
135-
/// safe for the changed-files list (that comes from the untracked scan, not from
136-
/// hunk emission — a binary is listed today with no hunk at all), but it would
137-
/// drop a hunk the rest of the pipeline has seen since forever, and nothing
138-
/// here needs that risk. What remains unbounded is time, not memory: a
139-
/// multi-GB file is still streamed end to end.
141+
/// still gets the same hunk it always did, and the count matches `str::lines`
142+
/// (both split on `\n` and neither counts a trailing newline as a line).
140143
fn count_text_lines(path: &Path) -> Option<u32> {
141-
use std::io::BufRead;
142-
143-
let file = std::fs::File::open(path).ok()?;
144-
let mut count: u32 = 0;
145-
for line in std::io::BufReader::new(file).lines() {
146-
// Invalid UTF-8 rejects the whole file, as `read_to_string` did — a
147-
// partial count would invent a hunk length for a binary.
148-
line.ok()?;
149-
count = count.saturating_add(1);
144+
use std::io::Read;
145+
146+
const CHUNK: usize = 64 * 1024;
147+
148+
let mut file = std::fs::File::open(path).ok()?;
149+
let mut buf = vec![0u8; CHUNK];
150+
let mut carry: Vec<u8> = Vec::new();
151+
let mut newlines: u32 = 0;
152+
let mut last_byte: Option<u8> = None;
153+
154+
loop {
155+
let read = file.read(&mut buf).ok()?;
156+
if read == 0 {
157+
break;
158+
}
159+
carry.extend_from_slice(&buf[..read]);
160+
let valid_upto = match std::str::from_utf8(&carry) {
161+
Ok(_) => carry.len(),
162+
// A truncated character at the end of a chunk is not an error yet;
163+
// anything else is a binary and rejects the file, as before.
164+
Err(e) if e.error_len().is_none() => e.valid_up_to(),
165+
Err(_) => return None,
166+
};
167+
newlines = newlines
168+
.saturating_add(carry[..valid_upto].iter().filter(|b| **b == b'\n').count() as u32);
169+
if let Some(&b) = carry[..valid_upto].last() {
170+
last_byte = Some(b);
171+
}
172+
carry.drain(..valid_upto);
173+
}
174+
175+
// Trailing bytes that never completed a character mean the file ends
176+
// mid-sequence — invalid UTF-8, same verdict as `read_to_string`.
177+
if !carry.is_empty() {
178+
return None;
150179
}
151-
Some(count)
180+
// `str::lines` does not count a trailing newline as starting a line, and
181+
// counts a final unterminated line as one.
182+
Some(match last_byte {
183+
None => 0,
184+
Some(b'\n') => newlines,
185+
Some(_) => newlines.saturating_add(1),
186+
})
152187
}
153188

154189
/// Heavy phase: clone/parse/fragment/discover/tokenize/score. Independent

0 commit comments

Comments
 (0)