Skip to content

fix(digest): hashes of text with a lone surrogate return a digest instead of raising UnicodeEncodeError - #761

Open
antonwolfy wants to merge 10 commits into
intel:mainfrom
antonwolfy:fix/surrogate-digests
Open

antonwolfy wants to merge 10 commits into
intel:mainfrom
antonwolfy:fix/surrogate-digests

Conversation

@antonwolfy

@antonwolfy antonwolfy commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Eight sites hash text that can hold a lone surrogate (a surrogate-escaped argv value or file name, or a \udXXX escape in loaded JSON/YAML) with .encode() or .encode("utf-8"), which raises UnicodeEncodeError: ... surrogates not allowed. On main:

Issue Call Result on main
#713 cache_path("https://example.com/n\udcff", cache_dir) (what RepoSource.parse("https://example.com/n\udcff@" + "a" * 40) gives) UnicodeEncodeError
#714 _safe_id("n\udcff?") UnicodeEncodeError
#715 reach.token("s", "n\udcff", "p") and skill_reach.token("s", "n\udcff", "p") UnicodeEncodeError
#716 URLClient("n\udcff")._cache_key(), and the topics cache key of EvaluationPipeline._generate_questions for doc_source="url:https://example.com/n\udcff" UnicodeEncodeError
#717 _paired_plugin_deltas with a matrix cell named n\udcff (or n\ud800) UnicodeEncodeError
#718 task_id(c) with c.symbols == ("n\udcff",) UnicodeEncodeError
#719 Bundle(..., files=[BundleFile(path="n\udcff", ...)]).measured_digest() UnicodeEncodeError
#720 item_digest, assignment_digest, graph_digest, expectations_digest, response_cache_key and _content_key with json.loads('"n\\udcff"') (or "n\\ud800") in the data UnicodeEncodeError

Now each returns a digest, and the digest of every input that encoded before is unchanged (the tests pin the literals). #713 to #716, #718 and #719 hash .encode("utf-8", "surrogateescape"), which keeps the value of every UTF-8 input, keeps two different surrogate-escaped values apart, and does not depend on the locale (os.fsencode would raise for a non-ASCII value when the file-system encoding is ASCII, e.g. LC_ALL=C PYTHONUTF8=0). #717 and #720 use .encode("utf-8", "surrogatepass") instead: their text comes from a JSON/YAML escape, where "\ud800" is possible and os.fsencode("\ud800") raises (so #717 does not take the surrogateescape encoding the other sites use); errors="replace" would give two inputs one digest, and refusing the escape in each loader would change what a loaded file may hold at many call sites. #718 keeps surrogateescape: the SCIP reader decodes every symbol as strict UTF-8 and refuses the index otherwise, so a symbol read from an index holds no surrogate at all.

For #720, harbor_export._content_key is reached by a terminal-bench task.yaml whose metadata holds such an escape (difficulty: "n\udcff"), so it is fixed and tested here; the export that follows still stops on writing task.toml, which is tracked in #759.

