Skip to content

fix(validator): FHIR string/markdown/code regex rejects non-breaking spaces (Unicode vs XSD \S) - #472

Open
mauripunzueta wants to merge 1 commit into
mainfrom
fix/425-string-regex-nbsp
Open

fix(validator): FHIR string/markdown/code regex rejects non-breaking spaces (Unicode vs XSD \S)#472
mauripunzueta wants to merge 1 commit into
mainfrom
fix/425-string-regex-nbsp

Conversation

@mauripunzueta

Copy link
Copy Markdown
Contributor

Fixes #425.

Problem

FHIR primitive pattern facets are XSD regexes, and XSD defines the shorthand whitespace class as ASCII only — \s == [#x20#x9#xD#xA]. Rust's regex crate makes \s/\S Unicode-aware by default, so U+00A0 (non-breaking space) — Unicode whitespace but not XSD whitespace — matches neither the literal [ \r\n\t] branch nor \S in the R4/R4B string/markdown pattern [ \r\n\t\S]+. The anchored match fails and a valid FHIR string is reported invalid.

R4 R4B R5 R6
string/markdown [ \r\n\t\S]+ (bug) [ \r\n\t\S]+ (bug) [\s\S]+ (ok) [\s\S]+ (ok)
code ([^\s]+…) affected affected affected affected

Root cause & fix

The single compile site is crates/fhir-validator/src/engine/primitives.rs::compiled(), which compiled ^(?:{pattern})$ on the Unicode-aware regex::Regex. The fix compiles with unicode(false) so the shorthand classes carry XSD/ASCII semantics.

The obvious spelling — (?-u:…) (which the issue suggested) or RegexBuilder…unicode(false) on the &str engine — does not compile: the &str engine refuses any program that could match invalid UTF-8, and (?-u:\S) / (?-u:[^\s]) can. The fix therefore compiles on the regex::bytes engine and matches the value's UTF-8 bytes. The validator's input is always a valid UTF-8 &str (Value::as_str()), so the only observable effect is that \s/\S/\d/\w become ASCII.

Why byte matching is safe/equivalent here: across every primitive pattern in all four versions, \s and \S are the only shorthand classes that appear (in string/markdown, code, uri/url/canonical \S*, and R4/R4B base64Binary). No primitive pattern uses ., \d, or \w, and every bounded quantifier (id {1,64}, base64 {4}, dates, uuid) operates on explicit ASCII classes where 1 byte = 1 char. So byte matching is exactly XSD-equivalent — verified empirically and by a 5-persona design review.

Scope

Fixing at the compile site inherently corrects both the issue's headline case (string/markdown, R4/R4B) and the code U+00A0 residual (all versions) — same one-line root cause, no extra diff. uri/base64/R5-R6 are already correct under the new engine and are covered by regression tests.

Tests

Added unit tests in primitives.rs (exercised through compiled(), so they cover real anchoring + engine + flags):

  • string/markdown R4/R4B accepts U+00A0 (the exact issue case) and lone U+00A0; still accepts spaces/tabs; still rejects empty.
  • R5/R6 [\s\S]+ proven unchanged.
  • code treats U+00A0 as content (single token) but still rejects leading/trailing real spaces.
  • uri \S* rejects a real space, accepts U+00A0, accepts empty.
  • Negative guards: date and id still reject a stray U+00A0 (the fix does not blanket-accept non-ASCII); base64Binary {4} grouping unaffected.

Full validator suite is green on R4,R4B,R5,fhirpath; cargo fmt --check clean.

Notes

…ntics

FHIR primitive `pattern` facets are XSD regexes, and XSD defines `\s` as
ASCII-only (`[#x20#x9#xD#xA]`). Rust's `regex` crate makes `\s`/`\S`
Unicode-aware, so a value containing U+00A0 (non-breaking space) — Unicode
whitespace but not XSD whitespace — was wrongly rejected: it matches neither
the literal `[ \r\n\t]` branch nor `\S` in the R4/R4B `string`/`markdown`
pattern `[ \r\n\t\S]+`, so the anchored match fails on a valid FHIR string.
The same Unicode `\s` also affected `code` (`[^\s]+...`) on every version.

Fix at the compile site in `engine/primitives.rs::compiled()`: compile with
`unicode(false)` so the shorthand classes carry XSD/ASCII semantics. Because
`unicode(false)` on the `&str` engine refuses any program that could match
invalid UTF-8 (which `\S`/`[^\s]` can), compile on the `regex::bytes` engine
and match the value's UTF-8 bytes. The value is always valid UTF-8, so the
only observable effect is ASCII shorthand classes. Across every primitive
pattern in all four versions, `\s`/`\S` are the only shorthands present (no
`.`, `\d`, `\w`), so byte matching is exactly XSD-equivalent here.

Packs stay spec-literal (no regeneration) and no storage/DB is touched.

Fixes #425
@mauripunzueta mauripunzueta self-assigned this Jul 31, 2026
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@mauripunzueta
mauripunzueta marked this pull request as ready for review July 31, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Validator: FHIR string/markdown regex rejects non-breaking spaces on R4 and R4B (Unicode-aware \S)

1 participant