. "$BST_LIB" # BST-TIMEOUT: 900 # TWO python3 starts, and they are the whole cost. Everything # # else below reads the filesystem or dpkg with shell builtins, # # or greps what one of those two starts printed. # # # # The two are: a bare `import' (the platform gate) and the # # probe, which takes ONE start for 130-odd measurements. The # # work inside the probe is JSON over payloads of at most a few # # kilobytes -- microseconds beside an interpreter start on this # # arm, which is tens of seconds before the first line of the # # program runs. # # # # 900 is 450s per start, against the ~60s a python3 start has # # measured at here. 99-python3-crc32c gives its own three starts # # 1200 on the same arithmetic. Nothing below is quadratic in # # anything: the largest single input is the 1025-deep nesting # # document, which is 2050 bytes. # # # # A suite-timeout red is a batch-wide cost, so if the two starts # # prove slower than that on target the RIGHT fix is this number, # # not fewer cases. # # python3-ujson 5.9.0 (meta-openembedded/meta-python # recipes-devtools/python/python3-ujson_5.9.0.bb, `inherit pypi ptest setuptools3', # PyPI sdist) on the wasm32-hwjs glibc image. BRINTOS-2036. # # ============================================================================ # WHAT THE PACKAGE IS, AND WHY THE IMPORT IS A GATE RATHER THAN A FORMALITY. # ============================================================================ # # UltraJSON: a JSON encoder and decoder written entirely in C, exposing the # standard library's dumps/loads/dump/load names so it can be dropped in where # `json' is too slow. # # It is ONE CPython extension and nothing else. setup.py declares a single # Extension("ujson", ...) and `find . -name '*.py' -not -path '*/tests/*'' over # the sdist returns setup.py alone, so -- unlike python3-protobuf, which has a # documented pure-Python codec and was ported by declining its accelerator -- # there is no half of this package that survives without the .so. # # THIS IS ALSO THE FIRST CPYTHON EXTENSION ON THIS ARM WHOSE SOURCES ARE NOT ALL # C. ujson vendors Google's double-conversion, so nine of its fourteen # translation units are C++ and the module carries a C++ ABI allocation surface # that a -shared side module has to satisfy from inside itself. What that cost # the recipe is in python3-ujson_%.bbappend; what it means here is that `import' # below is the case that answers whether any of it worked. # # ============================================================================ # WHERE THE EXPECTED VALUES COME FROM. TWO PLACES, AND THE SPLIT IS THE POINT. # ============================================================================ # # 1. WHERE ujson AND `json' AGREE, THE ORACLE IS `json' ITSELF, ON THIS MACHINE. # The corpus below is round-tripped three ways in the probe -- # ujson.loads(ujson.dumps(x)), json.loads(ujson.dumps(x)) and # ujson.loads(json.dumps(x)) -- and every one must reproduce x. No expected # literal is carried for any of it: the claim is that the two codecs on the # TARGET agree, so a wasm32 disagreement in the C parser shows up with no host # oracle involved at all. That is what makes "drop-in replacement" a testable # property rather than a slogan. # # The probe's comparator is deliberately stricter than `==': bools are not # allowed to compare equal to ints, floats are compared by repr (so -0.0 and a # one-ulp drift both red), and an int is not allowed to equal a float -- which # is exactly the shape an integer that fell into a double would take. # # 2. WHERE THEY ARE DOCUMENTED TO DIFFER, THE LITERAL IS MEASURED ON AN x86-64 # CONTROL AT THE SAME VERSION, FROM THE SAME SOURCE, WITH THE SAME PATCHES. # The control is `bitbake python3-ujson-native' out of this very recipe -- # BBCLASSEXTEND = "native" is already there -- run on CPython 3.12.14. That # matters more than usual here because the recipe carries four CVE backports # (below) and three of them change behaviour this suite asserts, so a value # copied from upstream documentation or from a distro ujson would be wrong. # # THE FOUR CVE PATCHES, because they are why several literals below are what they # are and not what the package's README says: # # CVE-2026-32875 indent overflow. `indent' is now capped, and the dubious # negative-indent behaviour (pad colons, emit no indentation) # is preserved ON PURPOSE by the patch's own author. Both ends # are pinned below: `indent-neg' and `indent-big'. # CVE-2026-32874 a leak parsing large integers, in Object_newIntegerFromString. # No behaviour change; the big-integer cases below are what # exercise the path. # CVE-2026-44660 ujson.dump() failure cleanup. `dump-write-fails' is the case. # CVE-2026-54911 more UTF-8 validation under reject_bytes=False. The three # `bytes-off-*' rejection cases are that patch's regression. # # ============================================================================ # TWO THINGS THE TICKET PREDICTED THAT THE PACKAGE DOES NOT DO. # ============================================================================ # # Both were measured on the x86-64 control, so they are facts about ujson 5.9.0 # and not about this arm. They are asserted as ujson's actual behaviour -- a # suite that asserted the ticket's prediction would be red on a correct port. # # * THERE IS NO INTEGER OVERFLOW. The ticket expected one. ujson stores what # fits a 64-bit slot and falls through to CPython's own arbitrary-precision # conversion beyond it, so 2**64, 10**30 and -(10**30) all round-trip # exactly. They are corpus entries here rather than error cases, and # `enc-u64-max-plus' pins the encoder's answer so a future release that DID # start truncating is a red rather than a silently wrong number. # # * A BARE CONTROL CHARACTER IN A STRING IS ACCEPTED. The ticket expected # ujson.JSONDecodeError. RFC 8259 forbids an unescaped control character and # the standard library refuses one; ujson returns it. Both halves are # asserted -- `err-bare-control' that ujson accepts, `bare-control-json' # that `json' on the same interpreter refuses -- so the divergence is stated # rather than buried, and a ujson that started rejecting it is visible. # # Note this is a DECODER leniency only. The ENCODER escapes control characters # correctly, which `rt-str-control' covers by round-tripping "a\x00b\x01c\x1fd". # # ============================================================================ # WHAT THIS SUITE DOES NOT COVER. # ============================================================================ # # The recipe inherits ptest and installs upstream's tests/, but python3-ujson-ptest # RDEPENDS on python3-pytest, python3-pytz and python3-unittest-automake-output, # none of which this image can satisfy, so the -ptest package is not installed and # the upstream suite cannot be driven. Everything above drives the API directly # instead, which is why the corpus is written out here rather than deferred to it. P=python3-ujson # `suite-canary' is emitted FIRST and unconditionally. A suite that dies before # its first result line is indistinguishable from one that was never scheduled, # so this one line is what separates "ran and found nothing" from "did not run". bst_ok $P/suite-canary require python3 $P/python3-present SITE=/usr/lib/python3.12/site-packages SO=$SITE/ujson.cpython-312.so DI=$SITE/ujson-5.9.0.dist-info PKGLIST=/var/lib/dpkg/info/python3-ujson.list PROBE=$BST_DATA/python3-ujson-probe.py tmp=${TMPDIR:-/tmp} W=$tmp/bst-ujson.$$ mkdir -p "$W" || { bst_notok $P/workdir "cannot create $W"; exit 0; } # The corpus labels, in the probe's own order. Named once and used twice -- for # the assertions and for the skip loop -- so the two can never drift apart, which # is the failure mode a hand-written second copy has. RT_LABELS="null true false int_zero int_one int_minus_one int_i32_max int_i32_max_plus int_i32_min int_u32_max int_u32_max_plus int_i64_max int_i64_min int_u64_max int_u64_max_plus int_1e30 int_neg_1e30 float_zero float_neg_zero float_one float_neg float_tenth float_third float_tiny float_subnormal_min float_huge float_dbl_max float_exp_neg str_empty str_ascii str_quote str_backslash str_tab_nl str_control str_latin1 str_cjk str_astral str_slash str_html str_bmp_edge list_empty dict_empty list_mixed dict_mixed dict_unicode_keys dict_escape_keys nested_5 nested_50" ERR_LABELS="trailing_obj trailing_num trailing_arr unterminated_str unterminated_arr unterminated_obj invalid_escape bad_unicode_escape empty bare_word single_quotes trailing_comma_arr trailing_comma_obj leading_plus hex_number nan_lowercase" # Everything the probe answers that is neither a corpus round trip nor a decoder # error case. Same reason as RT_LABELS: one list, two uses. OTHER_CASES="probe-count module-answered is-extension version dist-version has-dumps has-loads has-dump has-load has-encode has-decode has-jsondecodeerror decode-error-is-valueerror corpus-count bytewise-identical bytewise-count dflt-sep-list dflt-sep-dict slash-default slash-off slash-json ascii-default ascii-off ascii-off-roundtrip html-default html-on indent-0 indent-2 indent-neg indent-big sort-off sort-on enc-nan enc-nan-nonan enc-inf enc-inf-nonan enc-ninf enc-ninf-nonan dec-nan dec-inf enc-u64-max enc-u64-max-plus enc-u64-max-plus-val enc-i64-min-minus enc-i64-min-minus-val enc-1e30-val dec-u64-max-plus bytes-default bytes-off bytes-off-utf8 bytes-off-bad-cont bytes-off-truncated bytes-off-overlong default-unhandled default-handler bare-control-json err-bare-control err-depth-1024 err-depth-1025 err-trailing-text dump-wrote load-read dump-write-fails encode-is-dumps decode-is-loads" # ---------------------------------------------------------------- packaging -- # WHERE THE PACKAGING SAYS THE MODULE LANDS. These run before any import, so a red # here says the .deb is wrong rather than that the module misbehaves -- which # matters more than usual for this port, since a missing .so and an unloadable one # both end at the same ImportError. check $P/site-packages-dir test -d "$SITE" check $P/extension-present test -f "$SO" check $P/extension-nonempty test -s "$SO" # IT IS AN EXTENSION AND NOTHING ELSE. The sdist has no .py, so a `ujson.py' or a # `ujson/' package directory here would mean a release that grew a pure-Python # half -- which would change what this port is and wants noticing. check $P/no-py-module test ! -f "$SITE/ujson.py" check $P/no-py-package test ! -d "$SITE/ujson" # THE SONAME CARRIES NO PLATFORM TRIPLE. Upstream names an extension # `ujson.cpython-312-.so'; poky's python3 reports an empty EXT_SUFFIX # triple on this arm, so the file is plain `ujson.cpython-312.so'. The import # resolves through importlib.machinery.EXTENSION_SUFFIXES, so the two must agree # -- and if a future poky restored the triple, this case reds before the import # does and says why. check_eq $P/extension-name "ujson.cpython-312.so" \ "$(cd "$SITE" 2>/dev/null && echo ujson.cpython-312*.so)" # NO EXECUTABLE. setup.py declares no console_scripts, so nothing lands in # /usr/bin. Asserted so a future release that started shipping one is noticed. check $P/no-bin-script test ! -e /usr/bin/ujson # --------------------------------------------------------------- dist-info --- check $P/dist-info-dir test -d "$DI" check $P/dist-info-metadata test -f "$DI/METADATA" check_out $P/dist-info-version "Version: 5.9.0" cat "$DI/METADATA" check_out $P/dist-info-name "Name: ujson" cat "$DI/METADATA" check $P/dist-info-wheel test -f "$DI/WHEEL" # NOT PURELIB, stated by the packaging itself. `Root-Is-Purelib: false' is the # wheel's own claim that it contains something architecture-specific -- the # opposite of what every pure-Python port here asserts, and the one-line summary # of why this port is different. check_out $P/dist-info-not-purelib "Root-Is-Purelib: false" cat "$DI/WHEEL" # ------------------------------------------------------------ the .deb file --- # The installed file list, read once into $W. The copy is what the loop below # redirects from: a stdin redirect the top-level shell performs on the virtiofs # root makes dup2(2) return 513 and ENDS the suite silently (brintos/linux#73), # and $W is on tmpfs. cat "$PKGLIST" > "$W/pkgf" 2>/dev/null || : > "$W/pkgf" check $P/pkg-list-present test -s "$W/pkgf" # EXACTLY ONE SHARED OBJECT SHIPS. Everything else in the package is dist-info # metadata. A second .so would mean the build produced something this port did not # expect and did not measure. check_eq $P/pkg-one-so "1" "$(grep -c '\.so$' "$W/pkgf" 2>/dev/null)" # Every payload path is under site-packages and is the .so or a dist-info file. _bad="" while IFS= read -r _f; do case "$_f" in ''|/usr/lib/python3.12/site-packages) continue ;; */) continue ;; esac [ -d "$_f" ] && continue case "$_f" in *.so|*.py|*.pyc) ;; */METADATA|*/WHEEL|*/RECORD|*/INSTALLER|*/LICENSE|*/LICENSE.*) ;; */AUTHORS.*|*/entry_points.txt|*/top_level.txt) ;; *) _bad="$_bad $_f" ;; esac done < "$W/pkgf" check_eq $P/pkg-only-expected "" "$_bad" # ------------------------------------------------------------- dpkg status --- sed -n '/^Package: python3-ujson$/,/^$/p' /var/lib/dpkg/status > "$W/st" 2>/dev/null check $P/pkg-known test -s "$W/st" check_out $P/pkg-installed "Status: install ok installed" cat "$W/st" check_out $P/pkg-arch "Architecture: wasm32" cat "$W/st" # THE DEPENDENCY SET IS THE PORT'S CLAIM ABOUT WHAT IT NEEDS AT RUN TIME. The # recipe's only DEPENDS is python3-setuptools-scm-native, which is build-time; its # target RDEPENDS are python3-datetime and python3-numbers, both sub-packages of # the ported interpreter. Asserted by name: a control stanza that had lost one # would surface as an ImportError that looked like this port's fault. grep '^Depends:' "$W/st" > "$W/dep" 2>/dev/null || : > "$W/dep" check $P/pkg-depends-line test -s "$W/dep" check_out $P/pkg-depends-core "python3-core" cat "$W/dep" check_out $P/pkg-depends-datetime "python3-datetime" cat "$W/dep" check_out $P/pkg-depends-numbers "python3-numbers" cat "$W/dep" # ------------------------------------------------------------------ import --- # THE PLATFORM GATE, and the first of two python3 starts. This is a dlopen of a # side module in site-packages whose CPython API imports must resolve against a # statically linked main, and whose C++ ABI allocation surface must resolve # against itself. check $P/import python3 -c 'import ujson' # ------------------------------------------------------------------- probe --- if [ ! -f "$PROBE" ]; then bst_notok $P/probe-present "probe missing at $PROBE" exit 0 fi bst_ok $P/probe-present # THE SECOND START, and the one that carries the suite. Bounded at 600s inside the # 900s suite budget so a hung probe is reported as a probe overrun with the last # line it printed, rather than as a suite timeout that says nothing about where it # stopped. # # The probe EXITS 0 even when the import fails, so this case passing means "the # probe ran", not "the module works" -- `import-ok' below is what carries that. bst_probe $P/probe-run "$W/probe" 600 python3 "$PROBE" OUT="$W/probe" # pv : the value the probe emitted for , or empty. `head -n 1' because # a key is emitted once and a duplicate would otherwise concatenate; `cut -f2-' # because a value may legitimately contain `=' (several JSON documents below do). pv() { grep "^$1=" "$OUT" 2>/dev/null | head -n 1 | cut -d= -f2-; } # A skip for every behavioural case, with one reason attached. Used by BOTH # early-exit branches below -- an incomplete probe and a failed import are one # fact each, and a hundred and thirty reds would bury either rather than report # it. The two cases that carry the verdict are reded, not skipped. skip_all_behavioural() { for _l in $RT_LABELS; do bst_skip $P/rt-$(printf '%s' "$_l" | tr _ -) "$1" done for _l in $ERR_LABELS; do bst_skip $P/err-$(printf '%s' "$_l" | tr _ -) "$1" done for _c in $OTHER_CASES; do bst_skip $P/$_c "$1"; done } # THE PROBE REACHED ITS END, and this is a gate rather than a formality on this # arm. Every case below reads a value the probe printed, so a probe that stopped # early would otherwise become a run of empty-string mismatches that all name the # wrong thing. # # IT HAS A SECOND, MORE LIKELY MEANING HERE. A trap inside a dlopen'd extension # does not raise a Python exception -- it ends the interpreter process, rc=132 -- # so a module that IMPORTS and then traps on the first call into it leaves # `import_ok=yes' emitted and nothing after it. That is exactly the shape # BRINTOS-2535 / linux-in-the-browser#186 record for python3-xxhash and # python3-lz4, and this port is in the same class, so the branch is written for # it rather than left to be discovered. # # The last key the probe managed to print is reported, because it says WHERE the # process died -- which is the difference between a trap in the encoder and one # in the decoder. _done=$(pv probe_done) check_eq $P/probe-complete "yes" "$_done" if [ "$_done" != "yes" ]; then _last=$(tail -n 1 "$OUT" 2>/dev/null | cut -d= -f1) _impinc=$(pv import_ok) skip_all_behavioural \ "the probe did not reach its end; last key emitted was '${_last:-none}', import_ok=${_impinc:-unset} -- a trap inside the extension ends the interpreter rather than raising" exit 0 fi # ------------------------------------------------------- the gate's verdict --- # ONE case for the platform fact, carrying the exception if there is one. _imp=$(pv import_ok) check_eq $P/import-ok "yes" "$_imp" if [ "$_imp" != "yes" ]; then # The extension did not load. Skip -- not red -- every behavioural case, each # with the reason attached: they are all one fact, and a hundred reds would bury # it rather than report it. `import' and `import-ok' above are the two reds that # carry the verdict, and the design doc attributes them. skip_all_behavioural "ujson.cpython-312.so did not load: $(pv import_error)" exit 0 fi check_eq $P/probe-count "132" "$(pv emitted_count)" # ---------------------------------------------------------------- identity --- # WHICH COPY ANSWERED. Without this every case below would be a statement about # whatever was first on sys.path. check_eq $P/module-answered "$SO" "$(pv module_file)" check_eq $P/is-extension "yes" "$(pv is_extension)" check_eq $P/version "5.9.0" "$(pv version)" check_eq $P/dist-version "5.9.0" "$(pv dist_version)" # The drop-in surface, by name. `encode'/`decode' are ujson's own aliases and are # asserted to agree with dumps/loads further down. check_eq $P/has-dumps "yes" "$(pv has_dumps)" check_eq $P/has-loads "yes" "$(pv has_loads)" check_eq $P/has-dump "yes" "$(pv has_dump)" check_eq $P/has-load "yes" "$(pv has_load)" check_eq $P/has-encode "yes" "$(pv has_encode)" check_eq $P/has-decode "yes" "$(pv has_decode)" check_eq $P/has-jsondecodeerror "yes" "$(pv has_JSONDecodeError)" # WHAT MAKES IT A DROP-IN FOR ERROR HANDLING TOO. json.JSONDecodeError is a # ValueError, so code that catches ValueError keeps working when ujson is # substituted. ujson.c builds its exception with PyExc_ValueError as the base; this # is the case that says so on target rather than in a comment. check_eq $P/decode-error-is-valueerror "yes" "$(pv decode_error_is_valueerror)" # ------------------------------------------- the corpus: ujson vs json, here --- # Each label is THREE claims (see the header): ujson->ujson, ujson->json and # json->ujson all reproduce the value, under a comparator that will not let a bool # pass as an int or a lost float sign pass as equal. The probe emits `ok' or a # string naming WHICH of the three broke and what came back, so one red names the # mechanism instead of only the input. check_eq $P/corpus-count "48" "$(pv corpus_count)" for _l in $RT_LABELS; do check_eq $P/rt-$(printf '%s' "$_l" | tr _ -) "ok" "$(pv rt_$_l)" done # A STRONGER CLAIM WHERE JSON LEAVES NO FREEDOM: for the singletons, the integers # and the ASCII strings that carry no character ujson escapes differently, the # BYTES must match json's compact form, not merely parse back to the same object. # Scoped deliberately -- `/', `<', `>' and every non-ASCII string are excluded # because ujson's defaults differ there by design, and those are asserted # individually below. check_eq $P/bytewise-count "20" "$(pv bytewise_count)" check_eq $P/bytewise-identical "yes" "$(pv bytewise_identical)" # ---------------------------------------------------- documented DIFFERENCES --- # From here down `json' is no longer the oracle. Every literal was measured on the # x86-64 python3-ujson-native build of this recipe. # ujson's default separators are compact; json's are `, ' and `: '. check_eq $P/dflt-sep-list "[1,2]" "$(pv dflt_sep_list)" check_eq $P/dflt-sep-dict '{"a":1,"b":2}' "$(pv dflt_sep_dict)" # FORWARD SLASHES. ujson escapes `/' by default and json never does -- the single # most likely thing to surprise someone substituting one for the other, because # both documents parse to the same string. check_eq $P/slash-default '"a\/b"' "$(pv slash_default)" check_eq $P/slash-off '"a/b"' "$(pv slash_off)" check_eq $P/slash-json '"a/b"' "$(pv slash_json)" # ensure_ascii. Default on in both; the OFF case is where a UTF-8 encoder on a # wasm32 target would show a wrong byte, and the astral pair is where a surrogate # bug would. The default case pins the surrogate PAIR spelling -- ujson emits # 😀 for U+1F600 rather than a single \U escape. check_eq $P/ascii-default '"h\u00e9\u65e5\ud83d\ude00"' "$(pv ascii_default)" check_eq $P/ascii-off '"hé日😀"' "$(pv ascii_off)" check_eq $P/ascii-off-roundtrip "yes" "$(pv ascii_off_roundtrip)" # encode_html_chars is ujson's own option with no json counterpart. The default # case also shows the `/' escape arriving inside a longer string. check_eq $P/html-default '"&<\/a>"' "$(pv html_default)" check_eq $P/html-on '"\u003ca\u003e\u0026\u003c\/a\u003e"' "$(pv html_on)" # INDENT, both ends, because CVE-2026-32875 moved both. indent=0 is compact; # indent=2 is the pretty form (note the space after `:', which the compact form # does not have); a NEGATIVE indent pads colons and emits no indentation or # newlines, which the CVE patch preserved deliberately rather than breaking; and # an indent large enough to overflow the size arithmetic is now refused with # OverflowError rather than overrunning the buffer. check_eq $P/indent-0 '{"a":[1,2]}' "$(pv indent_0)" check_eq $P/indent-2 '{\n "a": [\n 1,\n 2\n ]\n}' "$(pv indent_2)" check_eq $P/indent-neg '{"a": [1,2]}' "$(pv indent_neg)" check_eq $P/indent-big "OverflowError" "$(pv indent_big_raised)" # sort_keys. Off is insertion order, on is sorted -- and the OFF case is the one # worth having: a codec that sorted unconditionally would still pass every # round-trip case above. check_eq $P/sort-off '{"b":1,"a":2,"c":3}' "$(pv sort_off)" check_eq $P/sort-on '{"a":2,"b":1,"c":3}' "$(pv sort_on)" # NaN AND INFINITY. allow_nan defaults on and ujson's spellings are NaN / # Infinity / -Infinity; with allow_nan=False each is refused with OverflowError, # which is ujson's own choice and not json's ValueError. The decoder accepts the # same three literals. check_eq $P/enc-nan "NaN" "$(pv enc_nan)" check_eq $P/enc-inf "Infinity" "$(pv enc_inf)" check_eq $P/enc-ninf "-Infinity" "$(pv enc_ninf)" check_eq $P/enc-nan-nonan "OverflowError" "$(pv enc_nan_nonan)" check_eq $P/enc-inf-nonan "OverflowError" "$(pv enc_inf_nonan)" check_eq $P/enc-ninf-nonan "OverflowError" "$(pv enc_ninf_nonan)" check_eq $P/dec-nan "nan" "$(pv dec_nan)" check_eq $P/dec-inf "inf" "$(pv dec_inf)" # INTEGERS PAST THE 64-BIT SLOT. The ticket expected an overflow; there is none. # `enc-u64-max' is the largest value that fits the slot, and the three beyond it # are encoded exactly by falling through to CPython's own conversion. The reason # these are asserted as VALUES and not merely as "raised nothing" is that an # encoder which silently truncated to 64 bits would also raise nothing. check_eq $P/enc-u64-max "18446744073709551615" "$(pv enc_u64_max)" check_eq $P/enc-u64-max-plus "none" "$(pv enc_u64_max_plus)" check_eq $P/enc-u64-max-plus-val "18446744073709551616" "$(pv enc_u64_max_plus_val)" check_eq $P/enc-i64-min-minus "none" "$(pv enc_i64_min_minus)" check_eq $P/enc-i64-min-minus-val "-9223372036854775809" "$(pv enc_i64_min_minus_val)" check_eq $P/enc-1e30-val "1000000000000000000000000000000" "$(pv enc_1e30_val)" check_eq $P/dec-u64-max-plus "18446744073709551616" "$(pv dec_u64_max_plus)" # reject_bytes. Default ON: bytes raise TypeError rather than being encoded under # a guessed encoding. OFF: they are decoded as UTF-8 -- and CVE-2026-54911 is # entirely about THAT decoder, so the three rejection cases below are that patch's # regression guard: a bad continuation byte, a truncated sequence and a codepoint # past the Unicode maximum each raise rather than emitting mojibake. check_eq $P/bytes-default "TypeError" "$(pv bytes_default)" check_eq $P/bytes-off '"abc"' "$(pv bytes_off)" check_eq $P/bytes-off-utf8 '"h\u00e9"' "$(pv bytes_off_utf8)" check_eq $P/bytes-off-bad-cont "OverflowError" "$(pv bytes_off_bad_cont)" check_eq $P/bytes-off-truncated "OverflowError" "$(pv bytes_off_truncated)" check_eq $P/bytes-off-overlong "OverflowError" "$(pv bytes_off_overlong)" # The `default' handler: an unhandled object raises, and a supplied handler is # called and its result encoded. check_eq $P/default-unhandled "TypeError" "$(pv default_unhandled)" check_eq $P/default-handler "[1]" "$(pv default_handler)" # ------------------------------------------------------- decoder error paths --- # EVERY ONE OF THESE MUST RAISE ujson.JSONDecodeError, not return a partial value. # A parser that accepts trailing data or an unterminated string is not a strict # parser, and its failure mode is a silently truncated document rather than an # error -- which is why they are asserted one at a time rather than in aggregate. for _l in $ERR_LABELS; do check_eq $P/err-$(printf '%s' "$_l" | tr _ -) "JSONDecodeError" "$(pv err_$_l)" done # THE ONE THE TICKET GOT WRONG, and it is asserted as a pair so the divergence is # a statement rather than an omission: ujson ACCEPTS an unescaped control # character inside a string and returns it, where `json' on this same interpreter # refuses. Measured on the x86-64 control, so it is ujson 5.9.0's behaviour and # not this arm's. A ujson that started refusing would red here and want a look. check_eq $P/err-bare-control "returned 'a\x01b'" "$(pv err_bare_control)" check_eq $P/bare-control-json "JSONDecodeError" "$(pv bare_control_json)" # NESTING. ultrajson.h pins JSON_MAX_OBJECT_DEPTH at 1024, so 1024 must decode and # 1025 must raise: an off-by-one is a stack overflow on one side or a needlessly # rejected document on the other, and only asserting BOTH distinguishes them. check_eq $P/err-depth-1024 "accepted" "$(pv err_depth_1024)" check_eq $P/err-depth-1025 "JSONDecodeError" "$(pv err_depth_1025)" # The error TEXT of the canonical case, so a decoder that started raising the # right class with no message is visible. check_eq $P/err-trailing-text "Trailing data" "$(pv err_trailing_text)" # ------------------------------------------------------------- dump / load --- # The file-object half of the API takes a separate code path from dumps/loads, and # CVE-2026-44660 is entirely about dump()'s failure cleanup -- so the pair is # exercised, and so is the path where the file object raises. check_eq $P/dump-wrote '{"a":[1,2,3]}' "$(pv dump_wrote)" check_eq $P/load-read "{'a': [1, 2, 3]}" "$(pv load_read)" check_eq $P/dump-write-fails "OSError" "$(pv dump_write_fails)" # The documented aliases agree with the functions they alias. check_eq $P/encode-is-dumps "yes" "$(pv encode_is_dumps)" check_eq $P/decode-is-loads "yes" "$(pv decode_is_loads)"