Fail closed on unsupported Rust schema constructs

This commit is contained in:
ms2k 2026-07-19 14:27:41 +09:00
commit a61ba4d365
6 changed files with 366 additions and 11 deletions

View file

@ -1,11 +1,17 @@
import { import {
createTypeSpecLibrary,
emitFile, emitFile,
getDiscriminatedUnion, getDiscriminatedUnion,
getMaxItems,
getMaxLength, getMaxLength,
getMaxValue, getMaxValue,
getMaxValueExclusive,
getMinItems,
getMinLength, getMinLength,
getMinValue, getMinValue,
getMinValueExclusive,
getPattern, getPattern,
paramMessage,
resolvePath, resolvePath,
walkPropertiesInherited, walkPropertiesInherited,
} from '@typespec/compiler' } from '@typespec/compiler'
@ -13,6 +19,18 @@ import {
export const namespace = 'Remora.Rust' export const namespace = 'Remora.Rust'
const roots = ['JobSubmission', 'EventFrame', 'JobFailure'] 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) { export async function $onEmit(context) {
const remora = context.program.getGlobalNamespaceType().namespaces.get('RemoraAPI') const remora = context.program.getGlobalNamespaceType().namespaces.get('RemoraAPI')
@ -28,6 +46,9 @@ export async function $onEmit(context) {
} }
collect(type, reachable) collect(type, reachable)
} }
if (!assertSupported(context.program, reachable)) {
return
}
const lines = [supportTypes()] const lines = [supportTypes()]
for (const scalar of remora.scalars.values()) { 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) { function emitScalar(program, scalar) {
if (scalar.name === 'OpaqueId') { if (scalar.name === 'OpaqueId') {
return ` return `
@ -266,6 +372,14 @@ function contextlessReport(program, diagnostics) {
function propertyType(program, model, property) { function propertyType(program, model, property) {
const type = property.type 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') { if (type.kind === 'Number') {
return `ConstU64<${type.valueAsString}>` return `ConstU64<${type.valueAsString}>`
} }
@ -279,29 +393,65 @@ function propertyType(program, model, property) {
return `${model.name}${pascal(property.name)}` return `${model.name}${pascal(property.name)}`
} }
if (type.kind === 'Scalar') { if (type.kind === 'Scalar') {
if (type.name === 'url' || type.name === 'utcDateTime') { if (type.name === 'utcDateTime') {
return rustType(type) return rustType(type)
} }
const minLength = getMinLength(program, property) const minLength = getMinLength(program, property)
const maxLength = getMaxLength(program, property) const maxLength = getMaxLength(program, property)
if (minLength !== undefined || maxLength !== undefined) { if (minLength !== undefined || maxLength !== undefined) {
if (type.name === 'url') {
return `BoundedUrl<${minLength ?? 0}, ${maxLength ?? '18446744073709551615'}>`
}
return `BoundedString<${minLength ?? 0}, ${maxLength ?? '18446744073709551615'}>` return `BoundedString<${minLength ?? 0}, ${maxLength ?? '18446744073709551615'}>`
} }
const minValue = getMinValue(program, property) const minValue = getMinValue(program, property)
const maxValue = getMaxValue(program, property) const maxValue = getMaxValue(program, property)
if (minValue !== undefined || maxValue !== undefined) { 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}>` 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) 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) { function rustType(type) {
if (type.kind === 'Model') { if (type.kind === 'Model') {
if (type.name === 'Array' && type.indexer) { if (isArray(type)) {
return `Vec<${rustType(type.indexer.value)}>` return `Vec<${rustType(type.indexer.value)}>`
} }
if (type.name === 'Record' && type.indexer) { 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")) 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() }`.trim()
} }

View file

@ -11,6 +11,9 @@
"default": "./index.js" "default": "./index.js"
} }
}, },
"scripts": {
"test": "node --test test.mjs"
},
"peerDependencies": { "peerDependencies": {
"@typespec/compiler": "1.14.0" "@typespec/compiler": "1.14.0"
}, },

View 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 }
}

View file

@ -4,7 +4,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"generate": "node generate.mjs", "generate": "node generate.mjs",
"check": "node generate.mjs --check", "check": "npm --prefix ../typespec-rust-emitter test && node generate.mjs --check",
"compile": "tsp compile ." "compile": "tsp compile ."
}, },
"devDependencies": { "devDependencies": {

View file

@ -98,6 +98,46 @@ impl<'de, const MIN: usize, const MAX: usize> serde::Deserialize<'de> for Bounde
} }
} }
#[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"))
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
#[serde(transparent)] #[serde(transparent)]
pub struct OpaqueId(String); pub struct OpaqueId(String);
@ -212,7 +252,7 @@ pub struct TargetOverride {
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct HttpSourceSubmission { pub struct HttpSourceSubmission {
pub url: url::Url, pub url: BoundedUrl<0, 8192>,
pub result_path: ResultPath, pub result_path: ResultPath,
pub credential_id: Option<CredentialId>, pub credential_id: Option<CredentialId>,
} }
@ -231,8 +271,8 @@ pub struct ObjectStoreSourceSubmission {
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct HuggingFaceSelection { pub struct HuggingFaceSelection {
pub paths: Option<Vec<ResultPath>>, pub paths: Option<BoundedVec<ResultPath, 0, 10000>>,
pub prefixes: Option<Vec<ResultPath>>, pub prefixes: Option<BoundedVec<ResultPath, 0, 10000>>,
} }
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
@ -324,9 +364,9 @@ pub struct EventProgress {
pub attempt_id: OpaqueId, pub attempt_id: OpaqueId,
pub fence: BoundedU64<1, 9007199254740991>, pub fence: BoundedU64<1, 9007199254740991>,
pub completed_bytes: BoundedU64<0, 9007199254740991>, pub completed_bytes: BoundedU64<0, 9007199254740991>,
pub total_bytes: Option<Option<i64>>, pub total_bytes: Option<Option<BoundedU64<0, 9007199254740991>>>,
pub completed_members: BoundedU64<0, 9007199254740991>, pub completed_members: BoundedU64<0, 9007199254740991>,
pub total_members: Option<Option<i64>>, pub total_members: Option<Option<BoundedU64<0, 9007199254740991>>>,
} }
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]

View file

@ -50,6 +50,20 @@ fn generated_roots_reject_governance_regressions() {
"occurred_at": "2026-07-15T00:00:00Z", "occurred_at": "2026-07-15T00:00:00Z",
"unexpected": true "unexpected": true
})); }));
let paths: Vec<Value> = (0..=10_000)
.map(|index| Value::String(format!("file-{index}")))
.collect();
assert_invalid::<JobSubmission>(json!({
"source": {
"kind": "hugging_face",
"repository_type": "model",
"repository_id": "owner/repository",
"revision": "main",
"selection": {
"paths": paths
}
}
}));
} }
fn assert_all<T: DeserializeOwned>(values: &Value) { fn assert_all<T: DeserializeOwned>(values: &Value) {