1use super::*;
2
3pub fn event_payload_digest(event: &Value) -> CheckpointResult<String> {
4 if !event.is_object() {
5 return Err(CheckpointError::new(
6 "event_payload_invalid",
7 "event payload must be an object",
8 ));
9 }
10 sha256_canonical(event, "event payload")
11}
12
13pub fn model_request_digest(request: &Value) -> CheckpointResult<String> {
14 operation_request_digest(OperationKind::Model, request)
15}
16
17pub fn tool_request_digest(
18 tool_call_id: &str,
19 tool_name: &str,
20 arguments: &Value,
21 idempotency_key: &str,
22) -> CheckpointResult<String> {
23 require_non_empty(tool_call_id, "tool_call_id")?;
24 require_non_empty(tool_name, "tool_name")?;
25 require_non_empty(idempotency_key, "idempotency_key")?;
26 if !arguments.is_object() {
27 return Err(CheckpointError::new(
28 "operation_request_invalid",
29 "tool arguments must be an object",
30 ));
31 }
32 operation_request_digest(
33 OperationKind::Tool,
34 &serde_json::json!({
35 "schema_version": OPERATION_REQUEST_SCHEMA,
36 "kind": "tool",
37 "request": {
38 "tool_call_id": tool_call_id,
39 "tool_name": tool_name,
40 "arguments": arguments,
41 "idempotency_key": idempotency_key,
42 },
43 }),
44 )
45}
46
47pub fn operation_request_digest(kind: OperationKind, request: &Value) -> CheckpointResult<String> {
48 let object = request.as_object().ok_or_else(|| {
49 CheckpointError::new(
50 "operation_request_invalid",
51 "operation request must be an object",
52 )
53 })?;
54 let expected = ["schema_version", "kind", "request"];
55 if object.len() != expected.len() || expected.iter().any(|field| !object.contains_key(*field)) {
56 return Err(CheckpointError::new(
57 "operation_request_invalid",
58 "operation request has missing or unknown fields",
59 ));
60 }
61 if object.get("schema_version").and_then(Value::as_str) != Some(OPERATION_REQUEST_SCHEMA) {
62 return Err(CheckpointError::new(
63 "operation_request_schema_unsupported",
64 "operation request schema is unsupported",
65 ));
66 }
67 let actual_kind = object.get("kind").and_then(Value::as_str).ok_or_else(|| {
68 CheckpointError::new(
69 "operation_request_invalid",
70 "operation request kind is invalid",
71 )
72 })?;
73 let expected_kind = match kind {
74 OperationKind::Model => "model",
75 OperationKind::Tool => "tool",
76 };
77 if actual_kind != expected_kind {
78 return Err(CheckpointError::new(
79 "operation_request_invalid",
80 "operation request kind does not match the journal operation",
81 ));
82 }
83 let request_payload = object
84 .get("request")
85 .and_then(Value::as_object)
86 .ok_or_else(|| {
87 CheckpointError::new(
88 "operation_request_invalid",
89 "operation request payload must be an object",
90 )
91 })?;
92 let required: &[&str] = match kind {
93 OperationKind::Model => &[
94 "model",
95 "messages",
96 "settings",
97 "tools",
98 "output_schema",
99 "idempotency_key",
100 ],
101 OperationKind::Tool => &["tool_call_id", "tool_name", "arguments", "idempotency_key"],
102 };
103 if request_payload.len() != required.len()
104 || required
105 .iter()
106 .any(|field| !request_payload.contains_key(*field))
107 {
108 return Err(CheckpointError::new(
109 "operation_request_invalid",
110 "operation request payload has missing or unknown fields",
111 ));
112 }
113 validate_i_json(request, "operation request")
114 .map_err(|error| CheckpointError::new("operation_request_not_i_json", error.message))?;
115 sha256_canonical(request, "operation request")
116}
117
118pub fn canonical_json_bytes(value: &Value, field_name: &str) -> CheckpointResult<Vec<u8>> {
119 validate_i_json(value, field_name)?;
120 serde_json_canonicalizer::to_vec(value).map_err(|error| {
121 CheckpointError::new(
122 "checkpoint_canonicalization_invalid",
123 format!("{field_name} cannot be canonicalized: {error}"),
124 )
125 })
126}
127
128pub fn run_definition_digest(definition: &Value) -> CheckpointResult<String> {
129 validate_run_definition(definition)?;
130 sha256_canonical(definition, "run_definition")
131}
132
133pub fn canonical_run_definition_bytes(definition: &Value) -> CheckpointResult<Vec<u8>> {
134 validate_run_definition(definition)?;
135 canonical_json_bytes(definition, "run_definition")
136}
137
138pub fn validate_run_definition(definition: &Value) -> CheckpointResult<()> {
139 validate_i_json(definition, "run_definition")?;
140 let object = definition.as_object().ok_or_else(|| {
141 CheckpointError::new(
142 "checkpoint_definition_invalid",
143 "run_definition must be an object",
144 )
145 })?;
146 let required = [
147 "schema_version",
148 "agent",
149 "root_input",
150 "compiled_prompt",
151 "initial_messages",
152 "initial_shared_state",
153 "run_metadata",
154 "context_ref",
155 "model",
156 "credential_slots",
157 "runtime_controls",
158 "tools",
159 "tool_policy",
160 "checkpoint_policy",
161 "budget_limits",
162 "output_schema",
163 "workspace_ref",
164 "session_ref",
165 "extensions",
166 "capability_refs",
167 ];
168 let required_set = required.iter().copied().collect::<BTreeSet<_>>();
169 if object
170 .keys()
171 .any(|key| !required_set.contains(key.as_str()))
172 || required.iter().any(|key| !object.contains_key(*key))
173 {
174 return Err(CheckpointError::new(
175 "checkpoint_definition_invalid",
176 "run_definition has missing or unknown top-level fields",
177 ));
178 }
179 if object.get("schema_version").and_then(Value::as_str) != Some(RUN_DEFINITION_SCHEMA) {
180 return Err(CheckpointError::new(
181 "checkpoint_definition_schema_unsupported",
182 "run_definition schema_version is unsupported",
183 ));
184 }
185 require_object(object.get("agent"), "run_definition.agent")?;
186 require_string(object.get("root_input"), "run_definition.root_input")?;
187 require_string(
188 object.get("compiled_prompt"),
189 "run_definition.compiled_prompt",
190 )?;
191 require_array(
192 object.get("initial_messages"),
193 "run_definition.initial_messages",
194 )?;
195 require_object(
196 object.get("initial_shared_state"),
197 "run_definition.initial_shared_state",
198 )?;
199 require_object(object.get("run_metadata"), "run_definition.run_metadata")?;
200 require_object(object.get("model"), "run_definition.model")?;
201 require_object(
202 object.get("runtime_controls"),
203 "run_definition.runtime_controls",
204 )?;
205 require_array(object.get("tools"), "run_definition.tools")?;
206 require_object(object.get("tool_policy"), "run_definition.tool_policy")?;
207 require_object(
208 object.get("checkpoint_policy"),
209 "run_definition.checkpoint_policy",
210 )?;
211 require_object(
212 object.get("capability_refs"),
213 "run_definition.capability_refs",
214 )?;
215
216 validate_optional_object_or_null(object.get("context_ref"), "run_definition.context_ref")?;
217 validate_optional_object_or_null(object.get("budget_limits"), "run_definition.budget_limits")?;
218 validate_optional_object_or_null(object.get("output_schema"), "run_definition.output_schema")?;
219 validate_optional_object_or_null(object.get("workspace_ref"), "run_definition.workspace_ref")?;
220 validate_optional_object_or_null(object.get("session_ref"), "run_definition.session_ref")?;
221
222 let slots = require_array(
223 object.get("credential_slots"),
224 "run_definition.credential_slots",
225 )?;
226 validate_credential_slots(definition, slots)?;
227 validate_capability_refs(
228 object.get("capability_refs"),
229 "run_definition.capability_refs",
230 )?;
231 validate_extensions(object.get("extensions"), "run_definition.extensions")?;
232 validate_tool_policy(object.get("tool_policy"))?;
233 validate_checkpoint_policy(object.get("checkpoint_policy"))?;
234 validate_header_names(object.get("model"))?;
235 Ok(())
236}
237
238pub fn normalize_run_definition(
241 definition: &Value,
242 credential_slots: &[String],
243) -> CheckpointResult<Value> {
244 let mut normalized = definition.clone();
245 let object = normalized.as_object_mut().ok_or_else(|| {
246 CheckpointError::new(
247 "checkpoint_definition_invalid",
248 "run_definition must be an object",
249 )
250 })?;
251 let slots = credential_slots
252 .iter()
253 .cloned()
254 .map(Value::String)
255 .collect::<Vec<_>>();
256 object.insert("credential_slots".to_string(), Value::Array(slots));
257 normalize_headers(object.get_mut("model"))?;
258 normalize_tool_policy(object.get_mut("tool_policy"))?;
259 normalize_extensions(object.get_mut("extensions"))?;
260 let normalized = redact_run_definition(&Value::Object(object.clone()), credential_slots)?;
261 validate_run_definition(&normalized)?;
262 Ok(normalized)
263}
264
265pub fn redact_run_definition(
266 definition: &Value,
267 credential_slots: &[String],
268) -> CheckpointResult<Value> {
269 let mut redacted = definition.clone();
270 let mut previous: Option<&str> = None;
271 for slot in credential_slots {
272 if let Some(previous_slot) = previous {
273 if utf16_cmp(previous_slot, slot) != Ordering::Less {
274 return Err(CheckpointError::new(
275 "checkpoint_credential_slots_invalid",
276 "credential slots must be sorted and unique",
277 ));
278 }
279 }
280 validate_pointer(slot)?;
281 set_json_pointer(
282 &mut redacted,
283 slot,
284 Value::String(CREDENTIAL_REDACTED.to_string()),
285 )?;
286 previous = Some(slot);
287 }
288 Ok(redacted)
289}
290
291pub fn resolve_json_pointer<'a>(value: &'a Value, pointer: &str) -> CheckpointResult<&'a Value> {
292 let tokens = pointer_tokens(pointer)?;
293 let mut current = value;
294 for token in tokens {
295 current = match current {
296 Value::Object(object) => object.get(&token).ok_or_else(|| {
297 CheckpointError::new(
298 "checkpoint_credential_slot_unresolved",
299 format!("JSON pointer {pointer} does not resolve"),
300 )
301 })?,
302 Value::Array(array) => {
303 let index = token.parse::<usize>().map_err(|_| {
304 CheckpointError::new(
305 "checkpoint_credential_slot_unresolved",
306 format!("JSON pointer {pointer} has an invalid array index"),
307 )
308 })?;
309 array.get(index).ok_or_else(|| {
310 CheckpointError::new(
311 "checkpoint_credential_slot_unresolved",
312 format!("JSON pointer {pointer} does not resolve"),
313 )
314 })?
315 }
316 _ => {
317 return Err(CheckpointError::new(
318 "checkpoint_credential_slot_unresolved",
319 format!("JSON pointer {pointer} does not resolve"),
320 ));
321 }
322 };
323 }
324 Ok(current)
325}
326
327pub fn set_json_pointer(
328 value: &mut Value,
329 pointer: &str,
330 replacement: Value,
331) -> CheckpointResult<()> {
332 let tokens = pointer_tokens(pointer)?;
333 if tokens.is_empty() {
334 *value = replacement;
335 return Ok(());
336 }
337 let mut current = value;
338 for token in &tokens[..tokens.len() - 1] {
339 current = match current {
340 Value::Object(object) => object.get_mut(token).ok_or_else(|| {
341 CheckpointError::new(
342 "checkpoint_credential_slot_unresolved",
343 format!("JSON pointer {pointer} does not resolve"),
344 )
345 })?,
346 Value::Array(array) => {
347 let index = token.parse::<usize>().map_err(|_| {
348 CheckpointError::new(
349 "checkpoint_credential_slot_unresolved",
350 format!("JSON pointer {pointer} has an invalid array index"),
351 )
352 })?;
353 array.get_mut(index).ok_or_else(|| {
354 CheckpointError::new(
355 "checkpoint_credential_slot_unresolved",
356 format!("JSON pointer {pointer} does not resolve"),
357 )
358 })?
359 }
360 _ => {
361 return Err(CheckpointError::new(
362 "checkpoint_credential_slot_unresolved",
363 format!("JSON pointer {pointer} does not resolve"),
364 ));
365 }
366 };
367 }
368 let last = tokens.last().expect("non-empty pointer tokens");
369 match current {
370 Value::Object(object) => {
371 if !object.contains_key(last) {
372 return Err(CheckpointError::new(
373 "checkpoint_credential_slot_unresolved",
374 format!("JSON pointer {pointer} does not resolve"),
375 ));
376 }
377 object.insert(last.clone(), replacement);
378 }
379 Value::Array(array) => {
380 let index = last.parse::<usize>().map_err(|_| {
381 CheckpointError::new(
382 "checkpoint_credential_slot_unresolved",
383 format!("JSON pointer {pointer} has an invalid array index"),
384 )
385 })?;
386 let Some(item) = array.get_mut(index) else {
387 return Err(CheckpointError::new(
388 "checkpoint_credential_slot_unresolved",
389 format!("JSON pointer {pointer} does not resolve"),
390 ));
391 };
392 *item = replacement;
393 }
394 _ => {
395 return Err(CheckpointError::new(
396 "checkpoint_credential_slot_unresolved",
397 format!("JSON pointer {pointer} does not resolve"),
398 ));
399 }
400 }
401 Ok(())
402}
403
404pub fn validate_extension_namespace(namespace: &str) -> CheckpointResult<()> {
405 if namespace.is_empty() || !namespace.is_ascii() {
406 return Err(CheckpointError::new(
407 "checkpoint_extension_namespace_invalid",
408 "extension namespace must be non-empty ASCII",
409 ));
410 }
411 if namespace.len() > MAX_EXTENSION_NAMESPACE_BYTES {
412 return Err(CheckpointError::new(
413 "checkpoint_extension_namespace_invalid",
414 format!("extension namespace exceeds {MAX_EXTENSION_NAMESPACE_BYTES} bytes"),
415 ));
416 }
417 let Some(first) = namespace.as_bytes().first().copied() else {
418 unreachable!("empty namespace handled above");
419 };
420 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
421 return Err(CheckpointError::new(
422 "checkpoint_extension_namespace_invalid",
423 "extension namespace must begin with a lowercase letter or digit",
424 ));
425 }
426 if !namespace.contains('.')
427 || namespace.bytes().any(|byte| {
428 !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte))
429 })
430 {
431 return Err(CheckpointError::new(
432 "checkpoint_extension_namespace_invalid",
433 "extension namespace does not match the reverse-DNS grammar",
434 ));
435 }
436 Ok(())
437}
438
439pub fn validate_sha256(value: &str, field_name: &str) -> CheckpointResult<()> {
440 if value.len() != 64
441 || value
442 .bytes()
443 .any(|byte| !byte.is_ascii_hexdigit() || byte.is_ascii_uppercase())
444 {
445 return Err(CheckpointError::new(
446 "checkpoint_digest_invalid",
447 format!("{field_name} must be a lowercase SHA-256 hex digest"),
448 ));
449 }
450 Ok(())
451}
452
453pub fn validate_checkpoint_key(key: &str) -> CheckpointResult<()> {
454 if key.trim().is_empty() || key.len() > MAX_CHECKPOINT_KEY_BYTES {
455 return Err(CheckpointError::new(
456 "checkpoint_key_invalid",
457 format!(
458 "checkpoint key must be non-empty and at most {MAX_CHECKPOINT_KEY_BYTES} UTF-8 bytes"
459 ),
460 ));
461 }
462 Ok(())
463}
464
465fn sha256_canonical(value: &Value, field_name: &str) -> CheckpointResult<String> {
466 let bytes = canonical_json_bytes(value, field_name)?;
467 Ok(format!("{:x}", Sha256::digest(bytes)))
468}
469
470pub(super) fn validate_i_json(value: &Value, field_name: &str) -> CheckpointResult<()> {
471 match value {
472 Value::Null | Value::Bool(_) | Value::String(_) => Ok(()),
473 Value::Number(number) => validate_number(number, field_name),
474 Value::Array(items) => items
475 .iter()
476 .enumerate()
477 .try_for_each(|(index, item)| validate_i_json(item, &format!("{field_name}[{index}]"))),
478 Value::Object(object) => object
479 .iter()
480 .try_for_each(|(key, item)| validate_i_json(item, &format!("{field_name}.{key}"))),
481 }
482}
483
484fn validate_number(number: &Number, field_name: &str) -> CheckpointResult<()> {
485 if let Some(value) = number.as_u64() {
486 if value > MAX_WIRE_INTEGER {
487 return Err(CheckpointError::new(
488 "checkpoint_definition_not_i_json",
489 format!("{field_name} is outside the JSON-safe integer range"),
490 ));
491 }
492 } else if let Some(value) = number.as_i64() {
493 if value.unsigned_abs() > MAX_WIRE_INTEGER {
494 return Err(CheckpointError::new(
495 "checkpoint_definition_not_i_json",
496 format!("{field_name} is outside the JSON-safe integer range"),
497 ));
498 }
499 } else if number.as_f64().is_none_or(|value| !value.is_finite()) {
500 return Err(CheckpointError::new(
501 "checkpoint_definition_not_i_json",
502 format!("{field_name} is not a finite JSON number"),
503 ));
504 }
505 Ok(())
506}
507
508pub(super) fn validate_capability_ref(
509 reference: &CapabilityRef,
510 field_name: &str,
511) -> CheckpointResult<()> {
512 if reference.id.trim().is_empty() || reference.version.trim().is_empty() {
513 return Err(CheckpointError::new(
514 "checkpoint_capability_ref_invalid",
515 format!("{field_name} requires non-empty id and version"),
516 ));
517 }
518 Ok(())
519}
520
521pub(super) fn validate_capability_slot(slot: &str) -> CheckpointResult<()> {
522 if slot.is_empty()
523 || !slot.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
524 || slot.bytes().any(|byte| {
525 !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_.:-".contains(&byte))
526 })
527 {
528 return Err(CheckpointError::new(
529 "checkpoint_capability_ref_invalid",
530 format!("invalid capability reference slot {slot}"),
531 ));
532 }
533 Ok(())
534}
535
536fn validate_capability_refs(value: Option<&Value>, field_name: &str) -> CheckpointResult<()> {
537 let object = require_object(value, field_name)?;
538 for (slot, reference) in object {
539 validate_capability_slot(slot)?;
540 let reference_object = require_object(Some(reference), &format!("{field_name}.{slot}"))?;
541 if reference_object.len() != 2
542 || !reference_object.contains_key("id")
543 || !reference_object.contains_key("version")
544 {
545 return Err(CheckpointError::new(
546 "checkpoint_capability_ref_invalid",
547 format!("{field_name}.{slot} must contain exactly id and version"),
548 ));
549 }
550 require_string(reference_object.get("id"), "capability reference id")?;
551 require_string(
552 reference_object.get("version"),
553 "capability reference version",
554 )?;
555 }
556 Ok(())
557}
558
559fn validate_extensions(value: Option<&Value>, field_name: &str) -> CheckpointResult<()> {
560 let extensions = require_array(value, field_name)?;
561 let mut previous: Option<&str> = None;
562 for extension in extensions {
563 let object = require_object(Some(extension), "run_definition extension")?;
564 let namespace = require_string(object.get("namespace"), "extension namespace")?;
565 validate_extension_namespace(namespace)?;
566 require_string(object.get("version"), "extension version")?;
567 if !object.get("required").is_some_and(Value::is_boolean) {
568 return Err(CheckpointError::new(
569 "checkpoint_definition_invalid",
570 "extension required must be boolean",
571 ));
572 }
573 if let Some(previous) = previous {
574 if previous >= namespace {
575 return Err(CheckpointError::new(
576 "checkpoint_definition_invalid",
577 "extensions must be sorted and unique by namespace",
578 ));
579 }
580 }
581 previous = Some(namespace);
582 }
583 Ok(())
584}
585
586fn validate_tool_policy(value: Option<&Value>) -> CheckpointResult<()> {
587 let object = require_object(value, "run_definition.tool_policy")?;
588 if let Some(allowed) = object.get("allowed_tools") {
589 if !allowed.is_null() {
590 validate_sorted_unique_strings(Some(allowed), "tool_policy.allowed_tools")?;
591 }
592 }
593 validate_sorted_unique_strings(
594 object.get("disallowed_tools"),
595 "tool_policy.disallowed_tools",
596 )?;
597 if let Some(predicate) = object.get("predicate_ref") {
598 if !predicate.is_null() {
599 validate_ref_value(predicate, "tool_policy.predicate_ref")?;
600 }
601 }
602 Ok(())
603}
604
605fn validate_checkpoint_policy(value: Option<&Value>) -> CheckpointResult<()> {
606 let object = require_object(value, "run_definition.checkpoint_policy")?;
607 for key in ["ambiguous_model_policy", "ambiguous_tool_policy"] {
608 require_string(object.get(key), &format!("checkpoint_policy.{key}"))?;
609 }
610 let max = object.get("max_extension_state_bytes").ok_or_else(|| {
611 CheckpointError::new(
612 "checkpoint_definition_invalid",
613 "checkpoint_policy.max_extension_state_bytes is required",
614 )
615 })?;
616 let Some(max) = max.as_u64() else {
617 return Err(CheckpointError::new(
618 "checkpoint_definition_not_i_json",
619 "max_extension_state_bytes must be a safe integer",
620 ));
621 };
622 if max > MAX_WIRE_INTEGER {
623 return Err(CheckpointError::new(
624 "checkpoint_definition_not_i_json",
625 "max_extension_state_bytes exceeds the safe integer range",
626 ));
627 }
628 Ok(())
629}
630
631fn validate_header_names(model: Option<&Value>) -> CheckpointResult<()> {
632 let Some(model) = model.and_then(Value::as_object) else {
633 return Ok(());
634 };
635 let Some(settings) = model.get("settings").and_then(Value::as_object) else {
636 return Ok(());
637 };
638 let Some(headers) = settings.get("extra_headers").and_then(Value::as_object) else {
639 return Ok(());
640 };
641 let mut normalized = BTreeSet::new();
642 for name in headers.keys() {
643 let lower = name.to_ascii_lowercase();
644 if !normalized.insert(lower) {
645 return Err(CheckpointError::new(
646 "checkpoint_definition_header_collision",
647 "header names collide after ASCII lowercasing",
648 ));
649 }
650 }
651 Ok(())
652}
653
654fn validate_credential_slots(definition: &Value, slots: &[Value]) -> CheckpointResult<()> {
655 let mut previous: Option<&str> = None;
656 for slot in slots {
657 let slot = slot.as_str().ok_or_else(|| {
658 CheckpointError::new(
659 "checkpoint_credential_slots_invalid",
660 "credential slots must be strings",
661 )
662 })?;
663 if let Some(previous) = previous {
664 if utf16_cmp(previous, slot) != Ordering::Less {
665 return Err(CheckpointError::new(
666 "checkpoint_credential_slots_invalid",
667 "credential slots must be sorted and unique",
668 ));
669 }
670 }
671 validate_pointer(slot)?;
672 let resolved = resolve_json_pointer(definition, slot)?;
673 if resolved.as_str() != Some(CREDENTIAL_REDACTED) {
674 return Err(CheckpointError::new(
675 "checkpoint_credential_value_not_redacted",
676 format!("credential slot {slot} is not redacted"),
677 ));
678 }
679 previous = Some(slot);
680 }
681 Ok(())
682}
683
684fn validate_sorted_unique_strings(value: Option<&Value>, field_name: &str) -> CheckpointResult<()> {
685 let values = require_array(value, field_name)?;
686 let mut previous: Option<&str> = None;
687 for value in values {
688 let string = require_string(Some(value), field_name)?;
689 if let Some(previous) = previous {
690 if utf16_cmp(previous, string) != Ordering::Less {
691 return Err(CheckpointError::new(
692 "checkpoint_definition_invalid",
693 format!("{field_name} must be sorted and unique"),
694 ));
695 }
696 }
697 previous = Some(string);
698 }
699 Ok(())
700}
701
702fn validate_ref_value(value: &Value, field_name: &str) -> CheckpointResult<()> {
703 let object = require_object(Some(value), field_name)?;
704 if object.len() != 2 || !object.contains_key("id") || !object.contains_key("version") {
705 return Err(CheckpointError::new(
706 "checkpoint_capability_ref_invalid",
707 format!("{field_name} must contain exactly id and version"),
708 ));
709 }
710 require_string(object.get("id"), &format!("{field_name}.id"))?;
711 require_string(object.get("version"), &format!("{field_name}.version"))?;
712 Ok(())
713}
714
715pub(super) fn validate_pointer(pointer: &str) -> CheckpointResult<()> {
716 pointer_tokens(pointer).map(|_| ())
717}
718
719fn pointer_tokens(pointer: &str) -> CheckpointResult<Vec<String>> {
720 if pointer.is_empty() {
721 return Ok(Vec::new());
722 }
723 if !pointer.starts_with('/') {
724 return Err(CheckpointError::new(
725 "checkpoint_credential_slots_invalid",
726 "JSON pointer must be empty or start with '/'",
727 ));
728 }
729 pointer
730 .split('/')
731 .skip(1)
732 .map(|raw| {
733 let mut token = String::with_capacity(raw.len());
734 let bytes = raw.as_bytes();
735 let mut index = 0;
736 while index < bytes.len() {
737 if bytes[index] != b'~' {
738 let character = raw[index..].chars().next().expect("valid UTF-8");
739 token.push(character);
740 index += character.len_utf8();
741 continue;
742 }
743 if index + 1 >= bytes.len() {
744 return Err(CheckpointError::new(
745 "checkpoint_credential_slots_invalid",
746 "JSON pointer contains an invalid escape",
747 ));
748 }
749 match bytes[index + 1] {
750 b'0' => token.push('~'),
751 b'1' => token.push('/'),
752 _ => {
753 return Err(CheckpointError::new(
754 "checkpoint_credential_slots_invalid",
755 "JSON pointer contains an invalid escape",
756 ));
757 }
758 }
759 index += 2;
760 }
761 Ok(token)
762 })
763 .collect()
764}
765
766fn normalize_headers(model: Option<&mut Value>) -> CheckpointResult<()> {
767 let Some(model) = model.and_then(Value::as_object_mut) else {
768 return Ok(());
769 };
770 let Some(settings) = model.get_mut("settings").and_then(Value::as_object_mut) else {
771 return Ok(());
772 };
773 let Some(headers) = settings
774 .get_mut("extra_headers")
775 .and_then(Value::as_object_mut)
776 else {
777 return Ok(());
778 };
779 let mut normalized = Map::new();
780 for (name, value) in std::mem::take(headers) {
781 let lower = name.to_ascii_lowercase();
782 if normalized.insert(lower, value).is_some() {
783 return Err(CheckpointError::new(
784 "checkpoint_definition_header_collision",
785 "header names collide after ASCII lowercasing",
786 ));
787 }
788 }
789 *headers = normalized;
790 Ok(())
791}
792
793fn normalize_tool_policy(policy: Option<&mut Value>) -> CheckpointResult<()> {
794 let Some(policy) = policy.and_then(Value::as_object_mut) else {
795 return Ok(());
796 };
797 for key in ["allowed_tools", "disallowed_tools"] {
798 let Some(values) = policy.get_mut(key).and_then(Value::as_array_mut) else {
799 continue;
800 };
801 let mut strings = values
802 .iter()
803 .map(|value| {
804 value.as_str().map(str::to_string).ok_or_else(|| {
805 CheckpointError::new(
806 "checkpoint_definition_invalid",
807 format!("tool policy {key} must contain strings"),
808 )
809 })
810 })
811 .collect::<CheckpointResult<Vec<_>>>()?;
812 strings.sort_by(|left, right| utf16_cmp(left, right));
813 strings.dedup();
814 *values = strings.into_iter().map(Value::String).collect();
815 }
816 Ok(())
817}
818
819fn normalize_extensions(extensions: Option<&mut Value>) -> CheckpointResult<()> {
820 let Some(extensions) = extensions.and_then(Value::as_array_mut) else {
821 return Ok(());
822 };
823 extensions.sort_by(|left, right| {
824 left.get("namespace")
825 .and_then(Value::as_str)
826 .cmp(&right.get("namespace").and_then(Value::as_str))
827 });
828 Ok(())
829}
830
831pub(super) fn require_non_empty(value: &str, field_name: &str) -> CheckpointResult<()> {
832 if value.trim().is_empty() {
833 return Err(CheckpointError::new(
834 "checkpoint_value_invalid",
835 format!("{field_name} must be non-empty"),
836 ));
837 }
838 Ok(())
839}
840
841pub(super) fn require_positive(value: u64, field_name: &str) -> CheckpointResult<()> {
842 if value == 0 || value > MAX_WIRE_INTEGER {
843 return Err(CheckpointError::new(
844 "checkpoint_integer_invalid",
845 format!("{field_name} must be between 1 and {MAX_WIRE_INTEGER}"),
846 ));
847 }
848 Ok(())
849}
850
851fn require_string<'a>(value: Option<&'a Value>, field_name: &str) -> CheckpointResult<&'a str> {
852 value.and_then(Value::as_str).ok_or_else(|| {
853 CheckpointError::new(
854 "checkpoint_definition_invalid",
855 format!("{field_name} must be a string"),
856 )
857 })
858}
859
860fn require_object<'a>(
861 value: Option<&'a Value>,
862 field_name: &str,
863) -> CheckpointResult<&'a Map<String, Value>> {
864 value.and_then(Value::as_object).ok_or_else(|| {
865 CheckpointError::new(
866 "checkpoint_definition_invalid",
867 format!("{field_name} must be an object"),
868 )
869 })
870}
871
872fn require_array<'a>(
873 value: Option<&'a Value>,
874 field_name: &str,
875) -> CheckpointResult<&'a Vec<Value>> {
876 value.and_then(Value::as_array).ok_or_else(|| {
877 CheckpointError::new(
878 "checkpoint_definition_invalid",
879 format!("{field_name} must be an array"),
880 )
881 })
882}
883
884fn validate_optional_object_or_null(
885 value: Option<&Value>,
886 field_name: &str,
887) -> CheckpointResult<()> {
888 if value.is_some_and(|value| !value.is_object() && !value.is_null()) {
889 return Err(CheckpointError::new(
890 "checkpoint_definition_invalid",
891 format!("{field_name} must be an object or null"),
892 ));
893 }
894 Ok(())
895}
896
897pub(super) fn utf16_cmp(left: &str, right: &str) -> Ordering {
898 left.encode_utf16().cmp(right.encode_utf16())
899}