Repository navigation
fix(api/spec): upgrade TypeSpec to address output path traversal #5337
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import assert from 'node:assert/strict' | ||
| import { it } from 'node:test' | ||
| import { getFriendlyName, serializeValueAsJson } from '@typespec/compiler' | ||
| import { createTestHost, createTestRunner } from '@typespec/compiler/testing' | ||
|
|
||
| it('preserves distinct names for visibility-projected nested models', async () => { | ||
| const host = await createTestHost() | ||
| const runner = await createTestRunner(host) | ||
| await runner.compile(` | ||
| @friendlyName("ApiChild") | ||
| model Child { | ||
| @visibility(Lifecycle.Read) id: string; | ||
| @visibility(Lifecycle.Create) value: string; | ||
| } | ||
| model Parent { child: Child; } | ||
| model Request is FilterVisibility< | ||
| Parent, #{ all: #[Lifecycle.Create] }, "Create{name}" | ||
| >; | ||
| `) | ||
|
|
||
| const models = runner.program.checker.getGlobalNamespaceType().models | ||
| const child = models.get('Child') | ||
| const requestChild = models.get('Request').properties.get('child').type | ||
|
|
||
| // The request clone must not inherit the response model's schema identity. | ||
| assert.equal(getFriendlyName(runner.program, child), 'ApiChild') | ||
| assert.equal(getFriendlyName(runner.program, requestChild), 'CreateChild') | ||
| assert.deepEqual([...requestChild.properties.keys()], ['value']) | ||
| }) | ||
|
|
||
| it('preserves discriminated-union object defaults during JSON serialization', async () => { | ||
| const host = await createTestHost() | ||
| const runner = await createTestRunner(host) | ||
| await runner.compile(` | ||
| enum Kind { subscription: "subscription", anchored: "anchored" } | ||
| model Subscription { type: Kind.subscription; } | ||
| model Anchored { type: Kind.anchored; interval: string; } | ||
| @discriminated(#{ discriminatorPropertyName: "type", envelope: "none" }) | ||
| union Alignment { subscription: Subscription, anchored: Anchored } | ||
| const defaultAlignment: Subscription = #{ type: Kind.subscription }; | ||
| model Settings { alignment?: Alignment = defaultAlignment; } | ||
| `) | ||
|
|
||
| const property = runner.program.checker | ||
| .getGlobalNamespaceType() | ||
| .models.get('Settings') | ||
| .properties.get('alignment') | ||
| assert.deepEqual( | ||
| serializeValueAsJson(runner.program, property.defaultValue, property), | ||
| { type: 'subscription' }, | ||
| ) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import assert from 'node:assert/strict' | ||
| import { fileURLToPath } from 'node:url' | ||
| import { describe, it } from 'node:test' | ||
| import { createTester } from '@typespec/compiler/testing' | ||
|
|
||
| const tester = createTester(fileURLToPath(new URL('..', import.meta.url)), { | ||
| libraries: ['@typespec/http', '@typespec/openapi3', '@typespec/versioning'], | ||
| }) | ||
|
|
||
| describe('OpenAPI output paths', () => { | ||
| for (const { version, filename } of [ | ||
| { version: '../../escape', filename: '.._.._escape.json' }, | ||
| { version: '..\\..\\escape', filename: '.._.._escape.json' }, | ||
| { version: '/tmp/escape', filename: '_tmp_escape.json' }, | ||
| { version: '2026-10-22', filename: '2026-10-22.json' }, | ||
| ]) { | ||
| it(`keeps version ${JSON.stringify(version)} inside the output directory`, async () => { | ||
| // Spec-derived names must not control directories, while benign versions | ||
| // keep their existing filenames and OpenAPI metadata. | ||
| const { outputs } = await tester.emit('@typespec/openapi3', { | ||
| 'output-file': '{version}.json', | ||
| }).compile(` | ||
| import "@typespec/http"; | ||
| import "@typespec/versioning"; | ||
| using TypeSpec.Http; | ||
| using TypeSpec.Versioning; | ||
| @service | ||
| @versioned(Versions) | ||
| namespace Service; | ||
| enum Versions { v1: ${JSON.stringify(version)} } | ||
| @get op read(): string; | ||
| `) | ||
|
|
||
| assert.deepEqual(Object.keys(outputs), [filename]) | ||
| assert.equal(JSON.parse(outputs[filename]).info.version, version) | ||
| }) | ||
| } | ||
|
|
||
| it('keeps spec-derived service names inside the output directory', async () => { | ||
| const { outputs } = await tester.emit('@typespec/openapi3', { | ||
| 'output-file': '{service-name}.json', | ||
| }).compile(` | ||
| import "@typespec/http"; | ||
| using TypeSpec.Http; | ||
| @service | ||
| namespace \`../../escape\`; | ||
| @get op read(): string; | ||
| `) | ||
|
|
||
| assert.deepEqual(Object.keys(outputs), ['.._.._escape.json']) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| diff --git a/dist/src/lib/decorators.d.ts b/dist/src/lib/decorators.d.ts | ||
| index d47aede90d9a522abdeb5dfd363493a7e8ea22d9..578a05f61a1dcd418ce3d39187309ee40a7f35e1 100644 | ||
| --- a/dist/src/lib/decorators.d.ts | ||
| +++ b/dist/src/lib/decorators.d.ts | ||
| @@ -198,6 +198,8 @@ export declare function getAllTags(program: Program, target: Namespace | Interfa | ||
| declare const getFriendlyName: (program: Program, type: Type) => string | undefined; | ||
| export declare const $friendlyName: FriendlyNameDecorator; | ||
| export { getFriendlyName }; | ||
| +export declare function clearFriendlyName(program: Program, target: Type): void; | ||
| +export declare const setFriendlyName: (program: Program, type: Type, value: string) => void; | ||
| /** | ||
| * `@key` - mark a model property as the key to identify instances of that type | ||
| * | ||
| diff --git a/dist/src/lib/decorators.js b/dist/src/lib/decorators.js | ||
| index 807bd3fabd06766ab8b1faff980da2307dff8f55..40c45d5441c31d934bf3f7a6d8c5363bace74c9a 100644 | ||
| --- a/dist/src/lib/decorators.js | ||
| +++ b/dist/src/lib/decorators.js | ||
| @@ -720,7 +720,8 @@ export function getAllTags(program, target) { | ||
| return tags.size > 0 ? Array.from(tags).reverse() : undefined; | ||
| } | ||
| // -- @friendlyName decorator --------------------- | ||
| -const [getFriendlyName, setFriendlyName] = useStateMap(createStateSymbol("friendlyNames")); | ||
| +const friendlyNamesSymbol = createStateSymbol("friendlyNames"); | ||
| +const [getFriendlyName, setFriendlyName] = useStateMap(friendlyNamesSymbol); | ||
| export const $friendlyName = (context, target, friendlyName, sourceObject) => { | ||
| // workaround for current lack of functionality in compiler | ||
| // https://github.com/microsoft/typespec/issues/2717 | ||
| @@ -742,7 +743,10 @@ export const $friendlyName = (context, target, friendlyName, sourceObject) => { | ||
| } | ||
| setFriendlyName(context.program, target, friendlyName); | ||
| }; | ||
| -export { getFriendlyName }; | ||
| +export { getFriendlyName, setFriendlyName }; | ||
| +export function clearFriendlyName(program, target) { | ||
| + program.stateMap(friendlyNamesSymbol).delete(target); | ||
| +} | ||
| /** | ||
| * `@key` - mark a model property as the key to identify instances of that type | ||
| * | ||
| diff --git a/dist/src/lib/examples.js b/dist/src/lib/examples.js | ||
| index df3a1aeacd0fa9f50f01f2247b62d601d9163991..7e980b323afb97ad02bf1886c1566a76cd5f0059 100644 | ||
| --- a/dist/src/lib/examples.js | ||
| +++ b/dist/src/lib/examples.js | ||
| @@ -107,6 +107,11 @@ function serializeObjectValueAsJson(program, value, type, handlers, diagnosticTa | ||
| obj[name] = serializeValueAsJson(program, propValue.value, definition, | ||
| /* encodeAs: */ undefined, handlers, propValue.node); | ||
| } | ||
| + else if (type.kind === "Union") { | ||
| + // If the type is still a union (meaning resolveUnions failed to resolve it to a specific variant), | ||
| + // we should still serialize all the properties from the value | ||
| + obj[propValue.name] = serializeValueAsJson(program, propValue.value, program.checker.anyType); | ||
| + } | ||
| } | ||
| return obj; | ||
| } | ||
| diff --git a/dist/src/lib/visibility.js b/dist/src/lib/visibility.js | ||
| index 3b5a795b2c060ae803462be8d6c1e3ab574f4e08..8bd40cd7c3fdc258ef1a9089c6207417c5060509 100644 | ||
| --- a/dist/src/lib/visibility.js | ||
| +++ b/dist/src/lib/visibility.js | ||
| @@ -9,6 +9,7 @@ import { mutateSubgraph, MutatorFlow, setAlwaysMutate } from "../experimental/mu | ||
| import { $ } from "../typekit/index.js"; | ||
| import { useStateMap } from "../utils/index.js"; | ||
| import { mutate } from "../utils/misc.js"; | ||
| +import { $friendlyName, setFriendlyName } from "./decorators.js"; | ||
| import { isKey } from "./key.js"; | ||
| import { createStateSymbol, filterModelPropertiesInPlace, replaceTemplatedStringFromProperties, } from "./utils.js"; | ||
| export const $withDefaultKeyVisibility = (context, entity, visibility) => { | ||
| @@ -347,7 +348,12 @@ function createVisibilityFilterMutator(filter, options = {}) { | ||
| modified ||= variant.type !== member.type; | ||
| } | ||
| } | ||
| - rename(clone, options.nameTemplate); | ||
| + if (options.nameTemplate) { | ||
| + const beforeLen = clone.decorators.length; | ||
| + clone.decorators = clone.decorators.filter((d) => d.decorator !== $friendlyName); | ||
| + modified ||= clone.decorators.length !== beforeLen; | ||
| + } | ||
| + rename(program, clone, options.nameTemplate); | ||
| return modified ? clone : union; | ||
| }, | ||
| }, | ||
| @@ -388,7 +394,12 @@ function createVisibilityFilterMutator(filter, options = {}) { | ||
| (options.decoratorName && isTypeSpecDecorator(d, options.decoratorName)))); | ||
| modified ||= clone.decorators.length !== model.decorators.length; | ||
| } | ||
| - rename(clone, options.nameTemplate); | ||
| + if (options.nameTemplate) { | ||
| + const beforeLen = clone.decorators.length; | ||
| + clone.decorators = clone.decorators.filter((d) => d.decorator !== $friendlyName); | ||
| + modified ||= clone.decorators.length !== beforeLen; | ||
| + } | ||
| + rename(program, clone, options.nameTemplate); | ||
| return modified ? clone : model; | ||
| }, | ||
| }, | ||
| @@ -427,13 +438,19 @@ function createVisibilityFilterMutator(filter, options = {}) { | ||
| return self; | ||
| } | ||
| /** | ||
| - * Internal helper to rename a type in place. | ||
| + * Internal helper to rename a type in place and register the rendered template | ||
| + * as the clone's friendly name. The clone inherits the original's @friendlyName | ||
| + * state (finishType re-applies it from the copied decorators), which would make | ||
| + * the clone collide with the original's schema name; overriding it with the | ||
| + * rendered nameTemplate keeps emitted schema names plain (e.g. "UpdateAddress") | ||
| + * instead of falling back to namespace-qualified type names. | ||
| */ | ||
| -function rename(type, nameTemplate) { | ||
| +function rename(program, type, nameTemplate) { | ||
| if (!nameTemplate || !type.name) | ||
| return; | ||
| const renamed = replaceTemplatedStringFromProperties(nameTemplate, type); | ||
| type.name = renamed; | ||
| + setFriendlyName(program, type, renamed); | ||
| } | ||
| function isVisibilitySubject(t) { | ||
| return (t.kind === "Model" || |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.