@@ -9,7 +9,9 @@ use crate::name::{LocalName, Namespace, NamespaceResolver, QName};
99use crate :: utils:: { is_whitespace, Bytes } ;
1010use crate :: XmlVersion ;
1111
12+ use std:: collections:: HashSet ;
1213use std:: fmt:: { self , Debug , Display , Formatter } ;
14+ use std:: hash:: { BuildHasherDefault , DefaultHasher , Hasher } ;
1315use std:: iter:: FusedIterator ;
1416use std:: { borrow:: Cow , ops:: Range } ;
1517
@@ -964,6 +966,53 @@ enum State {
964966 SkipEqValue ( usize ) ,
965967}
966968
969+ /// Number of attributes a start tag may have before the duplicate-name check
970+ /// switches from a direct linear scan of the previously seen names to a hash
971+ /// pre-filter (see [`IterState::check_for_duplicates`]).
972+ ///
973+ /// Real-world start tags carry only a handful of attributes -- the busiest
974+ /// element in our benchmark corpus (`tests/documents/players.xml`) has 22 --
975+ /// where the scan is faster than hashing and needs no allocation. Larger tags
976+ /// are where the scan became the O(N²) CPU-DoS of [#969], so above this count we
977+ /// pay for a hash set to keep the whole tag O(N). The value sits just above the
978+ /// measured linear-vs-hash crossover.
979+ ///
980+ /// [#969]: https://github.com/tafia/quick-xml/issues/969
981+ const SMALL_ATTRIBUTE_COUNT : usize = 32 ;
982+
983+ /// A no-op [`Hasher`] for the `key_hashes` set, whose values are already 64-bit
984+ /// hashes of attribute names; re-hashing them with the default SipHash would be
985+ /// wasted work. Only `write_u64` is ever exercised (via `u64`'s `Hash` impl).
986+ #[ derive( Default ) ]
987+ struct IdentityHasher ( u64 ) ;
988+
989+ impl Hasher for IdentityHasher {
990+ #[ inline]
991+ fn finish ( & self ) -> u64 {
992+ self . 0
993+ }
994+
995+ #[ inline]
996+ fn write ( & mut self , _: & [ u8 ] ) {
997+ // The set only ever stores `u64` keys, which route through `write_u64`.
998+ unreachable ! ( "IdentityHasher only supports u64 keys" )
999+ }
1000+
1001+ #[ inline]
1002+ fn write_u64 ( & mut self , n : u64 ) {
1003+ self . 0 = n;
1004+ }
1005+ }
1006+
1007+ /// Hashes a single attribute name. A fresh [`DefaultHasher`] per name keeps each
1008+ /// hash independent (so it is also DoS-resistant on untrusted input).
1009+ #[ inline]
1010+ fn hash_name ( name : & [ u8 ] ) -> u64 {
1011+ let mut hasher = DefaultHasher :: new ( ) ;
1012+ hasher. write ( name) ;
1013+ hasher. finish ( )
1014+ }
1015+
9671016/// External iterator over spans of attribute key and value
9681017#[ derive( Clone , Debug ) ]
9691018pub ( crate ) struct IterState {
@@ -979,6 +1028,13 @@ pub(crate) struct IterState {
9791028 /// names. We store a ranges instead of slices to able to report a previous
9801029 /// attribute position
9811030 keys : Vec < Range < usize > > ,
1031+ /// 64-bit hashes of the byte content of `keys`, used as an O(1) pre-filter
1032+ /// once a start tag declares more than `SMALL_ATTRIBUTE_COUNT` attributes, so
1033+ /// the duplicate check stays O(N) over the whole tag instead of O(N²). The
1034+ /// values are already hashes, so the set stores them with `IdentityHasher`
1035+ /// instead of re-hashing. Allocated only when the threshold is crossed, so
1036+ /// small tags (and [`IterState::new`]) stay allocation-free and `const`.
1037+ key_hashes : Option < HashSet < u64 , BuildHasherDefault < IdentityHasher > > > ,
9821038}
9831039
9841040impl IterState {
@@ -988,6 +1044,7 @@ impl IterState {
9881044 html,
9891045 check_duplicates : true ,
9901046 keys : Vec :: new ( ) ,
1047+ key_hashes : None ,
9911048 }
9921049 }
9931050
@@ -1063,13 +1120,27 @@ impl IterState {
10631120 }
10641121 }
10651122
1123+ /// Checks that the attribute name `key` (a range into `slice`) was not seen
1124+ /// earlier in the same start tag, recording it for subsequent checks.
1125+ ///
1126+ /// Small tags use a direct linear scan of [`Self::keys`]: for a handful of
1127+ /// attributes that beats hashing and needs no allocation, which is the
1128+ /// overwhelmingly common case. Once a tag declares more than
1129+ /// `SMALL_ATTRIBUTE_COUNT` attributes -- where the scan would become the
1130+ /// O(N²) CPU-DoS of [#969] -- it switches to a hash pre-filter that keeps the
1131+ /// whole tag O(N).
1132+ ///
1133+ /// [#969]: https://github.com/tafia/quick-xml/issues/969
10661134 #[ inline]
10671135 fn check_for_duplicates (
10681136 & mut self ,
10691137 slice : & [ u8 ] ,
10701138 key : Range < usize > ,
10711139 ) -> Result < Range < usize > , AttrError > {
10721140 if self . check_duplicates {
1141+ if self . keys . len ( ) >= SMALL_ATTRIBUTE_COUNT {
1142+ return self . check_for_duplicates_hashed ( slice, key) ;
1143+ }
10731144 if let Some ( prev) = self
10741145 . keys
10751146 . iter ( )
@@ -1082,6 +1153,44 @@ impl IterState {
10821153 Ok ( key)
10831154 }
10841155
1156+ /// Cold path of [`Self::check_for_duplicates`] for start tags with many
1157+ /// attributes: a [`HashSet`] of 64-bit name hashes acts as an O(1) pre-filter
1158+ /// so iterating N attributes is O(N) rather than O(N²).
1159+ #[ cold]
1160+ fn check_for_duplicates_hashed (
1161+ & mut self ,
1162+ slice : & [ u8 ] ,
1163+ key : Range < usize > ,
1164+ ) -> Result < Range < usize > , AttrError > {
1165+ let keys = & self . keys ;
1166+ let key_hashes = self . key_hashes . get_or_insert_with ( || {
1167+ // First time over the threshold: seed the set with the names already
1168+ // collected during the linear phase so the pre-filter knows them.
1169+ let mut set = HashSet :: with_capacity_and_hasher (
1170+ keys. len ( ) * 2 ,
1171+ BuildHasherDefault :: < IdentityHasher > :: default ( ) ,
1172+ ) ;
1173+ for r in keys {
1174+ set. insert ( hash_name ( & slice[ r. clone ( ) ] ) ) ;
1175+ }
1176+ set
1177+ } ) ;
1178+ // A fresh hash proves the name is new. On a hit (a real duplicate, or the
1179+ // astronomically rare 64-bit collision) fall back to the linear scan to
1180+ // recover the exact previous position for `AttrError::Duplicated`.
1181+ if !key_hashes. insert ( hash_name ( & slice[ key. clone ( ) ] ) ) {
1182+ if let Some ( prev) = self
1183+ . keys
1184+ . iter ( )
1185+ . find ( |r| slice[ ( * r) . clone ( ) ] == slice[ key. clone ( ) ] )
1186+ {
1187+ return Err ( AttrError :: Duplicated ( key. start , prev. start ) ) ;
1188+ }
1189+ }
1190+ self . keys . push ( key. clone ( ) ) ;
1191+ Ok ( key)
1192+ }
1193+
10851194 /// # Parameters
10861195 ///
10871196 /// - `slice`: content of the tag, used for checking for duplicates
@@ -1988,6 +2097,40 @@ mod xml {
19882097 assert_eq ! ( iter. next( ) , None ) ;
19892098 assert_eq ! ( iter. next( ) , None ) ;
19902099 }
2100+
2101+ /// Once a start tag declares more than `SMALL_ATTRIBUTE_COUNT`
2102+ /// attributes the duplicate check switches to its hash-based path. A
2103+ /// duplicate of a name first seen during the earlier linear phase must
2104+ /// still be detected, with the original position reported. Regression
2105+ /// cover for the cold path of [#969].
2106+ ///
2107+ /// [#969]: https://github.com/tafia/quick-xml/issues/969
2108+ #[ test]
2109+ fn duplicate_past_hash_threshold ( ) {
2110+ let dup = SMALL_ATTRIBUTE_COUNT / 2 ;
2111+ let n = SMALL_ATTRIBUTE_COUNT + 8 ;
2112+
2113+ let mut source = String :: from ( "tag" ) ;
2114+ let mut positions = Vec :: with_capacity ( n) ;
2115+ for i in 0 ..n {
2116+ source. push ( ' ' ) ;
2117+ positions. push ( source. len ( ) ) ;
2118+ source. push_str ( & format ! ( "k{:04}=''" , i) ) ;
2119+ }
2120+ // Repeat the name first seen at `positions[dup]` (linear phase).
2121+ source. push ( ' ' ) ;
2122+ let dup_pos = source. len ( ) ;
2123+ source. push_str ( & format ! ( "k{:04}=''" , dup) ) ;
2124+
2125+ let mut iter = Attributes :: new ( & source, 3 ) ;
2126+ for _ in 0 ..n {
2127+ assert ! ( matches!( iter. next( ) , Some ( Ok ( _) ) ) ) ;
2128+ }
2129+ assert_eq ! (
2130+ iter. next( ) ,
2131+ Some ( Err ( AttrError :: Duplicated ( dup_pos, positions[ dup] ) ) )
2132+ ) ;
2133+ }
19912134 }
19922135
19932136 /// Check for duplicated names is disabled
0 commit comments