Skip to content

Commit cf4e4ca

Browse files
stephentoubCopilot
andauthored
Fix codegen identifier sanitization (#1285)
* Fix generated identifier sanitization Sanitize generated string enum identifiers across C#, Go, and Rust so schema values containing path or URL separators produce valid language identifiers while preserving wire values. Also emit top-level Rust discriminated unions needed by newer schemas and refresh the Rust generated output from the currently imported 1.0.46 schema. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Harden enum collision handling Fail code generation when sanitized enum values collide with existing public member names instead of stabilizing arbitrary numeric suffixes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Complete denied-permission replay snapshots Add the optional post-denial model turns so denied edit tests can replay deterministically if the CLI asks the model to summarize the denied tool result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Complete shared denied-permission snapshot Add the optional post-denial model turn for the shared multi-client replay snapshot so SDKs that wait for the final assistant message can replay deterministically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Share denied-permission multi-client snapshot Point the Rust denied-permission multi-client E2E test at the shared multi_client snapshot and remove the duplicate Rust-only copy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Share approved-permission multi-client snapshot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7fb75a3 commit cf4e4ca

10 files changed

Lines changed: 269 additions & 211 deletions

rust/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,8 @@ let forked = client
166166
.rpc()
167167
.sessions()
168168
.fork(github_copilot_sdk::generated::api_types::SessionsForkRequest {
169-
session_id: "session-id".to_string(),
170-
from_message_id: None,
169+
session_id: "session-id".into(),
170+
to_event_id: None,
171171
})
172172
.await?;
173173
```

rust/src/generated/api_types.rs

Lines changed: 116 additions & 116 deletions
Large diffs are not rendered by default.

rust/tests/e2e/multi_client.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ async fn both_clients_see_tool_request_and_completion_events() {
105105
#[tokio::test]
106106
async fn one_client_approves_permission_and_both_see_the_result() {
107107
with_e2e_context(
108-
"rust_multi_client",
108+
"multi_client",
109109
"one_client_approves_permission_and_both_see_the_result",
110110
|ctx| {
111111
Box::pin(async move {
@@ -193,7 +193,7 @@ async fn one_client_approves_permission_and_both_see_the_result() {
193193
#[tokio::test]
194194
async fn one_client_rejects_permission_and_both_see_the_result() {
195195
with_e2e_context(
196-
"rust_multi_client",
196+
"multi_client",
197197
"one_client_rejects_permission_and_both_see_the_result",
198198
|ctx| {
199199
Box::pin(async move {

scripts/codegen/csharp.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,18 +118,42 @@ function xmlDocEnumComment(description: string | undefined, indent: string): str
118118
}
119119

120120
function toPascalCase(name: string): string {
121-
if (name.includes("_") || name.includes("-")) {
122-
return name.split(/[-_]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
123-
}
121+
const parts = splitCSharpIdentifierParts(name);
122+
if (parts.length > 1) return parts.map(toPascalCasePart).join("");
124123
return name.charAt(0).toUpperCase() + name.slice(1);
125124
}
126125

127126
function typeToClassName(typeName: string): string {
128-
return typeName.split(/[._]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
127+
return splitCSharpIdentifierParts(typeName).map(toPascalCasePart).join("");
128+
}
129+
130+
function splitCSharpIdentifierParts(value: string): string[] {
131+
return value.split(/[^A-Za-z0-9]+/).filter(Boolean);
132+
}
133+
134+
function toPascalCasePart(value: string): string {
135+
return value.charAt(0).toUpperCase() + value.slice(1);
129136
}
130137

131-
function toPascalCaseEnumMember(value: string): string {
132-
return value.split(/[-_.]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
138+
function toCSharpIdentifier(value: string, fallback: string): string {
139+
let identifier = splitCSharpIdentifierParts(value).map(toPascalCasePart).join("");
140+
if (!identifier) {
141+
identifier = fallback;
142+
} else if (!/^[A-Za-z_]/.test(identifier)) {
143+
identifier = `${fallback}${identifier}`;
144+
}
145+
return identifier;
146+
}
147+
148+
function uniqueCSharpIdentifier(value: string, used: Set<string>, fallback: string): string {
149+
const identifier = toCSharpIdentifier(value, fallback);
150+
if (used.has(identifier)) {
151+
throw new Error(
152+
`Generated C# string enum member identifier "${identifier}" is not unique for value "${value}". Add an explicit naming rule instead of stabilizing an arbitrary public member name.`
153+
);
154+
}
155+
used.add(identifier);
156+
return identifier;
133157
}
134158

