Skip to main content

ledgence_orchestration_api/
submission.rs

1//! Strict task submission decoding and semantic idempotency comparison.
2//!
3//! Use [`SubmitTask::decode`] on incoming JSON bytes. Deserializing an already
4//! constructed JSON value cannot detect duplicate keys or recover rounded
5//! out-of-range integer tokens. This module rejects both at the byte boundary.
6
7use crate::RetryPolicy;
8use ledgence_worker_api::{Error, ErrorKind, ProgramRef, Result, decode_json, validate_wire_value};
9use serde::{
10    Deserialize, Deserializer, Serialize,
11    de::{self, DeserializeOwned, MapAccess, SeqAccess, Visitor},
12};
13use serde_json::{Map, Number, Value};
14use std::{fmt, io};
15
16/// Maximum compact JSON encoding of the application-owned submission data.
17///
18/// This includes JSON quotes and escapes; insignificant incoming whitespace
19/// does not count toward this limit. The complete incoming request is bounded
20/// separately by [`SUBMISSION_MAX_BYTES`].
21pub const SUBMISSION_DATA_MAX_BYTES: usize = ledgence_worker_api::APPLICATION_INPUT_MAX_BYTES;
22
23/// Maximum incoming request bytes and normalized submission bytes (2 MiB).
24pub const SUBMISSION_MAX_BYTES: usize = 2 * 1024 * 1024;
25
26/// A request to create one logical task, before assigning run or attempt IDs.
27///
28/// `data` may be any JSON value, with at most 64 nested arrays/objects and a
29/// compact serialized size of 1 MiB. Strings, including escaped U+0000, remain
30/// application-owned. Integer tokens must fit i64/u64; fractional/exponent
31/// tokens use finite binary64, as in the worker protocol.
32///
33/// Tenant, namespace, and queue must contain 1–128 UTF-8 bytes and no Unicode
34/// control characters or Unicode noncharacters, matching the platform identifier
35/// rules. Optional correlation contains at most 512 UTF-8 bytes and no control
36/// characters. Program identifiers use [`ProgramRef::validate`].
37/// The default retry policy is supplied by [`RetryPolicy::default`]; omitted
38/// attempt timeout is 300,000 ms and must be between 60,000 and 86,400,000 ms.
39/// Unknown submission fields are rejected.
40///
41/// Use [`Self::semantically_matches`] rather than ordinary JSON equality for
42/// idempotency: integer and floating values, and positive/negative floating
43/// zero, have intentionally different normalized representations.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct SubmitTask {
47    pub tenant_id: String,
48    pub namespace: String,
49    pub queue: String,
50    pub program: ProgramRef,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub correlation_key: Option<String>,
53    pub data: Value,
54    #[serde(default)]
55    pub retry_policy: RetryPolicy,
56    #[serde(default = "default_attempt_timeout_ms")]
57    pub attempt_timeout_ms: u64,
58}
59
60const fn default_attempt_timeout_ms() -> u64 {
61    300_000
62}
63
64impl SubmitTask {
65    /// Validate a constructed submission, including bounded JSON encoding.
66    ///
67    /// This cannot detect duplicate keys or preexisting numeric rounding in a
68    /// `Value`; incoming transport bytes must pass through [`Self::decode`].
69    pub fn validate(&self) -> Result<()> {
70        for (name, value) in [
71            ("tenant_id", self.tenant_id.as_str()),
72            ("namespace", self.namespace.as_str()),
73            ("queue", self.queue.as_str()),
74        ] {
75            if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) {
76                return Err(invalid(format!(
77                    "{name} must contain 1–128 UTF-8 bytes and no control characters"
78                )));
79            }
80        }
81        if self
82            .tenant_id
83            .chars()
84            .chain(self.namespace.chars())
85            .chain(self.queue.chars())
86            .any(|character| {
87                let code = u32::from(character);
88                (0xfdd0..=0xfdef).contains(&code) || code & 0xffff >= 0xfffe
89            })
90        {
91            return Err(invalid(
92                "tenant_id, namespace, and queue must not contain Unicode noncharacters",
93            ));
94        }
95        if let Some(key) = &self.correlation_key
96            && (key.len() > 512 || key.chars().any(char::is_control))
97        {
98            return Err(invalid(
99                "correlation_key must contain at most 512 UTF-8 bytes and no control characters",
100            ));
101        }
102        self.program.validate()?;
103        self.retry_policy.validate()?;
104        if !(60_000..=86_400_000).contains(&self.attempt_timeout_ms) {
105            return Err(invalid(
106                "attempt_timeout_ms must be between 60000 and 86400000",
107            ));
108        }
109        validate_wire_value(&self.data)
110            .map_err(|_| invalid("submission data must not exceed 64 nested arrays or objects"))?;
111        check_encoded_size(&self.data, SUBMISSION_DATA_MAX_BYTES, "submission data")?;
112        check_encoded_size(self, SUBMISSION_MAX_BYTES, "submission")
113    }
114
115    /// Decode a complete JSON request without duplicate-key collapse or
116    /// out-of-range integer rounding, then validate its fields and limits.
117    ///
118    /// Duplicate keys are rejected at every depth, including inside user data;
119    /// keys with equivalent JSON escape spellings are also duplicates. Trailing
120    /// bytes, invalid UTF-8, nonfinite numbers, and unknown fields are rejected.
121    pub fn decode(bytes: &[u8]) -> Result<Self> {
122        let submission: Self = decode_unique_json(bytes, SUBMISSION_MAX_BYTES)?;
123        submission.validate()?;
124        Ok(submission)
125    }
126
127    /// Return a deterministic JSON encoding for semantic request comparison.
128    ///
129    /// Object keys are sorted recursively. Array order and string values remain
130    /// significant. Missing defaults normalize to their explicit values, and a
131    /// missing or null correlation key normalizes to absence. Integer `1` and
132    /// float `1.0` differ; equivalent binary64 spellings such as `1e0` and `1.0`
133    /// match. Positive and negative floating zero differ. The existing parser
134    /// treats the spelling `-0` as floating negative zero, matching `-0.0`.
135    ///
136    /// This is Ledgence's comparison encoding, not an implementation of RFC
137    /// 8785 or arbitrary-precision numeric canonicalization.
138    pub fn canonical_bytes(&self) -> Result<Vec<u8>> {
139        self.validate()?;
140        let value = serde_json::to_value(self)
141            .map_err(|error| invalid(format!("cannot encode submission: {error}")))?;
142        canonical_owned_json_bytes(value)
143    }
144
145    /// Compare validated submissions using [`Self::canonical_bytes`].
146    ///
147    /// Every field in this type participates, including scope, correlation,
148    /// immutable program identity, and the normalized execution policy.
149    pub fn semantically_matches(&self, other: &Self) -> Result<bool> {
150        Ok(self.canonical_bytes()? == other.canonical_bytes()?)
151    }
152}
153
154/// Decode a bounded JSON command without losing duplicate keys or large integers.
155///
156/// The byte limit applies to the complete incoming request, including whitespace.
157/// Duplicate keys are rejected recursively before object insertion, including
158/// equivalent escaped spellings. Existing worker numeric-token checks run before
159/// constructing any JSON values. The target's serde schema is then applied.
160/// Callers must additionally run their command's domain validation; this helper
161/// does not impose the submission-specific data, metadata, or execution limits.
162pub fn decode_unique_json<T: DeserializeOwned>(bytes: &[u8], max_bytes: usize) -> Result<T> {
163    if bytes.len() > max_bytes {
164        return Err(invalid(format!(
165            "JSON request exceeds its {max_bytes}-byte limit"
166        )));
167    }
168    let StrictValue(value) = decode_json(bytes)?;
169    serde_json::from_value(value).map_err(|error| invalid(format!("invalid JSON command: {error}")))
170}
171
172/// Deterministically encode an already validated JSON value for comparison.
173///
174/// Object ordering is ignored recursively; array ordering and strings remain
175/// significant. Integer and binary64 number classes remain distinct, as do
176/// positive and negative floating zero. Equivalent parsed binary64 values have
177/// the same encoding. See [`SubmitTask::canonical_bytes`] for normalization of
178/// submission defaults and the JSON spelling `-0`.
179///
180/// This helper also supports immutable execution-report comparison. It does
181/// not impose submission limits on a report or replace the caller's domain
182/// validation: callers must first enforce the appropriate depth/byte bounds
183/// and decode original JSON without duplicate-key collapse or numeric loss.
184pub fn canonical_json_bytes(value: &Value) -> Result<Vec<u8>> {
185    canonical_owned_json_bytes(value.clone())
186}
187
188fn canonical_owned_json_bytes(mut value: Value) -> Result<Vec<u8>> {
189    sort_objects(&mut value);
190    serde_json::to_vec(&value)
191        .map_err(|error| invalid(format!("cannot encode canonical JSON: {error}")))
192}
193
194fn invalid(message: impl Into<String>) -> Error {
195    Error::new(ErrorKind::InvalidInput, message)
196}
197
198pub(crate) fn check_encoded_size(value: &impl Serialize, limit: usize, label: &str) -> Result<()> {
199    let mut counter = ByteCounter { written: 0, limit };
200    serde_json::to_writer(&mut counter, value)
201        .map_err(|_| invalid(format!("{label} exceeds its {limit}-byte JSON limit")))
202}
203
204struct ByteCounter {
205    written: usize,
206    limit: usize,
207}
208
209impl io::Write for ByteCounter {
210    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
211        if bytes.len() > self.limit - self.written {
212            return Err(io::Error::other("JSON byte limit exceeded"));
213        }
214        self.written += bytes.len();
215        Ok(bytes.len())
216    }
217
218    fn flush(&mut self) -> io::Result<()> {
219        Ok(())
220    }
221}
222
223fn sort_objects(value: &mut Value) {
224    match value {
225        Value::Array(values) => values.iter_mut().for_each(sort_objects),
226        Value::Object(values) => {
227            let mut sorted: Vec<_> = std::mem::take(values).into_iter().collect();
228            sorted.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
229            for (key, mut value) in sorted {
230                sort_objects(&mut value);
231                values.insert(key, value);
232            }
233        }
234        _ => {}
235    }
236}
237
238/// A JSON value whose object keys have been checked before insertion.
239struct StrictValue(Value);
240
241impl<'de> Deserialize<'de> for StrictValue {
242    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
243        struct StrictVisitor;
244
245        impl<'de> Visitor<'de> for StrictVisitor {
246            type Value = StrictValue;
247
248            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249                formatter.write_str("a JSON value without duplicate object keys")
250            }
251
252            fn visit_bool<E: de::Error>(self, value: bool) -> std::result::Result<Self::Value, E> {
253                Ok(StrictValue(Value::Bool(value)))
254            }
255
256            fn visit_i64<E: de::Error>(self, value: i64) -> std::result::Result<Self::Value, E> {
257                Ok(StrictValue(Value::Number(value.into())))
258            }
259
260            fn visit_u64<E: de::Error>(self, value: u64) -> std::result::Result<Self::Value, E> {
261                Ok(StrictValue(Value::Number(value.into())))
262            }
263
264            fn visit_f64<E: de::Error>(self, value: f64) -> std::result::Result<Self::Value, E> {
265                Number::from_f64(value)
266                    .map(|number| StrictValue(Value::Number(number)))
267                    .ok_or_else(|| E::custom("nonfinite JSON number"))
268            }
269
270            fn visit_str<E: de::Error>(self, value: &str) -> std::result::Result<Self::Value, E> {
271                Ok(StrictValue(Value::String(value.to_owned())))
272            }
273
274            fn visit_string<E: de::Error>(
275                self,
276                value: String,
277            ) -> std::result::Result<Self::Value, E> {
278                Ok(StrictValue(Value::String(value)))
279            }
280
281            fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
282                Ok(StrictValue(Value::Null))
283            }
284
285            fn visit_seq<A: SeqAccess<'de>>(
286                self,
287                mut sequence: A,
288            ) -> std::result::Result<Self::Value, A::Error> {
289                let mut values = Vec::new();
290                while let Some(StrictValue(value)) = sequence.next_element()? {
291                    values.push(value);
292                }
293                Ok(StrictValue(Value::Array(values)))
294            }
295
296            fn visit_map<A: MapAccess<'de>>(
297                self,
298                mut object: A,
299            ) -> std::result::Result<Self::Value, A::Error> {
300                let mut values = Map::new();
301                while let Some(key) = object.next_key::<String>()? {
302                    if values.contains_key(&key) {
303                        return Err(de::Error::custom("duplicate JSON object key"));
304                    }
305                    let StrictValue(value) = object.next_value()?;
306                    values.insert(key, value);
307                }
308                Ok(StrictValue(Value::Object(values)))
309            }
310        }
311
312        deserializer.deserialize_any(StrictVisitor)
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use serde_json::json;
320
321    fn request(data: &str) -> Vec<u8> {
322        format!(
323            r#"{{"tenant_id":"acme","namespace":"billing","queue":"python","program":{{"id":"invoice","version":"1.0.0"}},"data":{data}}}"#
324        )
325        .into_bytes()
326    }
327
328    fn submission(data: &str) -> SubmitTask {
329        SubmitTask::decode(&request(data)).unwrap()
330    }
331
332    #[test]
333    fn omitted_optional_settings_use_explicit_defaults() {
334        let implicit = submission("null");
335        assert_eq!(implicit.correlation_key, None);
336        assert_eq!(implicit.retry_policy.max_attempts, 3);
337        assert_eq!(implicit.retry_policy.retry_delay_ms, 5_000);
338        assert_eq!(implicit.attempt_timeout_ms, 300_000);
339        let mut explicit: Value = decode_json(&request("null")).unwrap();
340        explicit["correlation_key"] = Value::Null;
341        explicit["retry_policy"] = json!({"max_attempts": 3, "retry_delay_ms": 5000});
342        explicit["attempt_timeout_ms"] = json!(300_000);
343        let explicit = SubmitTask::decode(&serde_json::to_vec(&explicit).unwrap()).unwrap();
344        assert!(implicit.semantically_matches(&explicit).unwrap());
345    }
346
347    #[test]
348    fn duplicate_keys_are_rejected_before_collapse_at_every_depth() {
349        for data in [
350            r#"{"x":1,"x":2}"#,
351            r#"[{"outer":{"x":1,"\u0078":1}}]"#,
352            r#"{"\u0000":null,"\u0000":true}"#,
353        ] {
354            let error = SubmitTask::decode(&request(data)).unwrap_err();
355            assert_eq!(error.kind, ErrorKind::InvalidInput);
356            assert!(error.message.contains("duplicate JSON object key"));
357        }
358        let duplicated = String::from_utf8(request("null")).unwrap().replacen(
359            "\"tenant_id\":\"acme\"",
360            "\"tenant_id\":\"acme\",\"tenant_id\":\"acme\"",
361            1,
362        );
363        assert!(SubmitTask::decode(duplicated.as_bytes()).is_err());
364        let duplicate_program = String::from_utf8(request("null")).unwrap().replace(
365            "\"id\":\"invoice\"",
366            "\"id\":\"invoice\",\"id\":\"invoice\"",
367        );
368        assert!(SubmitTask::decode(duplicate_program.as_bytes()).is_err());
369        assert!(submission(r#"[{"x":1},{"x":2}]"#).validate().is_ok());
370    }
371
372    #[test]
373    fn integer_limits_and_exact_large_integer_roundtrip() {
374        let accepted = submission("[-9223372036854775808,18446744073709551615,9007199254740993]");
375        assert_eq!(accepted.data[0].as_i64(), Some(i64::MIN));
376        assert_eq!(accepted.data[1].as_u64(), Some(u64::MAX));
377        assert_eq!(accepted.data[2].as_u64(), Some(9_007_199_254_740_993));
378        let roundtrip = SubmitTask::decode(&accepted.canonical_bytes().unwrap()).unwrap();
379        assert!(accepted.semantically_matches(&roundtrip).unwrap());
380        for rejected in ["-9223372036854775809", "18446744073709551616"] {
381            assert!(SubmitTask::decode(&request(rejected)).is_err());
382        }
383    }
384
385    #[test]
386    fn user_strings_and_keys_preserve_nul_and_numeric_looking_text() {
387        let task = submission(
388            r#"{"\u0000":"left\u0000right","$serde_json::private::Number":"18446744073709551616"}"#,
389        );
390        assert_eq!(task.data["\0"], json!("left\0right"));
391        let roundtrip = SubmitTask::decode(&task.canonical_bytes().unwrap()).unwrap();
392        assert!(task.semantically_matches(&roundtrip).unwrap());
393    }
394
395    #[test]
396    fn binary64_spellings_normalize_without_erasing_number_kinds_or_signed_zero() {
397        for (left, right) in [("1e0", "1.0"), ("2.5e2", "250.0"), ("-0", "-0.0")] {
398            assert!(
399                submission(left)
400                    .semantically_matches(&submission(right))
401                    .unwrap()
402            );
403        }
404        for (left, right) in [("1", "1.0"), ("0", "0.0"), ("0.0", "-0.0"), ("0", "-0")] {
405            assert!(
406                !submission(left)
407                    .semantically_matches(&submission(right))
408                    .unwrap()
409            );
410        }
411        let task = submission("[2.291712365432881e-09,-1.527077339613215e-236,-0.0]");
412        let restored = SubmitTask::decode(&task.canonical_bytes().unwrap()).unwrap();
413        for (actual, expected) in restored.data.as_array().unwrap().iter().zip([
414            2.291712365432881e-09_f64,
415            -1.527077339613215e-236_f64,
416            -0.0_f64,
417        ]) {
418            assert_eq!(actual.as_f64().unwrap().to_bits(), expected.to_bits());
419        }
420    }
421
422    #[test]
423    fn comparison_ignores_object_order_but_preserves_arrays_and_values() {
424        let left = submission(r#"{"b":[{"z":0,"a":1},2],"a":"same"}"#);
425        let reordered = submission(r#"{"a":"same","b":[{"a":1,"z":0},2]}"#);
426        assert!(left.semantically_matches(&reordered).unwrap());
427        assert!(
428            !left
429                .semantically_matches(&submission(r#"{"a":"same","b":[2,{"a":1,"z":0}]}"#))
430                .unwrap()
431        );
432        assert!(
433            !left
434                .semantically_matches(&submission(r#"{"a":"different","b":[{"a":1,"z":0},2]}"#))
435                .unwrap()
436        );
437    }
438
439    #[test]
440    fn shared_json_comparison_preserves_nested_report_number_classes() {
441        let left: Value =
442            decode_json(br#"{"outcome":{"z":-0.0,"a":9007199254740993},"attempt":1}"#).unwrap();
443        let reordered: Value =
444            decode_json(br#"{"attempt":1,"outcome":{"a":9007199254740993,"z":-0e0}}"#).unwrap();
445        assert_eq!(
446            canonical_json_bytes(&left).unwrap(),
447            canonical_json_bytes(&reordered).unwrap()
448        );
449        for changed in [
450            br#"{"attempt":1,"outcome":{"a":9007199254740993,"z":0.0}}"#.as_slice(),
451            br#"{"attempt":1.0,"outcome":{"a":9007199254740993,"z":-0.0}}"#.as_slice(),
452        ] {
453            let changed: Value = decode_json(changed).unwrap();
454            assert_ne!(
455                canonical_json_bytes(&left).unwrap(),
456                canonical_json_bytes(&changed).unwrap()
457            );
458        }
459    }
460
461    #[test]
462    fn all_submission_settings_participate_in_comparison() {
463        let original = submission("null");
464        let mut changed = Vec::new();
465        let mut task = original.clone();
466        task.program.version = "2.0.0".into();
467        changed.push(task);
468        let mut task = original.clone();
469        task.retry_policy.max_attempts = 2;
470        changed.push(task);
471        let mut task = original.clone();
472        task.retry_policy.retry_delay_ms += 1;
473        changed.push(task);
474        let mut task = original.clone();
475        task.correlation_key = Some("invoice:1".into());
476        changed.push(task);
477        let mut task = original.clone();
478        task.attempt_timeout_ms += 1;
479        changed.push(task);
480        let mut task = original.clone();
481        task.tenant_id = "other".into();
482        changed.push(task);
483        let mut task = original.clone();
484        task.namespace = "other".into();
485        changed.push(task);
486        let mut task = original.clone();
487        task.queue = "other".into();
488        changed.push(task);
489        for task in changed {
490            assert!(!original.semantically_matches(&task).unwrap());
491        }
492    }
493
494    #[test]
495    fn metadata_uses_utf8_byte_limits_and_rejects_controls() {
496        let mut task = submission("null");
497        task.tenant_id = "é".repeat(64);
498        task.correlation_key = Some("é".repeat(256));
499        assert!(task.validate().is_ok());
500        task.tenant_id.push('é');
501        assert!(task.validate().is_err());
502        task.tenant_id = "acme".into();
503        task.correlation_key.as_mut().unwrap().push('é');
504        assert!(task.validate().is_err());
505        task.correlation_key = Some("business\u{85}reference".into());
506        assert!(task.validate().is_err());
507        task.correlation_key = None;
508        for value in ["", "line\nbreak", "nul\0value"] {
509            task.queue = value.into();
510            assert!(task.validate().is_err());
511        }
512    }
513
514    #[test]
515    fn platform_identifiers_reject_noncharacters_before_the_task_can_be_accepted() {
516        for character in [
517            '\u{fdd0}',
518            '\u{fdef}',
519            '\u{fffe}',
520            '\u{ffff}',
521            '\u{1fffe}',
522            '\u{10ffff}',
523        ] {
524            let mut task = submission("null");
525            task.tenant_id = format!("tenant{character}");
526            assert!(task.validate().is_err());
527            task.tenant_id = "acme".into();
528            task.namespace = format!("namespace{character}");
529            assert!(task.validate().is_err());
530            task.namespace = "billing".into();
531            task.queue = format!("queue{character}");
532            assert!(task.validate().is_err());
533        }
534        let mut task = submission("null");
535        task.correlation_key = Some("reference\u{10ffff}".into());
536        assert!(task.validate().is_ok());
537    }
538
539    #[test]
540    fn generic_command_decoder_rejects_nested_duplicate_keys_and_respects_limits() {
541        let bytes = br#"{"report":{"output":{"value":1,"\u0076alue":2}}}"#;
542        assert!(decode_unique_json::<Value>(bytes, bytes.len()).is_err());
543        let valid = br#"{"report":{"output":9007199254740993}}"#;
544        assert!(decode_unique_json::<Value>(valid, valid.len() - 1).is_err());
545        let decoded: Value = decode_unique_json(valid, valid.len()).unwrap();
546        assert_eq!(
547            decoded["report"]["output"].as_u64(),
548            Some(9_007_199_254_740_993)
549        );
550        assert!(
551            decode_unique_json::<Value>(br#"{"report":{"output":18446744073709551616}}"#, 1000)
552                .is_err()
553        );
554    }
555
556    #[test]
557    fn data_size_counts_compact_json_bytes_including_string_escapes() {
558        let mut task = submission("null");
559        task.data = Value::String("x".repeat(SUBMISSION_DATA_MAX_BYTES - 2));
560        assert!(task.validate().is_ok());
561        task.data = Value::String("x".repeat(SUBMISSION_DATA_MAX_BYTES - 1));
562        assert!(task.validate().is_err());
563        task.data = Value::String("\0".repeat(SUBMISSION_DATA_MAX_BYTES / 6 + 1));
564        assert!(task.validate().is_err());
565    }
566
567    #[test]
568    fn whole_request_limit_applies_to_incoming_whitespace() {
569        let mut bytes = request("null");
570        bytes.resize(SUBMISSION_MAX_BYTES, b' ');
571        assert!(SubmitTask::decode(&bytes).is_ok());
572        bytes.push(b' ');
573        assert!(SubmitTask::decode(&bytes).is_err());
574    }
575
576    #[test]
577    fn data_depth_64_is_allowed_and_65_is_rejected() {
578        let mut data = Value::Null;
579        for _ in 0..64 {
580            data = Value::Array(vec![data]);
581        }
582        let encoded = serde_json::to_string(&data).unwrap();
583        assert!(SubmitTask::decode(&request(&encoded)).is_ok());
584        data = Value::Array(vec![data]);
585        let encoded = serde_json::to_string(&data).unwrap();
586        assert!(SubmitTask::decode(&request(&encoded)).is_err());
587    }
588
589    #[test]
590    fn attempt_timeout_endpoints_are_inclusive() {
591        let mut task = submission("null");
592        for valid in [60_000, 86_400_000] {
593            task.attempt_timeout_ms = valid;
594            assert!(task.validate().is_ok());
595        }
596        for invalid in [0, 1, 59_999, 86_400_001] {
597            task.attempt_timeout_ms = invalid;
598            assert!(task.validate().is_err());
599        }
600    }
601
602    #[test]
603    fn invalid_schema_syntax_and_nonfinite_values_are_rejected() {
604        for data in ["1e400", "NaN", "[1 2]", "\"\\ud800\""] {
605            assert!(SubmitTask::decode(&request(data)).is_err());
606        }
607        let mut value: Value = decode_json(&request("null")).unwrap();
608        value["unexpected"] = json!(true);
609        assert!(SubmitTask::decode(&serde_json::to_vec(&value).unwrap()).is_err());
610        value.as_object_mut().unwrap().remove("unexpected");
611        value["program"]["version"] = json!("../invalid");
612        assert!(SubmitTask::decode(&serde_json::to_vec(&value).unwrap()).is_err());
613    }
614}