diff --git a/adopt-project.py b/adopt-project.py index 97bd9f09..268e526c 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -1208,12 +1208,19 @@ def _spelled(path: str) -> str: file can hold -- written raw, it raised mid-write and left `--out` empty (#166's NEW-2) -- and a name may hold a newline, which would end the line. So a character that is not printable is spelled with a backslash, - `\\xe9` for the byte 0xE9 and `\\n` for a newline, and so are a - backslash and a backtick, so that the spelling reads one way only and - the backticks still delimit it. The plan's own `path:` spells that byte - `\\udce9`, not `\\xe9` (`repo_shape.double_quoted`), because in YAML - `\\xe9` is the character U+00E9, which is another name: a question or a - report that says `caf\\xe9` is the entry `"caf\\udce9"` in the plan. + and so are a backslash and a backtick, so that the spelling reads one way + only and the backticks still delimit it. + + THE SPELLING IS THE PLAN'S OWN (`_spelled_character`): the byte 0xE9 is + `\\udce9` and the character U+0085 is `\\x85`, as `path:` writes them + (`repo_shape.double_quoted`), so that a question or a report that says + `caf\\udce9` is the entry `"caf\\udce9"` in the plan. Each escape names + a code point, so a byte and a character never share one. They did: a + byte was `\\xe9` here, which in YAML is the character U+00E9, and the 32 + C1 controls, U+00A0 and U+00AD were spelled as the bytes of the same + value, 34 pairs of different names that read alike (#329). A spelling is + only printed, in a finding, a refusal or a question the plan carries; + none is read back or handed to git. """ return "`" + "".join(map(_spelled_character, path)) + "`" @@ -1233,13 +1240,17 @@ def _shown(path: str) -> str: def _spelled_character(char: str) -> str: + """One character of `_spelled`: itself when it is printable, a backslash + before a backslash or a backtick, and otherwise the escape + `repo_shape.double_quoted` writes for it in the plan, so the plan's one + table and not a second one: `\\n` for a line feed, `\\x85` for U+0085, + `\\u2028` for U+2028, and `\\udc85` for the byte 0x85, which + `Source.tree` keeps as the surrogate U+DC00 plus the byte.""" if char in "\\`": return "\\" + char if char.isprintable(): return char - if "\udc80" <= char <= "\udcff": - return f"\\x{ord(char) - 0xDC00:02x}" - return ascii(char)[1:-1] + return double_quoted(char)[1:-1] def cross_leg_references(source: "Source", entries: list["Entry"], @@ -3349,7 +3360,7 @@ def _refused_by_policy(path: str) -> str | None: * a byte that is not UTF-8, which `Source.tree` keeps as one of U+DC80 to U+DCFF (`surrogateescape`). Since #199 (PR #247) `write_paths_listing` writes it as that byte, and with this refusal - disabled a source holding `caf\\xe9.md` was extracted and verified, + disabled a source holding `caf\\udce9.md` was extracted and verified, the byte in the leg, measured. It is held until adopting such a name end to end is measured on macOS and Windows: a name that holds one prints as an escape, never as itself, and the split commit's message @@ -4279,8 +4290,8 @@ def _index_entries(assembly: Path, as #169 refuses case-only twins of the two leg paths. `-z`, for a path git would otherwise quote. A name that is not UTF-8 keeps each byte as a surrogate, as `Source.tree` keeps it, so the refusal spells the byte 0xFF - `\\xff`, as every other finding does (#212). Decoded with `replace`, - `a<0xFF>` and `a<0xFE>` both read `a`. + `\\udcff`, as every other finding does (#212, #329). Decoded with + `replace`, `a<0xFF>` and `a<0xFE>` both read `a`. """ raw = git_out(["--icase-pathspecs", "ls-files", "-s", "-z", "--", *pathspecs], cwd=assembly, binary=True) diff --git a/tests/test_adopt_entry_paths.py b/tests/test_adopt_entry_paths.py index c5afddb2..45298bef 100644 --- a/tests/test_adopt_entry_paths.py +++ b/tests/test_adopt_entry_paths.py @@ -177,9 +177,9 @@ def planned(tmp_path: Path, tree: dict, ("line\u2028sep.md", "line separator `\\u2028`"), # A byte that is not UTF-8, as `Source.tree` keeps it: since #199 the # path list carries it byte-exact. - ("caf\udce9.md", "`\\xe9`, which is not UTF-8"), - ("docs/\udcff/", "`\\xff`, which is not UTF-8"), - ("\udc80.md", "`\\x80`, which is not UTF-8"), + ("caf\udce9.md", "`\\udce9`, which is not UTF-8"), + ("docs/\udcff/", "`\\udcff`, which is not UTF-8"), + ("\udc80.md", "`\\udc80`, which is not UTF-8"), ] @@ -653,8 +653,8 @@ def with_a_name_that_is_not_utf8(adopter, tmp_path: Path): #: The finding `check` must print first for `NOT_UTF8`, spelled as #: `_spelled` spells a byte that is not UTF-8. NOT_UTF8_FINDING = ( - f"FINDING {UNSAFE}: `docs/caf\\xe9.md` has leg: spec, and `execute` " - "will not extract it into a leg: it holds `\\xe9`, which is not UTF-8 " + f"FINDING {UNSAFE}: `docs/caf\\udce9.md` has leg: spec, and `execute` " + "will not extract it into a leg: it holds `\\udce9`, which is not UTF-8 " "and which `execute` refuses as policy, though git and `git filter-repo` " "would take it literally: a name that holds one prints as an escape and " "never as itself, the split commit's message would list it with a `?` " diff --git a/tests/test_adopt_finding_spelling.py b/tests/test_adopt_finding_spelling.py index 76343e06..d4a12cda 100644 --- a/tests/test_adopt_finding_spelling.py +++ b/tests/test_adopt_finding_spelling.py @@ -313,10 +313,11 @@ def test_adopt_mount_occupied_spells_the_kept_path_under_the_mount( def test_adopt_mount_occupied_spells_a_byte_that_is_not_utf8(adopter, tmp_path): """`_index_entries` reads the index as `Source.tree` reads a tree: a byte - that is not UTF-8 is kept, so the refusal spells 0xFE `\\xfe`, as - `plan-uncovered` would. Decoded with `replace`, `a<0xFE>` and `a<0xFF>` - both read `a`. The entries go in with `git update-index - --index-info`, fed bytes, so no filesystem has to hold them.""" + that is not UTF-8 is kept, so the refusal spells 0xFE `\\udcfe`, as + `plan-uncovered` would, and as the plan's own `path:` does (#329). + Decoded with `replace`, `a<0xFE>` and `a<0xFF>` both read `a`. + The entries go in with `git update-index --index-info`, fed bytes, so no + filesystem has to hold them.""" repo = tmp_path / "assembly" repo.mkdir() git_out(repo, "init", "-q") @@ -326,13 +327,13 @@ def test_adopt_mount_occupied_spells_a_byte_that_is_not_utf8(adopter, for name in (b"spec/a\xfe", b"spec/a\xff"))) assert [adopter._entry_named(entry) for entry in adopter._index_entries(repo, ["spec"])] == [ - "`spec/a\\xfe` (a file)", "`spec/a\\xff` (a file)"] + "`spec/a\\udcfe` (a file)", "`spec/a\\udcff` (a file)"] with pytest.raises(adopter.Refusal) as caught: adopter._refuse_an_unmountable_path(repo, "spec") assert caught.value.code == "adopt-mount-occupied" assert caught.value.detail == ( "the assembly's index already holds paths under spec, the leg mount " - "path: `spec/a\\xfe` (a file) and 1 more") + "path: `spec/a\\udcfe` (a file) and 1 more") #: The root tree's two leg mounts, as `_verify` passes them to diff --git a/tests/test_adopt_gitmodules_values.py b/tests/test_adopt_gitmodules_values.py index 5a6eca0d..41aa3734 100644 --- a/tests/test_adopt_gitmodules_values.py +++ b/tests/test_adopt_gitmodules_values.py @@ -259,7 +259,7 @@ def test_the_finding_names_the_key_git_dies_on(adopter, tmp_path, gitmodules, (with_line(r'update = "bo\ngus"'), f"invalid value for `submodule.b.update`: `bo\\ngus`, {NOT_A_STRATEGY}"), (with_line("shallow = caf").encode()[:-1] + b"\xe9\n", - "bad boolean config value `caf\\xe9` for `submodule.b.shallow`"), + "bad boolean config value `caf\\udce9` for `submodule.b.shallow`"), (b'[submodule "b\x1bc"]\n\tpath = vendor/b\n\turl = ../b\n' b"\tfetchRecurseSubmodules = `x`\n", "bad `submodule.b\\x1bc.fetchrecursesubmodules` argument: `\\`x\\``, " diff --git a/tests/test_adopt_literal_names.py b/tests/test_adopt_literal_names.py index 282a6566..0293788e 100644 --- a/tests/test_adopt_literal_names.py +++ b/tests/test_adopt_literal_names.py @@ -264,8 +264,8 @@ def test_a_colour_setting_in_the_source_is_not_in_a_follow_up(tmp_path): def test_a_name_with_a_line_break_or_a_byte_outside_utf8_is_one_spelled_line( tmp_path): """git's `-z` gives a name as its bytes, and the follow-up spells it as a - question spells a path: `\\n` for the line break, `\\xe9` for the byte, - on one line of UTF-8. On main each came back in git's C-quoted + question spells a path: `\\n` for the line break, `\\udce9` for the byte + (#329), on one line of UTF-8. On main each came back in git's C-quoted spelling, quotes and octal included. The two files are put in the TREE (`update-index -z --index-info`) and never on the disk, which macOS refuses for a name that is not UTF-8.""" @@ -285,7 +285,7 @@ def test_a_name_with_a_line_break_or_a_byte_outside_utf8_is_one_spelled_line( plan = planned(source, tmp_path) - assert named(plan) == [follow_up("src/caf\\xe9.py", "notes/"), + assert named(plan) == [follow_up("src/caf\\udce9.py", "notes/"), follow_up("src/line\\nbreak.py", "notes/")] diff --git a/tests/test_adopt_plan_escapes.py b/tests/test_adopt_plan_escapes.py index 0ae2a965..cc1ef280 100644 --- a/tests/test_adopt_plan_escapes.py +++ b/tests/test_adopt_plan_escapes.py @@ -409,7 +409,7 @@ def test_a_source_directory_that_is_not_utf8_is_shown_spelled(adopter, except AssertionError as exc: pytest.skip(f"git will not work under b'caf\\xe9' here: {exc}") shown = adopter._spelled(str(source.resolve())) - assert "\\xe9" in shown + assert "\\udce9" in shown plan = tmp_path / PLAN_FILE strict = {"PYTHONIOENCODING": "utf-8:strict"} diff --git a/tests/test_adopt_spelled_unambiguous.py b/tests/test_adopt_spelled_unambiguous.py new file mode 100644 index 00000000..e3e4ca0b --- /dev/null +++ b/tests/test_adopt_spelled_unambiguous.py @@ -0,0 +1,260 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A name a finding or a question prints reads one way only (#329). + +On main at 71b58a1 `_spelled` wrote a raw byte that is not UTF-8 -- which +`Source.tree` keeps as the surrogate U+DC00 plus the byte -- as `\\xNN`, and +a character that is not printable and lies below U+0100 the same way. So the +byte 0x85 and the character U+0085 both read `\\x85`, and so did 32 more +pairs: the 32 C1 controls U+0080 to U+009F, U+00A0 and U+00AD. A question +that said `caf\\x85` could not say which entry of the plan it meant, and in +YAML, where the plan is written, `\\x85` is the character: the byte's own +entry is `"caf\\udc85"`. + +Now a name is spelled as the plan's writer spells it, by the one table of +escapes (`repo_shape.double_quoted`) that its reader resolves: the character +U+0085 is `\\x85` and the byte 0x85 is `\\udc85`. Asked here of the function, +of the finding and the question that print a name, of every code point, of +the plan's own writer and reader, and of `plan` and `check` run on a source +that holds the byte and the character side by side. +""" + +from __future__ import annotations + +import importlib.util +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +from conftest import ADOPT, REPO, make_source_repo, run_script, write_plan + +sys.path.insert(0, str(REPO / "scripts")) +from repo_shape import double_quoted, parse_yaml # noqa: E402 + +PROJECT = "Northwind" +UTF8 = "utf-8" +#: The bytes 0x80 to 0xFF that are not printable as the characters of the +#: same value: the 32 C1 controls, U+00A0 (a no-break space) and U+00AD (a +#: soft hyphen). The issue's 34 pairs; a byte of 0xA1 to 0xFF is printable as +#: its character, which is not what `_spelled` calls it. +AMBIGUOUS = [*range(0x80, 0xA1), 0xAD] +EACH = pytest.mark.parametrize("byte", AMBIGUOUS, + ids=[f"0x{byte:02X}" for byte in AMBIGUOUS]) + + +def as_byte(value: int) -> str: + """The raw byte `value`, as `Source.tree` keeps one that is not UTF-8.""" + return chr(0xDC00 + value) + + +@pytest.fixture(scope="module") +def adopter(): + spec = importlib.util.spec_from_file_location("adopt_spelled", ADOPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# --- the function ---------------------------------------------------------- + +def test_the_ambiguous_names_are_the_issues_34(): + """32 C1 controls, U+00A0 and U+00AD: the bytes of 0x80 to 0xFF whose + character is not printable. Pinned apart from the rest, so that a change + to what counts as printable here moves this test and not a count.""" + assert len(AMBIGUOUS) == 34 + assert AMBIGUOUS == [byte for byte in range(0x80, 0x100) + if not chr(byte).isprintable()] + + +@EACH +def test_a_byte_and_the_character_of_its_value_are_spelled_apart(adopter, + byte): + """Each of the 34 pairs: the byte as the plan's `path:` writes it, the + character as the plan writes it.""" + assert adopter._spelled(as_byte(byte)) == f"`\\udc{byte:02x}`" + assert adopter._spelled(chr(byte)) == f"`\\x{byte:02x}`" + + +def test_a_byte_that_is_not_utf8_is_always_spelled_as_that_byte(adopter): + """Every byte of 0x80 to 0xFF, the printable characters' too: a byte is + `\\udcNN` and never the character of its value, which a name may hold + beside it.""" + for byte in range(0x80, 0x100): + assert adopter._spelled(as_byte(byte)) == f"`\\udc{byte:02x}`" + assert adopter._spelled(chr(0xE9)) == f"`{chr(0xE9)}`" + assert adopter._spelled(as_byte(0xE9)) == "`\\udce9`" + + +def test_no_two_characters_are_spelled_alike(adopter): + """What the 34 pairs broke, over every code point of the BMP, lone + surrogates included, and a few beyond it: no spelling is another + character's.""" + seen: dict[str, int] = {} + beyond = [0x10000, 0x1F600, 0xE0001, 0xFFFFF, 0x10FFFF] + for code in [*range(0x10000), *beyond]: + spelled = adopter._spelled(chr(code)) + assert spelled not in seen, ( + f"U+{seen[spelled]:04X} and U+{code:04X} both read {spelled}") + seen[spelled] = code + + +def test_a_character_is_spelled_as_the_plan_writes_it(adopter): + """The finding and the plan spell one name the same way, character by + character: the plan's writer is the table, and `_spelled` asks it. A + backslash and a backtick are the finding's own (the backticks delimit + it) and a double quote is the plan's own, so those three are left out.""" + for code in [*range(0x10000), 0x10000, 0x1F600, 0xE0001, 0x10FFFF]: + char = chr(code) + if char in '"\\`': + continue + assert adopter._spelled_character(char) == double_quoted(char)[1:-1], ( + f"U+{code:04X}") + + +def test_a_backslash_and_a_backtick_are_escaped_as_before(adopter): + assert adopter._spelled("a\\b`c") == "`a\\\\b\\`c`" + + +def test_a_spelled_name_is_the_entry_the_plan_holds(adopter): + """The failure, read as the reader does: a person who copies a spelled + name into the plan between double quotes gets THAT name back, byte for + byte, for each of the 256 characters and each of the 128 bytes. On main + `\\x85` for the byte read back as the character.""" + names = [chr(code) for code in range(0x100)] + names += [as_byte(byte) for byte in range(0x80, 0x100)] + for name in names: + if name in '"\\`': + continue + body = adopter._spelled("caf" + name + "x")[1:-1] + assert parse_yaml(f'path: "{body}"\n') == { + "path": "caf" + name + "x"}, (body, name) + + +# --- a finding and a question ---------------------------------------------- + +@EACH +def test_plan_uncovered_names_a_byte_and_its_character_differently(adopter, + byte): + findings = adopter._coverage_findings( + ["README.md"], + ["README.md", f"Icon{as_byte(byte)}", f"Icon{chr(byte)}"]) + assert findings == [ + f"FINDING plan-uncovered: `Icon\\udc{byte:02x}` is in the source " + "tree and in no plan entry", + f"FINDING plan-uncovered: `Icon\\x{byte:02x}` is in the source tree " + "and in no plan entry"] + + +@EACH +def test_a_question_names_a_byte_and_its_character_differently(adopter, byte): + """`_listed` is how a question names the paths it asks about, and the + plan carries the question.""" + listed = adopter._listed([f"a{as_byte(byte)}", f"a{chr(byte)}"]) + assert listed == f"`a\\udc{byte:02x}`, `a\\x{byte:02x}`" + + +# --- the messages that name a value, not a path (#255) -------------------- + +#: The C1 controls: the characters of 0x80 to 0x9F, which a file refuses +#: (`unwritable_reason`), so that a byte and its character can both be named. +C1 = list(range(0x80, 0xA0)) + + +@pytest.mark.parametrize("byte", C1, ids=[f"0x{byte:02X}" for byte in C1]) +def test_an_unwritable_value_names_a_byte_and_its_character_apart(adopter, + byte): + """`unwritable_values` names the value as Python's `repr` does, which + spells by code point: a byte as `\\udcNN`, a character as `\\xNN`, so + the two sentences differ, as the plan's own `elected_by:` entries do.""" + byte_said, character_said = adopter.unwritable_values( + [("elected_by", f"a{as_byte(byte)}", "the manifest"), + ("elected_by", f"a{chr(byte)}", "the manifest")]) + assert byte_said.startswith(f"elected_by is 'a\\udc{byte:02x}', ") + assert character_said.startswith(f"elected_by is 'a\\x{byte:02x}', ") + + +def test_a_clone_url_that_is_not_text_is_named_as_the_plan_spells_it(adopter): + """`_refuse_an_unwritable_clone_url` names the source's own path, whose + byte 0xE9 is `\\udce9` and not the character U+00E9 a `\\xe9` is.""" + with pytest.raises(adopter.Refusal) as refused: + adopter._refuse_an_unwritable_clone_url(f"/src/caf{as_byte(0xE9)}") + assert "'/src/caf\\udce9'" in refused.value.detail + assert "\\xe9" not in refused.value.detail + + +def test_python_repr_and_a_spelled_name_agree_on_every_character(adopter): + """Why the two messages above need no table of their own: `repr` and + `_spelled` escape every character alike, a backslash included. Only the + backtick differs, which `_spelled` escapes because backticks delimit the + name and `repr` does not.""" + for code in [*range(0x10000), 0x10000, 0x1F600, 0xE0001, 0x10FFFF]: + char = chr(code) + if char == "`": + continue + assert repr(char)[1:-1] == adopter._spelled(char)[1:-1], ( + f"U+{code:04X}") + + +# --- plan and check, on a source that holds both --------------------------- + +def source_with_both(tmp_path: Path, byte: int) -> Path: + """A source whose head holds `Icon` and `Icon`. Both go + into git's index as `git add` would record them, so no file system has to + hold a name it cannot (APFS refuses the first). Skipped where git will + not record them or keep them in a tree, which only git can say.""" + source = make_source_repo(tmp_path / "Thing", {"README.md": "# Thing\n"}, + edits=()) + names = [b"Icon" + bytes([byte]), b"Icon" + chr(byte).encode(UTF8)] + blob = subprocess.run(["git", "hash-object", "-w", "--stdin"], + cwd=str(source), input=b"latin1\n", + capture_output=True, check=True).stdout.strip() + added = subprocess.run( + ["git", "update-index", "--add", "-z", "--index-info"], + cwd=str(source), capture_output=True, check=False, + input=b"".join(b"100644 " + blob + b"\t" + name + b"\0" + for name in names)) + if added.returncode: + pytest.skip(f"git update-index refuses {names!r} here: " + f"{added.stderr!r}") + committed = subprocess.run( + ["git", "-c", "user.name=Source Human", + "-c", "user.email=human@source.invalid", + "commit", "-q", "-m", "A byte and a character of one value"], + cwd=str(source), capture_output=True, check=False) + assert committed.returncode == 0, committed.stderr + held = subprocess.run(["git", "ls-tree", "-r", "-z", "--name-only", + "HEAD"], cwd=str(source), capture_output=True, + check=True).stdout.split(b"\0") + if not set(names) <= set(held): + pytest.skip(f"git does not keep {names!r} in a tree here") + return source + + +@pytest.mark.parametrize("byte", [0x85, 0xA0, 0xAD], + ids=["next-line", "no-break-space", "soft-hyphen"]) +def test_plan_and_check_name_a_byte_and_its_character_as_the_plan_does( + tmp_path, byte): + """THE ISSUE END TO END. `plan` writes both entries into the plan, one + spelled `\\xNN` and one `\\udcNN`; its own table, and `check`'s findings, + name them the same two ways. On main both read `\\xNN` in the table and in + every finding, though the plan file held two spellings.""" + source = source_with_both(tmp_path, byte) + plan = tmp_path / "adoption-plan.yaml" + written = write_plan(source, plan, project=PROJECT) + assert written.returncode == 0, written.stderr + written.stdout + character, raw = f"Icon\\x{byte:02x}", f"Icon\\udc{byte:02x}" + in_the_plan = re.findall(r'^ - path: "(Icon[^"]*)"$', + plan.read_text(encoding=UTF8), re.MULTILINE) + assert sorted(in_the_plan) == sorted([character, raw]) + in_the_table = re.findall(r"^`(Icon[^`]*)` ", written.stdout, + re.MULTILINE) + assert sorted(in_the_table) == sorted([character, raw]), written.stdout + + result = run_script(ADOPT, "check", "--plan", str(plan)) + assert result.returncode == 1, result.stderr + result.stdout + unresolved = re.findall(r"^FINDING plan-unresolved: `(Icon[^`]*)` ", + result.stderr, re.MULTILINE) + assert sorted(unresolved) == sorted([character, raw]), result.stderr + assert unresolved == in_the_plan diff --git a/tests/test_adopt_submodules.py b/tests/test_adopt_submodules.py index a9d379ea..15c665cf 100644 --- a/tests/test_adopt_submodules.py +++ b/tests/test_adopt_submodules.py @@ -873,9 +873,9 @@ def raw_source(base: Path, gitlink: bytes, beside: bytes | None) -> Path: # Not UTF-8: `Source.tree` keeps the byte as a surrogate, which no UTF-8 # plan can hold. (b"upstream/d\xe9p", None, UPSTREAM, - " This entry holds the submodule(s) `upstream/d\\xe9p`: it is asked"), + " This entry holds the submodule(s) `upstream/d\\udce9p`: it is asked"), (b"src/vendored", b"src/caf\xe9.py", "src/", - "an entry for each of `src/app/`, `src/caf\\xe9.py`, `src/vendored`,"), + "an entry for each of `src/app/`, `src/caf\\udce9.py`, `src/vendored`,"), # A newline would end the plan's line, and read back as a space. (b"src/vendored", b"src/a\nb.py", "src/", "an entry for each of `src/a\\nb.py`, `src/app/`, `src/vendored`,"), diff --git a/tests/test_adopt_template_values.py b/tests/test_adopt_template_values.py index 251bfff3..5fd5eda9 100644 --- a/tests/test_adopt_template_values.py +++ b/tests/test_adopt_template_values.py @@ -456,13 +456,14 @@ def test_plan_and_check_say_the_same_sentence(tmp_path, answered): #: execute's refusal)`: a value `check` printed raw under `WHAT WILL #: HAPPEN`, which raised on a strict stdout before any finding about it was #: printed. It is shown as a name is (`_shown`), a byte that is not UTF-8 as -#: `\xe9`. A leg path is `plan-bad-leg-path`; the adopt branch passes `check` -#: and is `unsafe-value` to `execute`, as each was on main before #247. +#: `\udce9`, as the plan's own `path:` spells it (#329). A leg path is +#: `plan-bad-leg-path`; the adopt branch passes `check` and is `unsafe-value` +#: to `execute`, as each was on main before #247. PRINTED = [ ("spec_path-surrogate", " spec_path", "sp\ud800ec", rb"`sp\ud800ec`/", 1, b"FINDING plan-bad-leg-path: legs.spec_path is ", b"plan-bad-leg-path"), - ("code_path-byte", " code_path", "co\udce9de", rb"`co\xe9de`/", 1, + ("code_path-byte", " code_path", "co\udce9de", rb"`co\udce9de`/", 1, b"FINDING plan-bad-leg-path: legs.code_path is ", b"plan-bad-leg-path"), ("adopt_branch-surrogate", "adopt_branch", "adopt/x\ud800", rb"`adopt/x\ud800` of", 0, PLAN_OK, b"unsafe-value"),