135159
async function formatCSharpFile(filePath: string): Promise<void> {
@@ -311,6 +335,7 @@ const COPYRIGHT = `/*-----------------------------------------------------------
311335

312336
const EXPERIMENTAL_ATTRIBUTE = "[Experimental(Diagnostics.Experimental)]";
313337
const OBSOLETE_ATTRIBUTE = `[Obsolete("This member is deprecated and will be removed in a future version.")]`;
338+
const STRING_ENUM_RESERVED_MEMBER_NAMES = new Set(["Value", "Equals", "GetHashCode", "ToString", "Converter"]);
314339

315340
function experimentalAttribute(indent = ""): string {
316341
return `${indent}${EXPERIMENTAL_ATTRIBUTE}`;
@@ -374,9 +399,11 @@ function getOrCreateEnum(
374399
lines.push(` }`, "");
375400
lines.push(` /// <summary>Gets the value associated with this <see cref="${enumName}"/>.</summary>`);
376401
lines.push(` public string Value => _value ?? string.Empty;`, "");
402+
const usedMemberNames = new Set(STRING_ENUM_RESERVED_MEMBER_NAMES);
377403
for (const value of values) {
404+
const memberName = uniqueCSharpIdentifier(value, usedMemberNames, "Value");
378405
lines.push(` /// <summary>Gets the <c>${escapeXml(value)}</c> value.</summary>`);
379-
lines.push(` public static ${enumName} ${toPascalCaseEnumMember(value)} { get; } = new("${value}");`, "");
406+
lines.push(` public static ${enumName} ${memberName} { get; } = new("${escapeCSharpStringLiteral(value)}");`, "");
380407
}
381408
lines.push(` /// <summary>Returns a value indicating whether two <see cref="${enumName}"/> instances are equivalent.</summary>`);
382409
lines.push(` public static bool operator ==(${enumName} left, ${enumName} right) => left.Equals(right);`, "");

scripts/codegen/go.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ const wrapGoCommentText = wordwrap(goCommentTextWrapLength);
5252

5353
function toPascalCase(s: string): string {
5454
return s
55-
.split(/[._]/)
55+
.split(/[^A-Za-z0-9]+/)
56+
.filter((word) => word.length > 0)
5657
.map((w) => goInitialisms.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1))
5758
.join("");
5859
}
@@ -92,7 +93,7 @@ function splitGoIdentifierWords(name: string): string[] {
9293
return name
9394
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
9495
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
95-
.split(/[._-]/)
96+
.split(/[^A-Za-z0-9]+/)
9697
.filter((word) => word.length > 0);
9798
}
9899

