@@ -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
957983fn 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\x00 3\x00 foo\t bar\x00 some/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\t bar" )
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\x00 1\x00 secret*\x00 secret\n name.py\x00 " ;
1326+ let rules = parse_verbose_ignore_records ( stdout) ;
1327+ assert_eq ! ( rules. len( ) , 1 ) ;
1328+ assert_eq ! (
1329+ rules. get( "secret\n name.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\t bar\t some/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\t bar" ) ;
1335+ fn several_records_and_a_trailing_nul_parse_cleanly ( ) {
1336+ let stdout = ".gitignore\x00 1\x00 *.log\x00 a.log\x00 .gitignore\x00 2\x00 *.tmp\x00 b/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\t file.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\x00 1\x00 *.log\x00 a.log\x00 .gitignore\x00 2\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 ---
0 commit comments