feat(runner): validate structured question responses (#12420)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Rust runner now owns a bounded ACPX session and a fail-closed
turn lifecycle
> - Provider questions pause a turn and must return structured answers
to the same persisted question set
> - JSON Schema validates the wire shape, but it cannot validate
identifiers and constraints across two documents
> - Unknown questions, invalid choices, and malformed custom answers
must fail before any provider receives them
> - This pull request adds only the package-local response validator and
tests
> - The benefit is a small trust boundary that later request-resolution
code can use without changing production selection

## Linked Issues or Issue Description

Refs #12419

## What Changed

- Validate `paperclip.question_response.v1` against its versioned JSON
Schema.
- Bound serialized responses to 768 KiB before validation.
- Require answer identifiers to match the exact persisted question set.
- Require answers for required questions and reject unknown question
identifiers.
- Enforce text, single-select, and multi-select answer modes.
- Match the existing TypeScript numeric syntax, including decimal,
exponent, hexadecimal, octal, and binary input.
- Match ECMAScript trimming exactly, including BOM whitespace while
rejecting Unicode NEL rather than inheriting Rust-specific whitespace
behavior.
- Enforce known options, custom-answer policy, text length, pattern, and
numeric constraints.
- Validate duplicate option IDs, inverted bounds, and dynamic patterns
before answer lookup so malformed optional questions fail closed even
when unanswered.
- Match JavaScript UTF-16 code-unit length semantics for text
constraints and the 100,000-unit response-field bound.
- Preserve the public optional `recommended` question-option field in
the versioned schema, generated schema bundle, and Rust validation path.
- Return typed validation errors for malformed inputs without panics.
- Export the validator from the Rust runner core.
- Add table-driven tests for valid, mismatched, malformed, oversized,
and numeric-boundary responses.
- Document the package-local structured-response boundary.
- Add `num-bigint` 0.4 and `num-traits` 0.2 as direct runner-core
dependencies for exact arbitrary-length radix parsing and one-step
JavaScript Number rounding; update only the package-local runner Cargo
lockfile.
- Do not change the repository PNPM lockfile, workflows, runnerd
selection, server behavior, UI, or migrations.

## Verification

- Replay base: `9a9fdf06ee4142f77427db30efccc4c43056f64b` (`master`
after #12419 merged).
- Exact replay head: `fad92b3fb348b66ddb10dde44b7b060e55c4fe96`.
- Stable patch ID: `4d6ffbd519dd081f7ea530977cd965bd4569fc75`; this is
the prepared two-commit delta plus the focused cross-language parity fix
found during replay review.
- The exact delta is 10 files, 712 additions, and 2 deletions, all in
`packages/paperclip-runner`.
- The package-local `packages/paperclip-runner/runner/Cargo.lock`
records the two direct runner-core dependencies; their already-resolved
versions and checksums are unchanged.
- The question-set schema source, generated TypeScript schema bundle,
and protocol manifest hash are updated together; the schema SHA-256 is
`42b5441a3d388851dacb6e4500dfd4a17d878eded2e724228078b647e7440d3f`.
- GitHub Actions run `33366812025`, attempt 2: **PASSED** on the exact
replay head (23/23 jobs passed; a failed-job-only retry cleared one
unrelated ACPX runtime-host timeout).
- Greptile: **5/5** on the exact replay head with zero unresolved review
threads; Superagent, Socket, Snyk, and contributor-trust checks also
passed.
- No local test result is claimed. GitHub Actions is the authoritative
verification environment for this replayed revision.

## Risks

- The validator compiles the embedded response schema for each
submission. Responses are user-paced and bounded, so this keeps the
slice simple without affecting a hot event path.
- The persisted question set is the source of truth for identifiers and
constraints. A malformed persisted set fails closed.
- Numeric input follows the existing structured-question contract,
including JavaScript-prefixed syntax. Optional whitespace-only answers
are rejected instead of being treated as an omitted value.
- Error messages identify the invalid field but do not include answer
text.
- No production path invokes this validator in this pull request.
Request resolution remains the next slice.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with GPT-5.6, agentic reasoning, tool use, and code
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta 2026-08-31 02:19:31 -05:00 committed by GitHub
parent 9a9fdf06ee
commit 3aa2065d08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 712 additions and 2 deletions

View File

@ -103,6 +103,10 @@ then resumes the same verified persistent identity in a fresh generation. This
prevents a late session-lifetime MCP callback from inheriting the next turn's
event authority.
The Rust question-response validator checks the versioned response envelope
against the exact persisted question IDs, answer modes, options, required
answers, custom-answer policy, and text constraints before provider delivery.
Run the complete contract gate with:
```sh

View File

@ -65,7 +65,7 @@
{
"path": "schemas/question-set.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json",
"sha256": "3bda40311ae3153bbf9d4a8c59f716680e17d711a4db3570193963918359751c"
"sha256": "42b5441a3d388851dacb6e4500dfd4a17d878eded2e724228078b647e7440d3f"
},
{
"path": "schemas/request.schema.json",

View File

@ -23,7 +23,8 @@
"properties": {
"id": { "type": "string", "minLength": 1, "maxLength": 160 },
"label": { "type": "string", "minLength": 1, "maxLength": 1000 },
"description": { "type": "string", "maxLength": 4000 }
"description": { "type": "string", "maxLength": 4000 },
"recommended": { "type": "boolean" }
},
"additionalProperties": false
},

View File

@ -663,6 +663,8 @@ dependencies = [
"getrandom 0.3.4",
"hmac",
"jsonschema",
"num-bigint",
"num-traits",
"serde",
"serde_json",
"sha2",

View File

@ -13,6 +13,8 @@ aes-gcm = "0.10"
getrandom = "0.3"
hmac = "0.12"
jsonschema = { version = "0.50", default-features = false }
num-bigint = "0.4"
num-traits = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10"

View File

@ -10,6 +10,8 @@ aes-gcm.workspace = true
getrandom.workspace = true
hmac.workspace = true
jsonschema.workspace = true
num-bigint.workspace = true
num-traits.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true

View File

@ -14,6 +14,7 @@ pub mod process_supervisor;
pub mod provider_backend;
pub mod provider_bridge;
pub mod provider_events;
pub mod question_response;
pub mod replay;
use std::error::Error;

View File

@ -0,0 +1,375 @@
use std::collections::BTreeSet;
use num_bigint::BigUint;
use num_traits::ToPrimitive;
use serde_json::{json, Value};
use crate::local_runner::LocalRunnerError;
const MAX_QUESTION_RESPONSE_BYTES: usize = 768 * 1024;
const MAX_QUESTION_ANSWER_CODE_UNITS: usize = 100_000;
/// Validates a provider-neutral response against the exact persisted question
/// set that produced it. The JSON Schema owns the wire shape; this function
/// adds cross-document identifiers, modes, required answers, and constraints.
pub fn validate_question_response(
question_set: &Value,
response: &Value,
) -> Result<(), LocalRunnerError> {
let bytes = serde_json::to_vec(response).map_err(|error| {
LocalRunnerError::invalid(format!("question response is not serializable: {error}"))
})?;
if bytes.len() > MAX_QUESTION_RESPONSE_BYTES {
return Err(LocalRunnerError::invalid(
"question response exceeds its bounded transport contract",
));
}
let question_set_schema: Value = serde_json::from_str(include_str!(
"../../../../protocol/schemas/question-set.schema.json"
))
.map_err(|_| LocalRunnerError::invalid("embedded question-set schema is invalid"))?;
let question_set_validator = jsonschema::validator_for(&question_set_schema)
.map_err(|_| LocalRunnerError::invalid("embedded question-set schema cannot compile"))?;
if !question_set_validator.is_valid(question_set) {
return Err(LocalRunnerError::invalid(
"persisted question set failed the Paperclip question-set schema",
));
}
let schema: Value = serde_json::from_str(include_str!(
"../../../../protocol/schemas/question-response.schema.json"
))
.map_err(|_| LocalRunnerError::invalid("embedded question-response schema is invalid"))?;
let validator = jsonschema::validator_for(&schema).map_err(|_| {
LocalRunnerError::invalid("embedded question-response schema cannot compile")
})?;
if !validator.is_valid(response) {
return Err(LocalRunnerError::invalid(
"response failed the Paperclip question-response schema",
));
}
let questions = question_set
.get("questions")
.and_then(Value::as_array)
.ok_or_else(|| LocalRunnerError::invalid("persisted question set is malformed"))?;
let answers = response
.get("answers")
.and_then(Value::as_object)
.ok_or_else(|| LocalRunnerError::invalid("question response answers are malformed"))?;
let question_ids = questions
.iter()
.filter_map(|question| question.get("id").and_then(Value::as_str))
.collect::<BTreeSet<_>>();
if question_ids.len() != questions.len() {
return Err(LocalRunnerError::invalid(
"persisted question set has invalid or duplicate ids",
));
}
for question in questions {
validate_persisted_question(question)?;
}
if answers.keys().any(|id| !question_ids.contains(id.as_str())) {
return Err(LocalRunnerError::invalid(
"question response contains an unknown question id",
));
}
for question in questions {
let question_id = question
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| LocalRunnerError::invalid("persisted question id is malformed"))?;
validate_answer(question, answers.get(question_id))?;
}
Ok(())
}
fn validate_answer(question: &Value, answer: Option<&Value>) -> Result<(), LocalRunnerError> {
let question_id = question
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| LocalRunnerError::invalid("persisted question id is malformed"))?;
let required = question
.get("required")
.and_then(Value::as_bool)
.ok_or_else(|| LocalRunnerError::invalid("persisted question requirement is malformed"))?;
let Some(answer) = answer else {
return if required {
Err(LocalRunnerError::invalid(format!(
"question response is missing required answer {question_id}"
)))
} else {
Ok(())
};
};
let answer = answer
.as_object()
.ok_or_else(|| LocalRunnerError::invalid("question response answer is malformed"))?;
let selected = answer
.get("selectedOptionIds")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.map(|value| {
value.as_str().ok_or_else(|| {
LocalRunnerError::invalid("question response option id is malformed")
})
})
.collect::<Result<Vec<_>, _>>()
})
.transpose()?;
let text = answer.get("text").and_then(Value::as_str);
let custom = answer.get("customText").and_then(Value::as_str);
if [text, custom]
.into_iter()
.flatten()
.any(|value| javascript_string_length(value) > MAX_QUESTION_ANSWER_CODE_UNITS)
{
return Err(LocalRunnerError::invalid(format!(
"question response answer {question_id} exceeds its text bound"
)));
}
let has_value = selected.as_ref().is_some_and(|values| !values.is_empty())
|| text.is_some_and(|value| !value.trim_matches(is_ecmascript_whitespace).is_empty())
|| custom.is_some_and(|value| !value.trim_matches(is_ecmascript_whitespace).is_empty());
if !has_value {
return Err(LocalRunnerError::invalid(format!(
"question response answer {question_id} is {}",
if required { "required" } else { "empty" }
)));
}
match question.get("answerMode").and_then(Value::as_str) {
Some("text") => {
if selected.as_ref().is_some_and(|values| !values.is_empty()) || custom.is_some() {
return Err(LocalRunnerError::invalid(format!(
"text answer {question_id} cannot contain selection fields"
)));
}
if let Some(text) = text {
validate_text_constraints(question_id, question, text)?;
}
}
Some("single_select" | "multi_select") => {
if text.is_some() {
return Err(LocalRunnerError::invalid(format!(
"select answer {question_id} cannot contain text"
)));
}
let allowed = question
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|option| option.get("id").and_then(Value::as_str))
.collect::<BTreeSet<_>>();
if selected
.iter()
.flatten()
.any(|option_id| !allowed.contains(option_id))
{
return Err(LocalRunnerError::invalid(format!(
"select answer {question_id} contains an unknown option"
)));
}
if question.get("answerMode").and_then(Value::as_str) == Some("single_select")
&& selected.as_ref().is_some_and(|values| values.len() > 1)
{
return Err(LocalRunnerError::invalid(format!(
"single-select answer {question_id} chose multiple options"
)));
}
if custom.is_some()
&& question
.pointer("/customAnswer/enabled")
.and_then(Value::as_bool)
!= Some(true)
{
return Err(LocalRunnerError::invalid(format!(
"select answer {question_id} does not allow custom text"
)));
}
if question.get("answerMode").and_then(Value::as_str) == Some("single_select")
&& selected.as_ref().is_some_and(|values| !values.is_empty())
&& custom
.is_some_and(|value| !value.trim_matches(is_ecmascript_whitespace).is_empty())
{
return Err(LocalRunnerError::invalid(format!(
"single-select answer {question_id} cannot combine an option and custom text"
)));
}
if let Some(custom) = custom {
validate_text_constraints(question_id, question, custom)?;
}
}
_ => {
return Err(LocalRunnerError::invalid(
"persisted question answer mode is malformed",
))
}
}
Ok(())
}
fn validate_text_constraints(
question_id: &str,
question: &Value,
text: &str,
) -> Result<(), LocalRunnerError> {
let Some(validation) = question.get("textValidation") else {
return Ok(());
};
let length = javascript_string_length(text) as u64;
if validation
.get("minLength")
.and_then(Value::as_u64)
.is_some_and(|minimum| length < minimum)
|| validation
.get("maxLength")
.and_then(Value::as_u64)
.is_some_and(|maximum| length > maximum)
{
return Err(LocalRunnerError::invalid(format!(
"answer {question_id} violates its text length constraint"
)));
}
if let Some(pattern) = validation.get("pattern").and_then(Value::as_str) {
let pattern_schema = json!({"type":"string","pattern":pattern});
let validator = jsonschema::validator_for(&pattern_schema)
.map_err(|_| LocalRunnerError::invalid("persisted question pattern cannot compile"))?;
if !validator.is_valid(&Value::String(text.to_owned())) {
return Err(LocalRunnerError::invalid(format!(
"answer {question_id} does not match its required pattern"
)));
}
}
if matches!(
validation.get("inputType").and_then(Value::as_str),
Some("number" | "integer")
) {
let number = parse_javascript_number(text).ok_or_else(|| {
LocalRunnerError::invalid(format!("answer {question_id} must be numeric"))
})?;
if !number.is_finite()
|| (validation.get("inputType").and_then(Value::as_str) == Some("integer")
&& number.fract() != 0.0)
|| validation
.get("minimum")
.and_then(Value::as_f64)
.is_some_and(|minimum| number < minimum)
|| validation
.get("maximum")
.and_then(Value::as_f64)
.is_some_and(|maximum| number > maximum)
{
return Err(LocalRunnerError::invalid(format!(
"answer {question_id} violates its numeric constraint"
)));
}
}
Ok(())
}
fn validate_persisted_question(question: &Value) -> Result<(), LocalRunnerError> {
let question_id = question
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| LocalRunnerError::invalid("persisted question id is malformed"))?;
if let Some(options) = question.get("options").and_then(Value::as_array) {
let option_ids = options
.iter()
.filter_map(|option| option.get("id").and_then(Value::as_str))
.collect::<BTreeSet<_>>();
if option_ids.len() != options.len() {
return Err(LocalRunnerError::invalid(format!(
"persisted question {question_id} has invalid or duplicate option ids"
)));
}
}
let Some(validation) = question.get("textValidation") else {
return Ok(());
};
if validation
.get("minLength")
.and_then(Value::as_u64)
.zip(validation.get("maxLength").and_then(Value::as_u64))
.is_some_and(|(minimum, maximum)| minimum > maximum)
{
return Err(LocalRunnerError::invalid(format!(
"persisted question {question_id} has inverted text length bounds"
)));
}
if validation
.get("minimum")
.and_then(Value::as_f64)
.zip(validation.get("maximum").and_then(Value::as_f64))
.is_some_and(|(minimum, maximum)| minimum > maximum)
{
return Err(LocalRunnerError::invalid(format!(
"persisted question {question_id} has inverted numeric bounds"
)));
}
if let Some(pattern) = validation.get("pattern").and_then(Value::as_str) {
let pattern_schema = json!({"type":"string","pattern":pattern});
jsonschema::validator_for(&pattern_schema).map_err(|_| {
LocalRunnerError::invalid(format!(
"persisted question {question_id} has an invalid text pattern"
))
})?;
}
Ok(())
}
fn javascript_string_length(value: &str) -> usize {
value.encode_utf16().count()
}
fn parse_javascript_number(value: &str) -> Option<f64> {
let value = value.trim_matches(is_ecmascript_whitespace);
if value.is_empty() {
return Some(0.0);
}
for (prefixes, radix) in [(["0x", "0X"], 16), (["0o", "0O"], 8), (["0b", "0B"], 2)] {
if let Some(digits) = prefixes
.iter()
.find_map(|prefix| value.strip_prefix(prefix))
{
if digits.is_empty() {
return None;
}
let digits = digits
.chars()
.map(|character| character.to_digit(radix).map(|digit| digit as u8))
.collect::<Option<Vec<_>>>()?;
// JavaScript parses the entire prefixed integer exactly and rounds
// once when converting it to Number. BigUint's f64 conversion uses
// round-to-odd before the final nearest-ties-to-even conversion,
// preserving that behavior without intermediate digit rounding.
return BigUint::from_radix_be(&digits, radix).and_then(|number| number.to_f64());
}
}
value.parse::<f64>().ok()
}
fn is_ecmascript_whitespace(character: char) -> bool {
matches!(
character,
'\u{0009}'
| '\u{000a}'
| '\u{000b}'
| '\u{000c}'
| '\u{000d}'
| '\u{0020}'
| '\u{00a0}'
| '\u{1680}'
| '\u{2000}'
..='\u{200a}'
| '\u{2028}'
| '\u{2029}'
| '\u{202f}'
| '\u{205f}'
| '\u{3000}'
| '\u{feff}'
)
}

View File

@ -0,0 +1,320 @@
use num_bigint::BigUint;
use paperclip_runner_core::question_response::validate_question_response;
use serde_json::{json, Value};
fn question_set() -> Value {
json!({
"schema":"paperclip.question_set.v1",
"questions":[
{
"id":"target",
"prompt":"Which target?",
"required":true,
"answerMode":"single_select",
"options":[{"id":"first","label":"First","recommended":true},{"id":"second","label":"Second"}],
"customAnswer":{"enabled":true}
},
{
"id":"regions",
"prompt":"Which regions?",
"required":false,
"answerMode":"multi_select",
"options":[{"id":"east","label":"East"},{"id":"west","label":"West"}]
},
{
"id":"notes",
"prompt":"Add notes",
"required":true,
"answerMode":"text",
"textValidation":{"minLength":2,"maxLength":5,"pattern":"^[A-Z]+$"}
},
{
"id":"count",
"prompt":"How many?",
"required":false,
"answerMode":"text",
"textValidation":{"inputType":"integer","minimum":1,"maximum":3}
}
]
})
}
fn valid_response() -> Value {
json!({
"schema":"paperclip.question_response.v1",
"answers":{
"target":{"selectedOptionIds":["first"]},
"regions":{"selectedOptionIds":["east","west"]},
"notes":{"text":"YES"},
"count":{"text":"2"}
}
})
}
#[test]
fn accepts_answers_that_match_the_exact_question_set() {
validate_question_response(&question_set(), &valid_response()).unwrap();
let mut custom = valid_response();
custom["answers"]["target"] = json!({"customText":"another"});
validate_question_response(&question_set(), &custom).unwrap();
let mut javascript_numeric_syntax = valid_response();
javascript_numeric_syntax["answers"]["count"] = json!({"text":"\u{feff}0x2\u{feff}"});
validate_question_response(&question_set(), &javascript_numeric_syntax).unwrap();
let mut empty_custom_with_selection = valid_response();
empty_custom_with_selection["answers"]["target"] =
json!({"selectedOptionIds":["first"],"customText":"\u{feff}"});
validate_question_response(&question_set(), &empty_custom_with_selection).unwrap();
}
#[test]
fn rounds_large_prefixed_integers_like_javascript_number() {
let mut bounded_set = question_set();
bounded_set["questions"][3]["textValidation"]["minimum"] = json!(1_152_921_504_606_847_200_u64);
bounded_set["questions"][3]["textValidation"]["maximum"] = json!(1_152_921_504_606_847_200_u64);
for value in [
"0x1000000000000081",
"0o100000000000000000201",
"0b1000000000000000000000000000000000000000000000000000010000001",
] {
let mut response = valid_response();
response["answers"]["count"] = json!({"text":value});
validate_question_response(&bounded_set, &response)
.unwrap_or_else(|error| panic!("{value} should match JavaScript Number: {error}"));
}
}
#[test]
fn matches_javascript_radix_overflow_midpoint() {
let mut unbounded_set = question_set();
let validation = unbounded_set["questions"][3]["textValidation"]
.as_object_mut()
.unwrap();
validation.remove("minimum");
validation.remove("maximum");
let overflow = BigUint::from(1_u8) << 1024_usize;
// Number.MAX_VALUE is 2^1024 - 2^971. The midpoint to the
// non-representable 2^1024 sentinel is 2^1024 - 2^970. At the midpoint,
// nearest-ties-to-even selects the sentinel, which JavaScript exposes as
// Infinity; the immediately preceding integer still rounds to MAX_VALUE.
let infinite_midpoint = &overflow - (BigUint::from(1_u8) << 970_usize);
let largest_finite = &infinite_midpoint - BigUint::from(1_u8);
let below_overflow_but_infinite = &overflow - BigUint::from(1_u8);
for (prefix, radix) in [("0x", 16), ("0o", 8), ("0b", 2)] {
let mut response = valid_response();
response["answers"]["count"] =
json!({"text":format!("{prefix}{}", largest_finite.to_str_radix(radix))});
validate_question_response(&unbounded_set, &response).unwrap_or_else(|error| {
panic!("the largest finite-rounding base-{radix} integer was rejected: {error}")
});
for value in [&infinite_midpoint, &below_overflow_but_infinite, &overflow] {
response["answers"]["count"] =
json!({"text":format!("{prefix}{}", value.to_str_radix(radix))});
assert!(
validate_question_response(&unbounded_set, &response).is_err(),
"base-{radix} value that rounds to Infinity was accepted"
);
}
}
}
#[test]
fn treats_ecmascript_bom_whitespace_as_an_empty_required_answer() {
let mut unconstrained_set = question_set();
unconstrained_set["questions"][2]
.as_object_mut()
.unwrap()
.remove("textValidation");
let mut bom_text = valid_response();
bom_text["answers"]["notes"] = json!({"text":"\u{feff}"});
assert!(validate_question_response(&unconstrained_set, &bom_text).is_err());
let mut bom_custom = valid_response();
bom_custom["answers"]["target"] = json!({"customText":"\u{feff}"});
assert!(validate_question_response(&question_set(), &bom_custom).is_err());
}
#[test]
fn rejects_present_empty_optional_answers() {
let mut optional_text_set = question_set();
optional_text_set["questions"][2]["required"] = json!(false);
optional_text_set["questions"][2]
.as_object_mut()
.unwrap()
.remove("textValidation");
let mut empty_text = valid_response();
empty_text["answers"]["notes"] = json!({"text":"\u{feff}\u{2009}"});
assert!(validate_question_response(&optional_text_set, &empty_text).is_err());
let mut optional_custom_set = question_set();
optional_custom_set["questions"][0]["required"] = json!(false);
let mut empty_custom = valid_response();
empty_custom["answers"]["target"] = json!({"customText":"\u{feff}\u{2009}"});
assert!(validate_question_response(&optional_custom_set, &empty_custom).is_err());
}
#[test]
fn rejects_malformed_persisted_text_constraints() {
let malformed_constraints = [
json!("not-an-object"),
json!({"minLength":"2"}),
json!({"maxLength":2.5}),
json!({"pattern":false}),
json!({"inputType":false}),
json!({"minimum":"1"}),
json!({"maximum":{}}),
];
for constraint in malformed_constraints {
let mut malformed_set = question_set();
malformed_set["questions"][2]["textValidation"] = constraint.clone();
assert!(
validate_question_response(&malformed_set, &valid_response()).is_err(),
"malformed text constraint unexpectedly passed: {constraint}"
);
}
}
#[test]
fn rejects_semantically_malformed_unanswered_questions() {
let cases = [
("duplicate option ids", {
let mut value = question_set();
value["questions"][0]["options"][1]["id"] = json!("first");
value
}),
("inverted text length bounds", {
let mut value = question_set();
value["questions"][2]["textValidation"]["minLength"] = json!(6);
value
}),
("inverted numeric bounds", {
let mut value = question_set();
value["questions"][3]["textValidation"]["minimum"] = json!(4);
value
}),
("invalid text pattern", {
let mut value = question_set();
value["questions"][2]["textValidation"]["pattern"] = json!("[");
value
}),
];
let response = json!({"schema":"paperclip.question_response.v1","answers":{}});
for (label, mut set) in cases {
for question in set["questions"].as_array_mut().unwrap() {
question["required"] = json!(false);
}
assert!(
validate_question_response(&set, &response).is_err(),
"{label} unexpectedly passed without an answer"
);
}
}
#[test]
fn counts_text_lengths_like_javascript_utf16() {
let mut bounded_set = question_set();
bounded_set["questions"][2]["textValidation"] = json!({"minLength":2,"maxLength":2});
let mut response = valid_response();
response["answers"]["notes"] = json!({"text":"😀"});
validate_question_response(&bounded_set, &response).unwrap();
bounded_set["questions"][2]["textValidation"] = json!({"maxLength":1});
assert!(validate_question_response(&bounded_set, &response).is_err());
}
#[test]
fn rejects_cross_document_and_answer_mode_mismatches() {
let cases = [
("missing required", {
let mut value = valid_response();
value["answers"].as_object_mut().unwrap().remove("target");
value
}),
("unknown question", {
let mut value = valid_response();
value["answers"]["other"] = json!({"text":"x"});
value
}),
("unknown option", {
let mut value = valid_response();
value["answers"]["target"] = json!({"selectedOptionIds":["other"]});
value
}),
("multiple single selections", {
let mut value = valid_response();
value["answers"]["target"] = json!({"selectedOptionIds":["first","second"]});
value
}),
("combined single selection", {
let mut value = valid_response();
value["answers"]["target"] =
json!({"selectedOptionIds":["first"],"customText":"other"});
value
}),
("selection on text", {
let mut value = valid_response();
value["answers"]["notes"] = json!({"selectedOptionIds":["first"]});
value
}),
("pattern mismatch", {
let mut value = valid_response();
value["answers"]["notes"] = json!({"text":"no"});
value
}),
("numeric mismatch", {
let mut value = valid_response();
value["answers"]["count"] = json!({"text":"4"});
value
}),
("invalid numeric syntax", {
let mut value = valid_response();
value["answers"]["count"] = json!({"text":"0xGG"});
value
}),
("non-ECMAScript numeric whitespace", {
let mut value = valid_response();
value["answers"]["count"] = json!({"text":"\u{0085}2\u{0085}"});
value
}),
];
for (label, response) in cases {
assert!(
validate_question_response(&question_set(), &response).is_err(),
"{label} unexpectedly passed"
);
}
}
#[test]
fn rejects_malformed_or_oversized_response_envelopes() {
assert!(validate_question_response(
&question_set(),
&json!({"schema":"paperclip.question_response.v2","answers":{}})
)
.is_err());
assert!(validate_question_response(
&question_set(),
&json!({
"schema":"paperclip.question_response.v1",
"answers":{"notes":{"text":"x".repeat(800_000)}}
})
)
.is_err());
let mut unconstrained_set = question_set();
unconstrained_set["questions"][2]
.as_object_mut()
.unwrap()
.remove("textValidation");
let mut code_unit_bounded = valid_response();
code_unit_bounded["answers"]["notes"] = json!({"text":"😀".repeat(50_000)});
validate_question_response(&unconstrained_set, &code_unit_bounded).unwrap();
code_unit_bounded["answers"]["notes"] = json!({"text":"😀".repeat(50_001)});
assert!(validate_question_response(&unconstrained_set, &code_unit_bounded).is_err());
}

View File

@ -2439,6 +2439,9 @@ export const questionSetSchema = {
"description": {
"type": "string",
"maxLength": 4000
},
"recommended": {
"type": "boolean"
}
},
"additionalProperties": false