@@ -547,8 +548,17 @@ function getOrCreateGoEnum(
547548
const consts = values
548549
.map((value) => ({ value, constSuffix: goEnumConstSuffix(value) }))
549550
.sort((left, right) => `${enumName}${left.constSuffix}`.localeCompare(`${enumName}${right.constSuffix}`));
551+
const usedConstNames = new Map<string, string>();
550552
for (const { value, constSuffix } of consts) {
551-
lines.push(`\t${enumName}${constSuffix} ${enumName} = "${value}"`);
553+
const constName = `${enumName}${constSuffix}`;
554+
const existingValue = usedConstNames.get(constName);
555+
if (existingValue !== undefined) {
556+
throw new Error(
557+
`Generated Go enum const identifier "${constName}" is not unique for values "${existingValue}" and "${value}". Add an explicit naming rule instead of stabilizing an arbitrary public const name.`
558+
);
559+
}
560+
usedConstNames.set(constName, value);
561+
lines.push(`\t${constName} ${enumName} = "${value}"`);
552562
}
553563
lines.push(`)`);
554564

@@ -558,14 +568,14 @@ function getOrCreateGoEnum(
558568
}
559569

560570
function goEnumConstSuffix(value: string): string {
561-
return value
562-
.split(/[-_.]/)
571+
const suffix = splitGoIdentifierWords(value)
563572
.map((word) =>
564573
goInitialisms.has(word.toLowerCase())
565574
? word.toUpperCase()
566575
: word.charAt(0).toUpperCase() + word.slice(1)
567576
)
568577
.join("");
578+
return suffix || "Value";
569579
}
570580

571581
function goDiscriminatedUnionVariantTypeName(

scripts/codegen/rust.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,39 @@ const STRING_NEWTYPE_OVERRIDES: Record<string, string> = {
5959

6060
function toPascalCase(s: string): string {
6161
return s
62-
.split(/[._\-\s]+/)
62+
.split(/[^A-Za-z0-9]+/)
63+
.filter(Boolean)
6364
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
6465
.join("");
6566
}
6667

68+
function toRustPascalIdentifier(value: string, fallback: string): string {
69+
let identifier = toPascalCase(value);
70+
if (!identifier) {
71+
identifier = fallback;
72+
} else if (!/^[A-Za-z_]/.test(identifier)) {
73+
identifier = `${fallback}${identifier}`;
74+
}
75+
76+
return RUST_KEYWORDS.has(identifier) ? `${identifier}Value` : identifier;
77+
}
78+
79+
function uniqueRustPascalIdentifier(
80+
value: string,
81+
used: Set<string>,
82+
fallback: string,
83+
reserved: Set<string> = new Set(),
84+
): string {
85+
const identifier = toRustPascalIdentifier(value, fallback);
86+
if (used.has(identifier) || reserved.has(identifier)) {
87+
throw new Error(
88+
`Generated Rust enum variant identifier "${identifier}" is not unique for value "${value}". Add an explicit naming rule instead of stabilizing an arbitrary public variant name.`,
89+
);
90+
}
91+
used.add(identifier);
92+
return identifier;
93+
}
94+
6795
function toSnakeCase(s: string): string {
6896
return s
6997
.replace(/([A-Z])/g, "_$1")
@@ -233,10 +261,16 @@ function tryEmitRustDiscriminatedUnion(
233261
lines.push("#[serde(untagged)]");
234262
lines.push(`pub enum ${enumName} {`);
235263

264+
const usedVariantNames = new Set<string>();
236265
for (const { schema: variantSchema, typeName } of resolvedVariants) {
237266
const kind = ((variantSchema.properties?.kind as JSONSchema7 | undefined)
238267
?.const ?? typeName) as string;
239-
lines.push(` ${toPascalCase(kind)}(${stripOption(typeName)}),`);
268+
const variantName = uniqueRustPascalIdentifier(
269+
kind,
270+
usedVariantNames,
271+
"Variant",
272+
);
273+
lines.push(` ${variantName}(${stripOption(typeName)}),`);
240274
}
241275

242276
lines.push("}");
@@ -617,8 +651,15 @@ function emitRustStringEnum(
617651
lines.push("#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]");
618652
lines.push(`pub enum ${enumName} {`);
619653

654+
const usedVariantNames = new Set<string>();
655+
const reservedVariantNames = new Set(["Unknown"]);
620656
for (const value of values) {
621-
const variantName = toPascalCase(value);
657+
const variantName = uniqueRustPascalIdentifier(
658+
value,
659+
usedVariantNames,
660+
"Value",
661+
reservedVariantNames,
662+
);
622663
if (variantName !== value) {
623664
lines.push(` #[serde(rename = "${value}")]`);
624665
}
@@ -651,7 +692,7 @@ function emitRustConstStringEnum(
651692
}
652693
lines.push("#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]");
653694
lines.push(`pub enum ${enumName} {`);
654-
const variantName = toPascalCase(value);
695+
const variantName = toRustPascalIdentifier(value, "Value");
655696
if (variantName !== value) {
656697
lines.push(` #[serde(rename = "${value}")]`);
657698
}
@@ -927,6 +968,8 @@ function generateApiTypesCode(apiSchema: ApiSchema): string {
927968
schema.description,
928969
isSchemaExperimental(schema),
929970
);
971+
} else if (getUnionVariants(schema)) {
972+
tryEmitRustDiscriminatedUnion(schema, name, "", ctx);
930973
} else if (isObjectSchema(schema)) {
931974
emitRustStruct(name, schema, ctx, schema.description);
932975
}

test/snapshots/multi_client/one_client_rejects_permission_and_both_see_the_result.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,30 @@ conversations:
2323
function:
2424
name: view
2525
arguments: '{"path":"${workdir}/protected.txt"}'
26+
- messages:
27+
- role: system
28+
content: ${system}
29+
- role: user
30+
content: Edit protected.txt and replace 'protected' with 'hacked'.
31+
- role: assistant
32+
content: I'll help you edit protected.txt to replace 'protected' with 'hacked'. Let me first view the file and then make
33+
the change.
34+
tool_calls:
35+
- id: toolcall_0
36+
type: function
37+
function:
38+
name: report_intent
39+
arguments: '{"intent":"Editing protected.txt file"}'
40+
- id: toolcall_1
41+
type: function
42+
function:
43+
name: view
44+
arguments: '{"path":"${workdir}/protected.txt"}'
45+
- role: tool
46+
tool_call_id: toolcall_0
47+
content: Intent logged
48+
- role: tool
49+
tool_call_id: toolcall_1
50+
content: Permission denied and could not request permission from user
51+
- role: assistant
52+
content: I don't have permission to view or edit protected.txt, so I can't make that change.

test/snapshots/permissions/should_deny_permission_when_handler_returns_denied.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,29 @@ conversations:
2222
function:
2323
name: view
2424
arguments: '{"path":"${workdir}/protected.txt"}'
25+
- messages:
26+
- role: system
27+
content: ${system}
28+
- role: user
29+
content: Edit protected.txt and replace 'protected' with 'hacked'.
30+
- role: assistant
31+
content: I'll view the file first, then make the edit.
32+
tool_calls:
33+
- id: toolcall_0
34+
type: function
35+
function:
36+
name: report_intent
37+
arguments: '{"intent":"Editing protected.txt file"}'
38+
- id: toolcall_1
39+
type: function
40+
function:
41+
name: view
42+
arguments: '{"path":"${workdir}/protected.txt"}'
43+
- role: tool
44+
tool_call_id: toolcall_0
45+
content: Intent logged
46+
- role: tool
47+
tool_call_id: toolcall_1
48+
content: Permission denied and could not request permission from user
49+
- role: assistant
50+
content: I don't have permission to view or edit protected.txt, so I can't make that change.

test/snapshots/rust_multi_client/one_client_approves_permission_and_both_see_the_result.yaml

Lines changed: 0 additions & 50 deletions
This file was deleted.

test/snapshots/rust_multi_client/one_client_rejects_permission_and_both_see_the_result.yaml

Lines changed: 0 additions & 25 deletions
This file was deleted.

0 commit comments

Comments
 (0)