From 3873d89665b19fe42ed5fa436935f306b9e173b4 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Mon, 29 Jun 2026 09:56:33 -0700 Subject: [PATCH 1/3] Updates from Shadow-Mode Rollout --- doc/plans/Innovation-2.1-canonical-request.md | 87 +++++++++++++++++++ proxy_agent/src/proxy/canonical/query.rs | 46 +++++++++- proxy_agent/src/proxy/canonical/rule.rs | 30 +++++++ 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/doc/plans/Innovation-2.1-canonical-request.md b/doc/plans/Innovation-2.1-canonical-request.md index 8e83bf52..05eba3a1 100644 --- a/doc/plans/Innovation-2.1-canonical-request.md +++ b/doc/plans/Innovation-2.1-canonical-request.md @@ -339,6 +339,93 @@ The canonicalizer ships before the matcher cuts over. - p99 added latency \< 100 µs (measured during shadow mode). - One full release in **shadow** behind a feature flag before any region flips to **enforce**. +### 9.4 Known divergences (triage log) + +The "zero divergences" cutover bar in §9.3 means **zero *unexplained* divergences**. As shadow mode runs against real fleet traffic it surfaces genuine differences between the legacy matcher and the canonical pipeline. Each one must be triaged to one of three dispositions before a region can flip to **enforce**: + +- **Canon-stricter-and-correct** — the canonical verdict is the intended secure behavior and legacy was the bug. These are tracked here, the root cause is fixed *at the source* (usually a misbehaving client), and the entry is closed once the divergence stops appearing. +- **Canon-bug** — the canonical pipeline is wrong; fix the pipeline. +- **Benign-formatting** — semantically identical decision, cosmetic delta only (e.g. trailing slash). Fold into a golden vector so it can't regress. + +Until a logged divergence is dispositioned, **do not enable enforce mode for the affected fleet**, because enforce mode acts on the canonical verdict and would change production behavior for that traffic. + +#### KD-1 · `%3F`-encoded query delimiter from `VmssRuntimeAgent` (`CANON_EMBQ`) + +| Field | Value | +|------------------|--------------------------------------------------------------------------------------------------------| +| First observed | 2026-06-26 (shadow) | +| Disposition | **Canon-stricter-and-correct** — client-side defect; canon deny is the intended behavior | +| Legacy decision | `allow` | +| Canon decision | `deny` → `CANON_EMBQ` (`CanonError::EmbeddedQuery`, "embedded `?` after decoding") | +| Emitter | `VmssRuntimeAgent.exe` (external first-party agent; **not** in this repo), running as `SYSTEM` | +| Destination | IMDS `169.254.169.254:80` | + +**Divergence log line:** + + CANON_DIVERGENCE mode=shadow rule_set= legacy=allow canon=error:CANON_EMBQ \ + uri="/metadata/scheduledevents%3Fapi-version=2019-08-01" + +**What happened.** The agent sends the IMDS *query delimiter* percent-encoded (`%3F`) instead of a literal `?`: + + on the wire : /metadata/scheduledevents%3Fapi-version=2019-08-01 ← literal '?' in the PATH, empty query + intended : /metadata/scheduledevents?api-version=2019-08-01 ← path + api-version query + +Per RFC 3986, `%3F` inside the path is a literal `?` character that is *part of the path*, not a delimiter — so the agent is technically requesting a path named `scheduledevents?api-version=2019-08-01` with no query string. + +**Why it currently succeeds.** Two layers absorb the malformed form, so production sees `200 OK`: + +1. **IMDS decodes-first** — it percent-decodes `%3F` → `?`, re-splits, and serves the real `scheduledevents` endpoint. Confirmed by a `200 OK` / 9 ms connection-log entry for `VmssRuntimeAgent.exe`. +2. **Legacy matcher** — matches the raw, still-encoded string with a case-insensitive `starts_with`, so the permissive prefix rule allows it. + +**Why canon denies (and why that's right).** The canonical pipeline decodes once and finds a literal `?` embedded in the decoded **path** — the unambiguous signature of the `%3F` query-smuggling vector called out in §3.1 and pinned by the `A1.embedded_query` / `D1.embedded_query_*_hex` golden vectors. It fails closed (`CANON_EMBQ`). This is the rule/request asymmetry the canonical model exists to eliminate: legacy authorized an *opaque path string* while IMDS acted on a *decoded query*. + +**Why we do NOT "fix" this in canon by decode-and-splitting.** GPA forwards the **original bytes** to the upstream (§1.3; `proxy_server.rs` only adds headers, never rewrites the URI). So any decode-and-split in canon would merely be GPA *predicting* IMDS's parser — re-introducing the differential the moment the two parsers disagree on an edge case (`%23` fragment, repeated/encoded delimiters, `+`→space), and broadening `%3F`-as-delimiter acceptance to *every* endpoint. The clean, auditable fail-closed deny is preferable to a fragile parser-equivalence bet. + +**Resolution / action items.** + +- [ ] **Root cause (owner: VmssRuntimeAgent team):** fix the IMDS URL construction to emit a literal `?`; audit all IMDS call sites for the same pattern (typically caused by escaping the whole path+query string instead of building the query with a URI builder). +- [ ] **GPA rollout gate:** keep the affected fleet in **shadow** (do not enable **enforce**) until the agent fix is deployed, to avoid blocking scheduled-events polling. +- [ ] **Close-out:** entry is resolved when the divergence stops appearing in shadow telemetry post-deployment. + +#### KD-2 · Case-sensitive query-value match on `mi_res_id` ARM ids + +| Field | Value | +|------------------|--------------------------------------------------------------------------------------------------------| +| First observed | 2026-06-26 (shadow) | +| Disposition | **Canon-bug** — canon compared query *values* case-sensitively; fixed in the pipeline | +| Legacy decision | `allow` | +| Canon decision | `deny` (no error code — a plain "no rule matched" verdict, not a `CanonError`) | +| Emitter | IMDS managed-identity token client (user-assigned identity scoped by `mi_res_id`) | +| Destination | IMDS `169.254.169.254:80`, `/metadata/identity/oauth2/token` | + +**Divergence log line:** + + CANON_DIVERGENCE mode=shadow rule_set= legacy=allow canon=deny \ + uri="/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://storage.azure.com/\ + &mi_res_id=/subscriptions//resourceGroups//providers/Microsoft.ManagedIdentity/userAssignedIdentities/" + +**What happened.** A user-assigned managed-identity token request is scoped by an ARM resource id in the `mi_res_id` query value. The request carried the id in its natural mixed case (`resourceGroups`, `Microsoft.ManagedIdentity`, `userAssignedIdentities`), while the authorization rule stored it in a different case. Legacy allowed it; canon denied it — with **no** `CANON_*` error, meaning canon simply found no matching rule. + +**Root cause.** ARM resource ids are **case-insensitive** by spec, and the two matchers disagreed on how query values are folded: + +- **Legacy** (`key_keeper/key.rs::Privilege::is_match`, ~L284) lowercases *both* the rule value (at load) **and** the request value: `decoded_v.to_lowercase() == *value` → case-insensitive value match. +- **Canon** (`proxy/canonical/query.rs::canonicalize_query`) lowercased only the **key** (`k.to_ascii_lowercase()`) and pushed the **value verbatim**. The rule side runs through the *same* function, so both sides preserved value case, and `rule.rs::CanonicalPattern::matches` does an exact `av == rv` comparison → case-**sensitive** value match. Any casing delta between rule and request on `mi_res_id` produced a spurious `deny`. + +**Fix.** ASCII-lowercase query **values** as well as keys in `canonicalize_query` (`.push(v.to_ascii_lowercase())`). Because rules and requests share that function, both sides fold symmetrically and the existing `av == rv` comparison becomes case-insensitive — matching legacy. ASCII folding is deliberate: it covers all real ARM-id casing (ASCII A–Z) while leaving the pipeline's allowed non-ASCII value bytes untouched, so no Unicode-casing length surprises. This does **not** weaken security: ARM ids are case-insensitive identifiers, so two casings denote the same resource. + +**Why a value, not a path, fix.** The differing token is in the *query* (`mi_res_id`), not the path, so it never touches the ASCII-only path pipeline. Lowercasing values is the minimal change that restores legacy parity; the path pipeline's stricter non-ASCII rejection is unchanged. + +**Regression coverage.** + +- `query.rs::value_ascii_lowercased` — a mixed-case `mi_res_id` is folded to lowercase. +- `query.rs::value_non_ascii_preserved_under_ascii_fold` — ASCII folds, non-ASCII bytes survive. +- `rule.rs::matches_query_value_case_insensitive` — reproduces the exact divergence: a rule value and request value that differ only in case still match. + +**Resolution / action items.** + +- [x] **Pipeline fix:** `canonicalize_query` ASCII-lowercases values; regression tests added (above). +- [ ] **Close-out:** confirm the divergence stops appearing in shadow telemetry for managed-identity token traffic before flipping the affected fleet to **enforce**. + ## 10. Test Strategy ### 10.1 Golden vectors diff --git a/proxy_agent/src/proxy/canonical/query.rs b/proxy_agent/src/proxy/canonical/query.rs index 1540ffd2..0c31052f 100644 --- a/proxy_agent/src/proxy/canonical/query.rs +++ b/proxy_agent/src/proxy/canonical/query.rs @@ -6,7 +6,15 @@ //! - Split on `&`, then on the first `=` (additional `=` characters //! become part of the value). //! - Single percent-decode of both key and value. -//! - Lowercase the key (case-insensitive matching). +//! - ASCII-lowercase **both** key and value (case-insensitive matching). +//! Values are folded too because the constrained query values GPA +//! matches on — ARM resource ids (`mi_res_id`, `resource`) — are +//! case-insensitive, and the legacy matcher lowercased values as well +//! (`key.rs::Privilege::is_match`: `decoded_v.to_lowercase() == value`). +//! Preserving value case here would reject valid requests that only +//! differ in casing from the rule (shadow-mode divergence KD-2). +//! ASCII folding leaves any non-ASCII bytes untouched, so the +//! non-ASCII-value allowance below still holds. //! - Reject control characters and malformed UTF-8 in both key and value. //! Unlike the path pipeline, well-formed non-ASCII UTF-8 is **allowed** //! here: query *values* legitimately carry it (e.g. an IMDS @@ -43,7 +51,14 @@ pub fn canonicalize_query(raw: &str) -> Result>, Ca // ghost empty key into the map. continue; } - map.entry(k.to_ascii_lowercase()).or_default().push(v); + // ASCII-lowercase the value as well as the key: the rule side runs + // through this same function, so folding both sides keeps the + // `av == rv` comparison in `rule::CanonicalPattern::matches` + // case-insensitive — matching legacy semantics for case-insensitive + // ARM ids (KD-2). + map.entry(k.to_ascii_lowercase()) + .or_default() + .push(v.to_ascii_lowercase()); } Ok(map) } @@ -124,6 +139,33 @@ mod query_tests { assert!(!q.contains_key("API-Version")); } + #[test] + fn value_ascii_lowercased() { + // KD-2 regression: query VALUES are ASCII-lowercased (like keys) so + // matching is case-insensitive. ARM resource ids are case-insensitive + // and the legacy matcher lowercased values too; preserving case here + // produced a `legacy=allow` / `canon=deny` shadow divergence. + let q = canonicalize_query( + "mi_res_id=/subscriptions/AB/resourceGroups/RG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/My-Id", + ) + .unwrap(); + assert_eq!( + q.get("mi_res_id"), + Some(&vec![ + "/subscriptions/ab/resourcegroups/rg/providers/microsoft.managedidentity/userassignedidentities/my-id" + .to_string() + ]) + ); + } + + #[test] + fn value_non_ascii_preserved_under_ascii_fold() { + // ASCII folding must leave non-ASCII bytes untouched (the query + // pipeline allows Unicode in values). Only the ASCII `R` folds. + let q = canonicalize_query("resource=Rés").unwrap(); + assert_eq!(q.get("resource"), Some(&vec!["rés".to_string()])); + } + #[test] fn percent_decoded_once() { let q = canonicalize_query("resource=https%3A%2F%2Fmanagement.azure.com%2F").unwrap(); diff --git a/proxy_agent/src/proxy/canonical/rule.rs b/proxy_agent/src/proxy/canonical/rule.rs index edbdd106..32fe38e3 100644 --- a/proxy_agent/src/proxy/canonical/rule.rs +++ b/proxy_agent/src/proxy/canonical/rule.rs @@ -490,4 +490,34 @@ mod rule_tests { assert_eq!(rule.matches(&r), *expected, "{label}"); } } + + #[test] + fn matches_query_value_case_insensitive() { + // KD-2 regression: ARM resource ids are case-insensitive. A rule + // value and a request value that differ ONLY in case must still + // match, because `canonicalize_query` ASCII-lowercases values on + // both the rule and request sides. Reproduces the exact shadow-mode + // divergence (legacy=allow / canon=deny) on a `mi_res_id`-scoped + // IMDS token request whose ARM id carries mixed case + // (`Microsoft.ManagedIdentity`, `userAssignedIdentities`). + let rule = CanonicalPattern::from_privilege(&priv_of( + "/metadata/identity/oauth2/token", + Some(&[( + "mi_res_id", + "/subscriptions/14784331-27fd-49a3-a6ec-840d707ebd4a/resourcegroups/xceuapbn1-rg/providers/microsoft.managedidentity/userassignedidentities/xceuapbn1-frontendrole-identity", + )]), + )) + .unwrap(); + let req = req_of( + "http://169.254.169.254/metadata/identity/oauth2/token\ + ?api-version=2018-02-01\ + &resource=https://storage.azure.com/\ + &mi_res_id=/subscriptions/14784331-27fd-49a3-a6ec-840d707ebd4a/resourceGroups/xceuapbn1-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/xceuapbn1-frontendrole-identity", + &Method::GET, + ); + assert!( + rule.matches(&req), + "case-differing ARM id must match (KD-2)" + ); + } } From e6702eb4579c153e8bf64e7dd6c6e81fa9f664ab Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Mon, 29 Jun 2026 15:12:47 -0700 Subject: [PATCH 2/3] fix spell --- cspell.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cspell.json b/cspell.json index fd1f639a..d81b4a0f 100644 --- a/cspell.json +++ b/cspell.json @@ -158,6 +158,7 @@ "devcontainer", "diffed", "diffs", + "dispositioned", "distros", "dllmain", "dministrator", @@ -458,6 +459,7 @@ "sas", "sbom", "scapy", + "scheduledevents", "scm", "SDDL", "secauthz", @@ -553,6 +555,7 @@ "updateable", "uppercased", "uring", + "userassignedidentities", "userinfo", "uzers", "valu", @@ -608,6 +611,7 @@ "xamarin", "xbb", "xbf", + "xceuapbn", "xcopy", "XDP", "xef", From 3b8d3c9fba9a9282c9c3807e38696497b146aaa2 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Mon, 6 Jul 2026 10:53:29 -0700 Subject: [PATCH 3/3] add KD3 --- doc/plans/Innovation-2.1-canonical-request.md | 50 +++++++++++++++++++ e2etest/.github/copilot-instructions.md | 3 ++ proxy_agent/src/proxy/canonical/query.rs | 6 +++ 3 files changed, 59 insertions(+) create mode 100644 e2etest/.github/copilot-instructions.md diff --git a/doc/plans/Innovation-2.1-canonical-request.md b/doc/plans/Innovation-2.1-canonical-request.md index 05eba3a1..820d1677 100644 --- a/doc/plans/Innovation-2.1-canonical-request.md +++ b/doc/plans/Innovation-2.1-canonical-request.md @@ -381,6 +381,8 @@ Per RFC 3986, `%3F` inside the path is a literal `?` character that is *part of **Why we do NOT "fix" this in canon by decode-and-splitting.** GPA forwards the **original bytes** to the upstream (§1.3; `proxy_server.rs` only adds headers, never rewrites the URI). So any decode-and-split in canon would merely be GPA *predicting* IMDS's parser — re-introducing the differential the moment the two parsers disagree on an edge case (`%23` fragment, repeated/encoded delimiters, `+`→space), and broadening `%3F`-as-delimiter acceptance to *every* endpoint. The clean, auditable fail-closed deny is preferable to a fragile parser-equivalence bet. +**Security assessment.** *Medium latent severity; benign for this emitter.* This is a genuine authorization **differential**, not a cosmetic one: legacy authorizes an opaque, still-encoded path string while IMDS acts on the *decoded* query, so the bytes GPA approved and the request IMDS served are semantically different. For this caller it is harmless — `scheduledevents` is an allowed endpoint either way and the emitter is a first-party `SYSTEM` agent — but the *class* (path/query confusion via `%3F`) is exactly how a crafted request turns a permissive prefix rule into access to a query-gated or path-adjacent endpoint the rule never intended to allow. Canon closing it is real hardening, not tidiness. There is no wire-level smuggling: the `%3F` stays percent-encoded in the bytes GPA forwards, so the confusion lives purely at the authZ layer. Enforce impact is availability-only — scheduled-events polling for this agent is denied until the client emits a literal `?`. + **Resolution / action items.** - [ ] **Root cause (owner: VmssRuntimeAgent team):** fix the IMDS URL construction to emit a literal `?`; audit all IMDS call sites for the same pattern (typically caused by escaping the whole path+query string instead of building the query with a URI builder). @@ -421,11 +423,59 @@ Per RFC 3986, `%3F` inside the path is a literal `?` character that is *part of - `query.rs::value_non_ascii_preserved_under_ascii_fold` — ASCII folds, non-ASCII bytes survive. - `rule.rs::matches_query_value_case_insensitive` — reproduces the exact divergence: a rule value and request value that differ only in case still match. +**Security assessment.** *No security exposure; availability bug only.* The pre-fix canon was **over-restrictive** — it denied legitimate managed-identity token requests, so the failure mode was false-*deny* (availability), never over-grant. There is no confidentiality or integrity angle in either direction. The fix does not weaken authorization: ARM resource ids are case-insensitive identifiers by spec, so ASCII-folding a value cannot make two *distinct* resources collide — a folded rule value matches a folded request value iff they denote the same resource. The fold is ASCII-only, so it also can't merge distinct non-ASCII values. Had this reached enforce unfixed, the impact would have been denied token requests for user-assigned identities whose `mi_res_id` casing differed from the stored rule — an outage, not a breach. + **Resolution / action items.** - [x] **Pipeline fix:** `canonicalize_query` ASCII-lowercases values; regression tests added (above). - [ ] **Close-out:** confirm the divergence stops appearing in shadow telemetry for managed-identity token traffic before flipping the affected fleet to **enforce**. +#### KD-3 · Trailing carriage return (`%0D`) on a `client_id` query value (`CANON_CTRL`) + +| Field | Value | +|------------------|--------------------------------------------------------------------------------------------------------| +| First observed | 2026-07-06 (shadow) | +| Disposition | **Canon-stricter-and-correct** — client-side defect; canon deny is the intended behavior | +| Legacy decision | `allow` | +| Canon decision | `deny` → `CANON_CTRL` (`CanonError::ControlChar`, control character in a decoded query component) | +| Emitter | IMDS managed-identity token client (identity scoped by `client_id`) | +| Destination | IMDS `169.254.169.254:80`, `/metadata/identity/oauth2/token` | + +**Divergence log line:** + + CANON_DIVERGENCE mode=shadow rule_set= legacy=allow canon=error:CANON_CTRL \ + uri="/metadata/identity/oauth2/token?api-version=2018-02-01\ + &authority=https://login.microsoftonline.com/&resource=https://vault.azure.net\ + &client_id=292e4c16-1d25-4666-a60a-52d441e9ee5b%0D" + +**What happened.** A managed-identity token request carried a trailing `%0D` appended to the `client_id` value. `%0D` percent-decodes to `0x0D` — a carriage return (`\r`): + + on the wire : client_id=292e4c16-...-52d441e9ee5b%0D ← trailing CR after the GUID + intended : client_id=292e4c16-...-52d441e9ee5b ← bare GUID + +No legitimate caller puts a control character in a `client_id`. This is classic line-ending contamination: the GUID was sourced from something CRLF-terminated (a file read, an env var, `echo` without `-n`, a Windows text buffer, or `"$id\r\n"` string concatenation) and then percent-encoded into the query as `%0D`. + +**Why it currently succeeds.** The `client_id` is not *constrained* by the authorization rule (the rule matches on path + `api-version`), so: + +1. **Legacy matcher** — decodes the value to `...52d441e9ee5b\r`, lowercases it, and since nothing compares against `client_id`, the privilege still matches and identity resolves → `allow`. The stray `\r` just rides along. +2. **IMDS** — tolerates or trims the trailing CR when resolving the identity, so production sees a token issued and the bug stays masked. + +**Why canon denies (and why that's right).** The canonical query pipeline (`proxy/canonical/query.rs::canonicalize_query` → `decode_query_component`) percent-decodes once, then rejects any decoded byte `< 0x20` or `== 0x7F` as `CanonError::ControlChar`. A `\r` in a request component is exactly the CRLF-injection / request-splitting vector the control-char guard exists to stop, and RFC 3986 query values are `pchar`-only — raw control octets are not allowed. Canon fails closed on the *whole* request (`CANON_CTRL`) rather than silently forwarding a `\r`. Legacy is the permissive, non-compliant side. + +**Why we do NOT "fix" this in canon by stripping the CR.** Silently trimming control characters would put GPA back in the business of *repairing* malformed client input and predicting how the upstream normalizes it — the same parser-equivalence trap called out in KD-1. Rejecting is auditable and safe under any upstream parser; the correct remedy is to stop emitting the CR at the source. + +**Regression coverage.** + +- `query.rs::control_char_rejected` — asserts a decoded control character in a query component yields `CanonError::ControlChar`, including the exact KD-3 vector (`client_id=...%0D`). + +**Security assessment.** *Low severity; availability consideration only.* No authorization bypass in either direction — `client_id` is not a rule constraint, so the trailing `\r` grants nothing extra (legacy) and denies no legitimate access on its merits (canon fails closed on hygiene, not scope). No wire-level CRLF injection: the control byte stays percent-encoded (`%0D`) in the bytes GPA forwards and is never decoded-then-reinserted into a header or request line. No log forging — the divergence line renders the URI with `{:?}`, which escapes `\r`. No cross-identity token leak — a CR-suffixed GUID collides with no other valid `client_id`; worst case IMDS trims it (intended token) or rejects it (client error). The only real risk is availability: flipping enforce before the client is fixed denies these malformed-but-currently-working token requests. Treat as data-hygiene / client-correctness and gate the enforce flip on the client fix; no hotfix or security escalation warranted. + +**Resolution / action items.** + +- [ ] **Root cause (owner: token-client team):** strip the trailing CR/LF from `client_id` (and audit all IMDS query values) at the source; the GUID should be trimmed before URL construction. +- [ ] **GPA rollout gate:** keep the affected fleet in **shadow** (do not enable **enforce**) until the client fix is deployed, so the malformed-but-currently-working requests are not denied. +- [ ] **Close-out:** entry is resolved when the divergence stops appearing in shadow telemetry post-deployment. + ## 10. Test Strategy ### 10.1 Golden vectors diff --git a/e2etest/.github/copilot-instructions.md b/e2etest/.github/copilot-instructions.md new file mode 100644 index 00000000..e40201cf --- /dev/null +++ b/e2etest/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +- @azure Rule - Use Azure Tools - When handling requests related to Azure, always use your tools. +- @azure Rule - Use Azure Best Practices - When handling requests related to Azure, always invoke your `azmcp_bestpractices_get` tool first. +- @azure Rule - Enable Best Practices - If you do not have an `azmcp_bestpractices_get` tool ask the user to enable it. diff --git a/proxy_agent/src/proxy/canonical/query.rs b/proxy_agent/src/proxy/canonical/query.rs index 0c31052f..5e84f9d5 100644 --- a/proxy_agent/src/proxy/canonical/query.rs +++ b/proxy_agent/src/proxy/canonical/query.rs @@ -217,6 +217,12 @@ mod query_tests { canonicalize_query("k=%0A").unwrap_err(), CanonError::ControlChar ); + // KD-3: a trailing carriage return (`%0D` -> `\r`) on a query value + // (observed as a contaminated `client_id`) must fail closed. + assert_eq!( + canonicalize_query("client_id=292e4c16-1d25-4666-a60a-52d441e9ee5b%0D").unwrap_err(), + CanonError::ControlChar + ); } #[test]