The same search (every .encode( in dokimi/dokimi and scripts) found sites with the same cause that no issue names, tracked in: #754 (run_reach salt from the tree label), #755 (build_fixture writes SKILL.md for a case name with a surrogate), #756 (Context7 and topic-extractor cache keys; this also stops a local: doc source with a non-UTF-8 directory name after #716), #757 (compute_question_set_hash), #758 (Atom id and retention cache key), #760 (process.run input).

Closes #713
Closes #714
Closes #715
Closes #716
Closes #717
Closes #718
Closes #719
Closes #720

Changes

Risk

None for an input that encoded before: the tests pin the digest, token, key or directory name of a UTF-8 input to the literal main gives (ten of them pass on main's code). A value with a lone surrogate used to stop the command with a traceback; it now gets a digest, so a cache directory or file name is made for it (for #713, n_-4d0dae2da255.git under the cache dir). On Windows a surrogate-escaped name is not a file-name form, and the tests that need one are skipped there.

Testing

On a Linux review host, at 4860cdb (on main 1bb8978):

  • make ci-local: 33 passed, 0 failed, 14 skipped. 20374 tests passed, 19 skipped; changed-line coverage 100% of 15 lines; mypy, ruff, the duplicate ratchet and the fragment check pass.
  • The new tests on main's code: 16 fail with UnicodeEncodeError (each test that feeds a surrogate) and the 10 that pin a UTF-8 digest pass, so the literals are what main gives. On this branch all 26 pass.
  • 55 mutants, one break at a time on a copy: for each site, errors="replace" (two surrogates collide), U+DCFE replaced by U+DCFF (two names share a digest), a high surrogate folded into an escaped byte, the pre-fix .encode() back (the raise), the UTF-8 digest changed, and for eval/matrix_rollup.py pair suffix raises UnicodeEncodeError for a matrix cell name with a lone surrogate #717 os.fsencode instead of surrogatepass (raises for n\ud800). Each fails the test of its site.
  • The tests of the file-system-encoding independence (8 of 34) on the code before the surrogateescape commit, where the sites used os.fsencode: all 8 fail with UnicodeEncodeError, the 26 others pass.
  • Not run: Python 3.11, 3.12 and 3.14 (this run used 3.13), the Windows legs, the gitleaks and semgrep scans, and the docker steps.

Revert

A plain revert restores the UnicodeEncodeError for these inputs. Nothing is stored: a cache directory or file made for a surrogate-escaped name while this was in is not read by the old code.

Checklist

  • Tests added/updated and pytest passes
  • ruff, mypy, and schema checks pass
  • Docs updated (or N/A) — see the docs-drift checklist in CONTRIBUTING.md
  • Self-reviewed per "Before opening a PR" in AGENTS.md
  • No ad-hoc run artifacts committed
  • New files carry the SPDX license header (or are exempt in .github/.licenserc.yaml)

…cache_path (intel#713)

cache_path hashed the URL with url.encode("utf-8"), which raises UnicodeEncodeError for a surrogate-escaped argv value such as https://example.com/n\udcff.

It now hashes os.fsencode(url): the clone directory of every UTF-8 URL is unchanged on POSIX, and two different surrogate-escaped URLs keep two directories.

Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
…cter no longer stops _safe_id (intel#714)

_safe_id appended hashlib.sha256(value.encode()) when the id held one of the characters Windows refuses, so a surrogate-escaped id such as n\udcff? from a --command template raised UnicodeEncodeError.

It now hashes os.fsencode(value): ids that encode as UTF-8 keep their directory name on POSIX, and two different surrogate-escaped ids keep two names.

Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
…on-UTF-8 byte no longer raise (intel#715)

token in repos/reach.py hashed f"{salt}\0{path}\0{position}".encode() with a file name from the tree, and token in repos/skill_reach.py did the same with the case id, so a surrogate-escaped name raised UnicodeEncodeError.

Both now hash os.fsencode of the same string: the token of every UTF-8 path or case id is unchanged on POSIX.

Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
…cache key or the topics cache (intel#716)

URLClient._cache_key hashed self.url.encode(), and the question step of EvaluationPipeline hashed self.doc_source.encode("utf-8") for the topics cache directory, so a surrogate-escaped --doc-source raised UnicodeEncodeError.

Both now hash os.fsencode of the value, as commands/questions.py already does for the same doc source: the key and the directory of a UTF-8 source are unchanged on POSIX.

Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
…he plugin-delta pairing (intel#717)

_paired_plugin_deltas hashed f"{baseline_name}\0{plugin_name}".encode("utf-8") for the delta artifact's name once a pair compared, and a JSON escape such as "n\udcff" or "n\ud800" in the matrix config loads as a lone surrogate, so it raised UnicodeEncodeError.

It now encodes with "surrogatepass": the suffix of every name that encodes as UTF-8 is unchanged, and different lone surrogates keep different suffixes. os.fsencode would still raise for "n\ud800", which is not a surrogate-escaped byte.

Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
…tel#718)

task_id hashed "\n".join(c.symbols).encode(), so a Candidate whose symbol holds a surrogate-escaped byte raised UnicodeEncodeError.

It now hashes os.fsencode of the joined symbols: the id of every UTF-8 symbol set is unchanged on POSIX. The SCIP reader decodes a symbol as strict UTF-8 and refuses the index otherwise (code_index/scip.py _text), so a symbol read from an index holds no surrogate of any kind and os.fsencode is enough here.

Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
…easured_digest (intel#719)

Bundle.measured_digest joined f"{item.path}:{item.sha256}" and encoded it with .encode("utf-8"); item.path is a file name from the skill directory, so a surrogate-escaped name raised UnicodeEncodeError.

It now hashes os.fsencode(rows): the digest of a UTF-8 path is unchanged on POSIX. A file name read from a directory can hold only a surrogate-escaped byte (U+DC80..U+DCFF), which os.fsencode encodes. _digest_text, the other hash in the module, takes text read as strict UTF-8 and is left as it is.

Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
… stops the digest of the data (intel#720)

item_digest and assignment_digest (eval/leakgate.py), graph_digest (eval_coverage.py), expectations_digest (repos/conformance.py), response_cache_key (caching.py) and _content_key (harnesses/harbor_export.py) dump parsed data with ensure_ascii=False and encode it as UTF-8, so a "\udcff" or "\ud800" escape in the input file loads as a lone surrogate and raised UnicodeEncodeError.

They now encode with "surrogatepass": every digest that encoded before is unchanged, and different surrogates keep different digests. errors="replace" would give two inputs one digest, os.fsencode raises for "\ud800", and refusing the escape in each loader would change what a loaded file may hold at many call sites.

The Harbor export's content key is reached by a terminal-bench task.yaml whose metadata holds such an escape (harbor_view calls it with _task_toml); the export then writes task.toml as UTF-8, which still raises, and is left as it is.

Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
@exai-bot

exai-bot commented Nov 14, 2026 •

Copy link
Copy Markdown

✅ OpenCodeReview: Review complete: 0 finding(s) across 2 selected item(s).

CI at c01b429: 28 passing, 3 skipped.

Review details
  • Commit reviewed: c01b429 against merge base 4860cdb
  • Coverage: 2 files in 1 groups
  • Engine: OpenCodeReview v1.13.5-exai.4.8f73eada, us.openai.gpt-6.1-sol
  • Usage: 96k tokens (63k from cache), 21s
  • Tool calls: 9 (8 file_read, 1 code_search)
  • small change set: 2 files

Comment thread dokimi/dokimi/mcp/url_client.py Resolved
@codecov-commenter

codecov-commenter commented Nov 14, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.72%. Comparing base (1bb8978) to head (c01b429).
⚠️ Report is 11 commits behind head on main.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #761      +/-   ##
==========================================
+ Coverage   95.71%   95.72%   +0.01%     
==========================================
  Files         413      415       +2     
  Lines       55236    55367     +131     
  Branches     9501     9516      +15     
==========================================
+ Hits        52867    53002     +135     
+ Misses       1466     1463       -3     
+ Partials      903      902       -1     
Files with missing lines Coverage Δ
dokimi/dokimi/caching.py 98.76% <100.00%> (ø)
dokimi/dokimi/eval/leakgate.py 99.03% <100.00%> (ø)
dokimi/dokimi/eval/matrix_rollup.py 90.00% <100.00%> (ø)
dokimi/dokimi/eval_coverage.py 99.17% <100.00%> (ø)
dokimi/dokimi/harnesses/harbor_export.py 91.22% <100.00%> (ø)
dokimi/dokimi/harnesses/runner.py 99.19% <100.00%> (ø)
dokimi/dokimi/mcp/url_client.py 94.44% <100.00%> (ø)
dokimi/dokimi/novelty/bundle.py 95.12% <100.00%> (ø)
dokimi/dokimi/orchestrator/pipeline.py 91.89% <100.00%> (ø)
dokimi/dokimi/repos/conformance.py 99.21% <100.00%> (ø)
... and 4 more

... and 17 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…system encoding

Settles the review point that os.fsencode makes the hashes depend on the locale: with an ASCII file-system encoding (LC_ALL=C PYTHONUTF8=0) it raised UnicodeEncodeError for an ordinary non-ASCII URL, path or symbol.

The cache path of a remote source, the harness id, the reach and skill-reach tokens, the URL cache key, Bundle.measured_digest, the graph task id and the topics cache key now encode with ("utf-8", "surrogateescape"). The digest of every UTF-8 input is unchanged, a non-UTF-8 byte still gets a digest, and the result no longer depends on the locale.

Tests run each site with an ASCII os.fsencode and one URLClient case in a child process with an ASCII file-system encoding.

Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
@antonwolfy

Copy link
Copy Markdown
Contributor Author

Review points answered at 4860cdb; 1 checks still pending (Package install smoke test (py3.11)); every other check passed.

skill_reach.token raised UnicodeEncodeError for a case id holding a
lone \ud800 escape, since surrogateescape maps only \udc80..\udcff.
Fall back to surrogatepass; digests that encoded before are unchanged.

Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com>
@vchamarthi

Copy link
Copy Markdown
Contributor

Pushed 1 commit to fix/surrogate-digests (4860cdb..c01b429).

  • skill_reach.token falls back to surrogatepass when surrogateescape cannot encode.
    Run: the full suite with main merged in, 20521 passed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment