Fail closed on unsupported Rust schema constructs
This commit is contained in:
parent
d0b745950b
commit
a61ba4d365
4 changed files with 366 additions and 11 deletions
|
|
@ -1,11 +1,17 @@
|
|||
import {
|
||||
createTypeSpecLibrary,
|
||||
emitFile,
|
||||
getDiscriminatedUnion,
|
||||
getMaxItems,
|
||||
getMaxLength,
|
||||
getMaxValue,
|
||||
getMaxValueExclusive,
|
||||
getMinItems,
|
||||
getMinLength,
|
||||
getMinValue,
|
||||
getMinValueExclusive,
|
||||
getPattern,
|
||||
paramMessage,
|
||||
resolvePath,
|
||||
walkPropertiesInherited,
|
||||
} from '@typespec/compiler'
|
||||
|
|
@ -13,6 +19,18 @@ import {
|
|||
export const namespace = 'Remora.Rust'
|
||||
|
||||
const roots = ['JobSubmission', 'EventFrame', 'JobFailure']
|
||||
const $lib = createTypeSpecLibrary({
|
||||
name: '@remora/typespec-rust',
|
||||
diagnostics: {
|
||||
'unsupported-schema': {
|
||||
severity: 'error',
|
||||
messages: {
|
||||
default: paramMessage`Rust schema emission does not support ${'detail'}.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const { reportDiagnostic } = $lib
|
||||
|
||||
export async function $onEmit(context) {
|
||||
const remora = context.program.getGlobalNamespaceType().namespaces.get('RemoraAPI')
|
||||
|
|
@ -28,6 +46,9 @@ export async function $onEmit(context) {
|
|||
}
|
||||
collect(type, reachable)
|
||||
}
|
||||
if (!assertSupported(context.program, reachable)) {
|
||||
return
|
||||
}
|
||||
|
||||
const lines = [supportTypes()]
|
||||
for (const scalar of remora.scalars.values()) {
|
||||
|
|
@ -80,6 +101,91 @@ function collect(type, reachable) {
|
|||
}
|
||||
}
|
||||
|
||||
function assertSupported(program, reachable) {
|
||||
let supported = true
|
||||
for (const type of reachable) {
|
||||
if (type.kind === 'Scalar') {
|
||||
const pattern = getPattern(program, type)
|
||||
const hasLengthBounds =
|
||||
getMinLength(program, type) !== undefined ||
|
||||
getMaxLength(program, type) !== undefined
|
||||
if (pattern !== undefined && hasLengthBounds) {
|
||||
reportUnsupported(program, type, `combined pattern and length bounds on ${type.name}`)
|
||||
supported = false
|
||||
}
|
||||
if (
|
||||
getMinValueExclusive(program, type) !== undefined ||
|
||||
getMaxValueExclusive(program, type) !== undefined
|
||||
) {
|
||||
reportUnsupported(program, type, `exclusive numeric bounds on ${type.name}`)
|
||||
supported = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (type.kind !== 'Model') {
|
||||
continue
|
||||
}
|
||||
if (type.indexer && type.name !== 'Array' && type.name !== 'Record') {
|
||||
reportUnsupported(program, type, `indexed model ${type.name}`)
|
||||
supported = false
|
||||
}
|
||||
for (const property of walkPropertiesInherited(type)) {
|
||||
const name = `${type.name}.${property.name}`
|
||||
const valueType = nullableValueType(property.type) ?? property.type
|
||||
if (
|
||||
getMinValueExclusive(program, property) !== undefined ||
|
||||
getMaxValueExclusive(program, property) !== undefined
|
||||
) {
|
||||
reportUnsupported(program, property, `exclusive numeric bounds on ${name}`)
|
||||
supported = false
|
||||
}
|
||||
if (getPattern(program, property) !== undefined) {
|
||||
reportUnsupported(program, property, `property pattern on ${name}`)
|
||||
supported = false
|
||||
}
|
||||
const minLength = getMinLength(program, property)
|
||||
const maxLength = getMaxLength(program, property)
|
||||
if (
|
||||
(minLength !== undefined || maxLength !== undefined) &&
|
||||
!supportsLengthBounds(valueType)
|
||||
) {
|
||||
reportUnsupported(program, property, `string length bounds on ${name}`)
|
||||
supported = false
|
||||
}
|
||||
const minValue = getMinValue(program, property)
|
||||
const maxValue = getMaxValue(program, property)
|
||||
if (
|
||||
(minValue !== undefined || maxValue !== undefined) &&
|
||||
!supportsNumericBounds(valueType)
|
||||
) {
|
||||
reportUnsupported(program, property, `numeric bounds on ${name}`)
|
||||
supported = false
|
||||
} else if ((minValue ?? 0) < 0) {
|
||||
reportUnsupported(program, property, `signed numeric bounds on ${name}`)
|
||||
supported = false
|
||||
}
|
||||
const minItems = getMinItems(program, property)
|
||||
const maxItems = getMaxItems(program, property)
|
||||
if (
|
||||
(minItems !== undefined || maxItems !== undefined) &&
|
||||
!isArray(valueType)
|
||||
) {
|
||||
reportUnsupported(program, property, `collection bounds on ${name}`)
|
||||
supported = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return supported
|
||||
}
|
||||
|
||||
function reportUnsupported(program, target, detail) {
|
||||
reportDiagnostic(program, {
|
||||
code: 'unsupported-schema',
|
||||
target,
|
||||
format: { detail },
|
||||
})
|
||||
}
|
||||
|
||||
function emitScalar(program, scalar) {
|
||||
if (scalar.name === 'OpaqueId') {
|
||||
return `
|
||||
|
|
@ -266,6 +372,14 @@ function contextlessReport(program, diagnostics) {
|
|||
|
||||
function propertyType(program, model, property) {
|
||||
const type = property.type
|
||||
const nullable = nullableValueType(type)
|
||||
if (nullable) {
|
||||
return `Option<${constrainedPropertyType(program, model, property, nullable)}>`
|
||||
}
|
||||
return constrainedPropertyType(program, model, property, type)
|
||||
}
|
||||
|
||||
function constrainedPropertyType(program, model, property, type) {
|
||||
if (type.kind === 'Number') {
|
||||
return `ConstU64<${type.valueAsString}>`
|
||||
}
|
||||
|
|
@ -279,29 +393,65 @@ function propertyType(program, model, property) {
|
|||
return `${model.name}${pascal(property.name)}`
|
||||
}
|
||||
if (type.kind === 'Scalar') {
|
||||
if (type.name === 'url' || type.name === 'utcDateTime') {
|
||||
if (type.name === 'utcDateTime') {
|
||||
return rustType(type)
|
||||
}
|
||||
const minLength = getMinLength(program, property)
|
||||
const maxLength = getMaxLength(program, property)
|
||||
if (minLength !== undefined || maxLength !== undefined) {
|
||||
if (type.name === 'url') {
|
||||
return `BoundedUrl<${minLength ?? 0}, ${maxLength ?? '18446744073709551615'}>`
|
||||
}
|
||||
return `BoundedString<${minLength ?? 0}, ${maxLength ?? '18446744073709551615'}>`
|
||||
}
|
||||
const minValue = getMinValue(program, property)
|
||||
const maxValue = getMaxValue(program, property)
|
||||
if (minValue !== undefined || maxValue !== undefined) {
|
||||
if ((minValue ?? 0) < 0) {
|
||||
throw new Error(`unsupported signed bound on ${model.name}.${property.name}`)
|
||||
}
|
||||
return `BoundedU64<${minValue ?? 0}, ${maxValue ?? Number.MAX_SAFE_INTEGER}>`
|
||||
}
|
||||
}
|
||||
if (isArray(type)) {
|
||||
const minItems = getMinItems(program, property)
|
||||
const maxItems = getMaxItems(program, property)
|
||||
if (minItems !== undefined || maxItems !== undefined) {
|
||||
return `BoundedVec<${rustType(type.indexer.value)}, ${minItems ?? 0}, ${maxItems ?? '18446744073709551615'}>`
|
||||
}
|
||||
}
|
||||
return rustType(type)
|
||||
}
|
||||
|
||||
function nullableValueType(type) {
|
||||
if (type.kind !== 'Union') {
|
||||
return undefined
|
||||
}
|
||||
const variants = [...type.variants.values()].map((variant) => variant.type)
|
||||
const values = variants.filter(
|
||||
(variant) => variant.kind !== 'Intrinsic' || variant.name !== 'null',
|
||||
)
|
||||
return values.length === 1 && variants.length === 2 ? values[0] : undefined
|
||||
}
|
||||
|
||||
function isArray(type) {
|
||||
return type.kind === 'Model' && type.name === 'Array' && type.indexer
|
||||
}
|
||||
|
||||
function supportsLengthBounds(type) {
|
||||
return type.kind === 'Scalar' && type.name !== 'utcDateTime'
|
||||
}
|
||||
|
||||
function supportsNumericBounds(type) {
|
||||
if (type.kind !== 'Scalar') {
|
||||
return false
|
||||
}
|
||||
return ['integer', 'int64', 'uint64'].includes(type.name) ||
|
||||
inheritsFrom(type, 'integer') ||
|
||||
inheritsFrom(type, 'int64') ||
|
||||
inheritsFrom(type, 'uint64')
|
||||
}
|
||||
|
||||
function rustType(type) {
|
||||
if (type.kind === 'Model') {
|
||||
if (type.name === 'Array' && type.indexer) {
|
||||
if (isArray(type)) {
|
||||
return `Vec<${rustType(type.indexer.value)}>`
|
||||
}
|
||||
if (type.name === 'Record' && type.indexer) {
|
||||
|
|
@ -477,5 +627,48 @@ impl<'de, const MIN: usize, const MAX: usize> serde::Deserialize<'de>
|
|||
Err(serde::de::Error::custom("string length outside bounds"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct BoundedUrl<const MIN: usize, const MAX: usize>(url::Url);
|
||||
|
||||
impl<'de, const MIN: usize, const MAX: usize> serde::Deserialize<'de>
|
||||
for BoundedUrl<MIN, MAX>
|
||||
{
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = <String as serde::Deserialize>::deserialize(deserializer)?;
|
||||
if !(MIN..=MAX).contains(&value.chars().count()) {
|
||||
return Err(serde::de::Error::custom("URL length outside bounds"));
|
||||
}
|
||||
url::Url::parse(&value)
|
||||
.map(Self)
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct BoundedVec<T, const MIN: usize, const MAX: usize>(Vec<T>);
|
||||
|
||||
impl<'de, T, const MIN: usize, const MAX: usize> serde::Deserialize<'de>
|
||||
for BoundedVec<T, MIN, MAX>
|
||||
where
|
||||
T: serde::Deserialize<'de>,
|
||||
{
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = <Vec<T> as serde::Deserialize>::deserialize(deserializer)?;
|
||||
if (MIN..=MAX).contains(&value.len()) {
|
||||
Ok(Self(value))
|
||||
} else {
|
||||
Err(serde::de::Error::custom("array length outside bounds"))
|
||||
}
|
||||
}
|
||||
}`.trim()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
"default": "./index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typespec/compiler": "1.14.0"
|
||||
},
|
||||
|
|
|
|||
105
contracts/typespec-rust-emitter/test.mjs
Normal file
105
contracts/typespec-rust-emitter/test.mjs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
const compiler = resolve(root, '../typespec/node_modules/.bin/tsp')
|
||||
const emitter = resolve(root, 'index.js')
|
||||
|
||||
test('emits bounded arrays', () => {
|
||||
const result = compile(`
|
||||
namespace RemoraAPI;
|
||||
model JobSubmission {
|
||||
@maxItems(2) values: string[];
|
||||
}
|
||||
model EventFrame { kind: string; }
|
||||
model JobFailure { message: string; }
|
||||
`)
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
assert.match(
|
||||
readFileSync(resolve(result.directory, 'remora_wire.rs'), 'utf8'),
|
||||
/pub values: BoundedVec<String, 0, 2>/,
|
||||
)
|
||||
rmSync(result.directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('preserves URL and nullable numeric bounds', () => {
|
||||
const result = compile(`
|
||||
namespace RemoraAPI;
|
||||
model JobSubmission {
|
||||
@maxLength(20) url: url;
|
||||
@minValue(0) count: integer | null;
|
||||
}
|
||||
model EventFrame { kind: string; }
|
||||
model JobFailure { message: string; }
|
||||
`)
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
const generated = readFileSync(resolve(result.directory, 'remora_wire.rs'), 'utf8')
|
||||
assert.match(generated, /pub url: BoundedUrl<0, 20>/)
|
||||
assert.match(generated, /pub count: Option<BoundedU64<0, 9007199254740991>>/)
|
||||
rmSync(result.directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('rejects arbitrary indexed models', () => {
|
||||
const result = compile(`
|
||||
namespace RemoraAPI;
|
||||
model UnsupportedMap is Record<string>;
|
||||
model JobSubmission { values: UnsupportedMap; }
|
||||
model EventFrame { kind: string; }
|
||||
model JobFailure { message: string; }
|
||||
`)
|
||||
assert.notEqual(result.status, 0)
|
||||
})
|
||||
|
||||
test('rejects unsupported property patterns', () => {
|
||||
const result = compile(`
|
||||
namespace RemoraAPI;
|
||||
model JobSubmission {
|
||||
@pattern("^https://") value: string;
|
||||
}
|
||||
model EventFrame { kind: string; }
|
||||
model JobFailure { message: string; }
|
||||
`)
|
||||
assert.notEqual(result.status, 0)
|
||||
})
|
||||
|
||||
test('rejects unsupported exclusive bounds', () => {
|
||||
const result = compile(`
|
||||
namespace RemoraAPI;
|
||||
model JobSubmission {
|
||||
@minValueExclusive(0) count: int64;
|
||||
}
|
||||
model EventFrame { kind: string; }
|
||||
model JobFailure { message: string; }
|
||||
`)
|
||||
assert.notEqual(result.status, 0)
|
||||
})
|
||||
|
||||
function compile(source) {
|
||||
const directory = mkdtempSync(resolve(tmpdir(), 'remora-typespec-rust-'))
|
||||
const main = resolve(directory, 'main.tsp')
|
||||
writeFileSync(main, source)
|
||||
const result = spawnSync(
|
||||
compiler,
|
||||
[
|
||||
'compile',
|
||||
main,
|
||||
'--emit',
|
||||
emitter,
|
||||
'--output-dir',
|
||||
directory,
|
||||
'--pretty',
|
||||
'false',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
)
|
||||
const stderr = `${result.stdout}${result.stderr}`
|
||||
if (result.status !== 0) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
return { ...result, directory, stderr }
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"generate": "node generate.mjs",
|
||||
"check": "node generate.mjs --check",
|
||||
"check": "npm --prefix ../typespec-rust-emitter test && node generate.mjs --check",
|
||||
"compile": "tsp compile ."
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue