fix(typert): validate and mount remote contributions safely

This commit is contained in:
imccyu
2026-08-07 14:07:19 +08:00
parent 737c12935a
commit 2fe4a53557
9 changed files with 173 additions and 25 deletions

View File

@@ -2810,7 +2810,9 @@ function stringLiteralValue(node: ts.Node | undefined): string | undefined {
}
function isRemoteSegment(value: string): boolean {
return /^[A-Za-z0-9_$.-]+$/.test(value)
// Generation bootstraps workspace artifacts before dsh-type-meta is built,
// so this extraction-only copy must mirror isTypeRTRemoteSegment().
return value !== '.' && value !== '..' && /^[A-Za-z0-9_$.-]+$/.test(value)
}
function expressionName(node: ts.Expression): string | undefined {

View File

@@ -90,6 +90,7 @@ export class WorkspaceTypertGenerator {
throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`)
}
}
if (artifact.face !== 'host') return
const remoteExpected = {
types: './lib/typert.remote-client.d.ts',
default: './lib/typert.remote-client.js',

View File

@@ -274,7 +274,7 @@ export interface BoxPayload {
assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root)
})
it.each(['create#v2', 'create goal'])('rejects untransportable Remote alias %s', (alias) => {
it.each(['create#v2', 'create goal', '.', '..'])('rejects untransportable Remote alias %s', (alias) => {
const root = copyFixture()
editFile(root, 'packages/remote/src/index.ts', source => source.replace(
' @Remote\n async create(',
@@ -301,6 +301,37 @@ export interface RemainingSchema {
.toThrow('publishes Remote artifacts but has no Remote methods')
})
it('validates Remote artifacts only on the host face of a dual-face package', () => {
const root = copyFixture()
const manifestPath = join(root, 'packages/remote/package.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
dshClient?: object
exports: Record<string, unknown>
files: string[]
}
manifest.dshClient = {}
manifest.exports['./client'] = './src/client.ts'
manifest.exports['./client/typert'] = {
types: './lib/typert.client.d.ts',
default: './lib/typert.client.js',
}
manifest.files.push('lib/typert.client.js', 'lib/typert.client.d.ts')
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
writeFileSync(join(root, 'tsconfig.client.json'), `${JSON.stringify({
extends: './tsconfig.base.json',
files: [],
references: [{ path: './packages/remote' }],
}, null, 2)}\n`)
writeFileSync(join(root, 'packages/remote/src/client.ts'), `/** @typert schema */
export interface ClientMarker {
readonly ready: boolean
}
`)
expect(new WorkspaceTypertGenerator(root).generate().map(artifact => artifact.face))
.toEqual(['host', 'client'])
})
it.each([
{
name: 'missing binding',

View File

@@ -600,7 +600,7 @@ function validateCodec(codec: InvocationDescriptor['result'], subject: string):
}
function validateWireName(subject: string, value: string): void {
if (!/^[A-Za-z0-9_$.-]+$/.test(value)) {
if (value === '.' || value === '..' || !/^[A-Za-z0-9_$.-]+$/.test(value)) {
throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`)
}
}

View File

@@ -247,7 +247,7 @@ describe('TypertRegistry', () => {
})).toThrow('endpoint "goals/create" is already registered')
})
it.each(['create#v2', 'create goal'])('rejects untransportable invocation method %s', async (method) => {
it.each(['create#v2', 'create goal', '.', '..'])('rejects untransportable invocation method %s', async (method) => {
const ctx = await makeCtx()
expect(() => ctx.typert.remotes.register({
package: '@fixture/invalid-endpoint',

View File

@@ -15,7 +15,7 @@ const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
* @returns whether the value can cross the shared RPC carrier unchanged.
*/
export function isTypeRTRemoteSegment(value: string): boolean {
return TYPERT_REMOTE_SEGMENT_PATTERN.test(value)
return value !== '.' && value !== '..' && TYPERT_REMOTE_SEGMENT_PATTERN.test(value)
}
export type {

View File

@@ -164,6 +164,8 @@ describe('type-meta Remote declarations', () => {
expect(() => Remote('bad/name')).toThrow('export name')
expect(() => Remote('bad#name')).toThrow('export name')
expect(() => Remote('bad name')).toThrow('export name')
expect(() => Remote('.')).toThrow('export name')
expect(() => Remote('..')).toThrow('export name')
expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key')
expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name')