1use crate::{
4 ProtocolViolation, is_valid_bcp47, is_valid_rfc3339, is_valid_rfc3339_date,
5 is_valid_rfc3339_time, normalize_utc_offset, validate_protocol_event,
6};
7use serde::Serialize;
8use serde_json::{Number, Value};
9use std::fmt;
10
11const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum LintSeverity {
17 Warning,
18 Error,
19}
20
21impl LintSeverity {
22 pub const fn as_str(self) -> &'static str {
23 match self {
24 Self::Warning => "warning",
25 Self::Error => "error",
26 }
27 }
28}
29
30impl fmt::Display for LintSeverity {
31 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32 formatter.write_str(self.as_str())
33 }
34}
35
36#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
38pub struct LintFinding {
39 pub rule_id: String,
40 pub severity: LintSeverity,
41 pub pointer: String,
42 pub message: String,
43}
44
45impl LintFinding {
46 pub fn error(rule_id: &str, pointer: impl Into<String>, message: impl Into<String>) -> Self {
47 Self {
48 rule_id: rule_id.to_string(),
49 severity: LintSeverity::Error,
50 pointer: pointer.into(),
51 message: message.into(),
52 }
53 }
54
55 pub fn warning(rule_id: &str, pointer: impl Into<String>, message: impl Into<String>) -> Self {
56 Self {
57 rule_id: rule_id.to_string(),
58 severity: LintSeverity::Warning,
59 pointer: pointer.into(),
60 message: message.into(),
61 }
62 }
63
64 pub const fn is_error(&self) -> bool {
65 matches!(self.severity, LintSeverity::Error)
66 }
67
68 pub fn to_json(&self) -> Value {
69 serde_json::json!({
70 "rule_id": self.rule_id,
71 "severity": self.severity,
72 "pointer": self.pointer,
73 "message": self.message,
74 })
75 }
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub struct LintOptions {
81 minimum_severity: LintSeverity,
82}
83
84impl LintOptions {
85 pub const fn new() -> Self {
87 Self {
88 minimum_severity: LintSeverity::Warning,
89 }
90 }
91
92 pub const fn minimum_severity(mut self, minimum_severity: LintSeverity) -> Self {
94 self.minimum_severity = minimum_severity;
95 self
96 }
97
98 pub const fn errors_only() -> Self {
99 Self::new().minimum_severity(LintSeverity::Error)
100 }
101}
102
103impl Default for LintOptions {
104 fn default() -> Self {
105 Self::new()
106 }
107}
108
109pub fn lint_value(value: &Value, options: LintOptions) -> Vec<LintFinding> {
111 let mut findings = Vec::new();
112 lint_value_at(value, "", &mut findings);
113 findings.retain(|finding| finding.severity >= options.minimum_severity);
114 findings
115}
116
117pub fn assert_strict_event(value: &Value) -> Result<(), ProtocolViolation> {
119 validate_protocol_event(value, true)
120}
121
122pub fn assert_no_lint_findings(value: &Value) -> Result<(), Vec<LintFinding>> {
124 assert_no_lint_findings_with_options(value, LintOptions::default())
125}
126
127pub fn assert_no_lint_findings_with_options(
129 value: &Value,
130 options: LintOptions,
131) -> Result<(), Vec<LintFinding>> {
132 let findings = lint_value(value, options);
133 if findings.is_empty() {
134 Ok(())
135 } else {
136 Err(findings)
137 }
138}
139
140#[derive(Clone, Debug, PartialEq, Eq)]
142pub enum RedactionCanaryError {
143 EmptyCanary,
144 Exposed,
146 InspectionLimitExceeded,
149}
150
151impl fmt::Display for RedactionCanaryError {
152 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
153 match self {
154 Self::EmptyCanary => formatter.write_str("redaction canary must not be empty"),
155 Self::Exposed => formatter.write_str("redaction canary remains in serialized output"),
156 Self::InspectionLimitExceeded => formatter.write_str(
157 "serialized output is still encoded after the inspection limit; canary absence is unproven",
158 ),
159 }
160 }
161}
162
163impl std::error::Error for RedactionCanaryError {}
164
165pub fn assert_redaction_canary_absent(
182 serialized: &str,
183 canary: &str,
184) -> Result<(), RedactionCanaryError> {
185 if canary.is_empty() {
186 return Err(RedactionCanaryError::EmptyCanary);
187 }
188 let mut current = std::borrow::Cow::Borrowed(serialized);
189 for _ in 0..64 {
192 if current.contains(canary) {
193 return Err(RedactionCanaryError::Exposed);
194 }
195 let decoded = decode_output_escapes(¤t);
196 if decoded == current {
197 return Ok(());
198 }
199 current = std::borrow::Cow::Owned(decoded);
200 }
201 Err(RedactionCanaryError::InspectionLimitExceeded)
202}
203
204fn decode_output_escapes(text: &str) -> String {
210 let bytes = text.as_bytes();
211 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
212 let mut index = 0;
213 while index < bytes.len() {
214 match bytes[index] {
215 b'\\' => match decode_backslash_escape(&bytes[index + 1..]) {
216 Some((decoded, consumed)) => {
217 out.extend_from_slice(decoded.as_bytes());
218 index += 1 + consumed;
219 }
220 None => {
221 out.push(bytes[index]);
222 index += 1;
223 }
224 },
225 b'%' => match percent_byte(&bytes[index + 1..]) {
226 Some(byte) => {
227 out.push(byte);
228 index += 3;
229 }
230 None => {
231 out.push(bytes[index]);
232 index += 1;
233 }
234 },
235 other => {
236 out.push(other);
237 index += 1;
238 }
239 }
240 }
241 String::from_utf8_lossy_owned(out)
242}
243
244fn decode_backslash_escape(rest: &[u8]) -> Option<(String, usize)> {
247 let simple = |character: char| Some((character.to_string(), 1));
248 match *rest.first()? {
249 b'n' => simple('\n'),
250 b'r' => simple('\r'),
251 b't' => simple('\t'),
252 b'b' => simple('\u{0008}'),
253 b'f' => simple('\u{000c}'),
254 b'v' => simple('\u{000b}'),
255 b'0' => simple('\0'),
256 b'\\' => simple('\\'),
257 b'"' => simple('"'),
258 b'/' => simple('/'),
259 b'u' => {
260 let first = hex4(rest.get(1..)?)?;
261 if (0xd800..0xdc00).contains(&first) {
264 let low = rest
265 .get(5..7)
266 .filter(|marker| *marker == b"\\u".as_slice())
267 .and_then(|_| rest.get(7..))
268 .and_then(hex4)
269 .filter(|value| (0xdc00..0xe000).contains(value))?;
270 let combined = 0x10000 + ((first - 0xd800) << 10) + (low - 0xdc00);
271 return char::from_u32(combined).map(|character| (character.to_string(), 11));
272 }
273 char::from_u32(first).map(|character| (character.to_string(), 5))
274 }
275 _ => None,
276 }
277}
278
279fn hex4(bytes: &[u8]) -> Option<u32> {
280 let digits = bytes.get(..4)?;
281 std::str::from_utf8(digits)
282 .ok()
283 .and_then(|text| u32::from_str_radix(text, 16).ok())
284}
285
286fn percent_byte(rest: &[u8]) -> Option<u8> {
287 let digits = rest.get(..2)?;
288 std::str::from_utf8(digits)
289 .ok()
290 .and_then(|text| u8::from_str_radix(text, 16).ok())
291}
292
293fn lint_value_at(value: &Value, pointer: &str, findings: &mut Vec<LintFinding>) {
294 lint_unsafe_integer(value, pointer, findings);
295 match value {
296 Value::Object(map) => {
297 if let Some(Value::Object(properties)) = map.get("properties") {
298 for (name, schema) in properties {
299 lint_schema_property(
300 name,
301 schema,
302 &join_pointer(pointer, "properties"),
303 findings,
304 );
305 }
306 }
307 for (key, child) in map {
308 if key == "properties" && child.is_object() {
310 continue;
311 }
312 let child_pointer = join_pointer(pointer, key);
313 lint_suffix_type(key, child, &child_pointer, findings);
314 lint_missing_suffix(key, child, &child_pointer, findings);
315 lint_value_at(child, &child_pointer, findings);
316 }
317 }
318 Value::Array(items) => {
319 for (index, item) in items.iter().enumerate() {
320 lint_value_at(item, &join_pointer(pointer, &index.to_string()), findings);
321 }
322 }
323 _ => {}
324 }
325}
326
327fn lint_schema_property(
328 name: &str,
329 schema: &Value,
330 properties_pointer: &str,
331 findings: &mut Vec<LintFinding>,
332) {
333 let property_pointer = join_pointer(properties_pointer, name);
334 let normalized_name = name.to_ascii_lowercase();
335 if !matches!(schema, Value::Object(_) | Value::Bool(_)) {
336 lint_suffix_type(name, schema, &property_pointer, findings);
337 lint_value_at(schema, &property_pointer, findings);
338 return;
339 }
340 if normalized_name.ends_with("_secret")
341 && let Some(object) = schema.as_object()
342 {
343 for field in ["default", "example"] {
344 if let Some(value) = object.get(field)
345 && !is_redacted_secret_literal(value)
346 {
347 findings.push(LintFinding::error(
348 "secret_schema_value_exposed",
349 join_pointer(&property_pointer, field),
350 format!("schema property {name:?} exposes secret {field}"),
351 ));
352 }
353 }
354 if let Some(Value::Array(examples)) = object.get("examples") {
355 for (index, value) in examples.iter().enumerate() {
356 if !is_redacted_secret_literal(value) {
357 findings.push(LintFinding::error(
358 "secret_schema_value_exposed",
359 join_pointer(
360 &join_pointer(&property_pointer, "examples"),
361 &index.to_string(),
362 ),
363 format!("schema property {name:?} exposes secret example"),
364 ));
365 }
366 }
367 }
368 }
369 lint_schema_suffix_type(name, schema, &property_pointer, findings);
370 lint_value_at(schema, &property_pointer, findings);
371}
372
373fn lint_schema_suffix_type(
374 name: &str,
375 schema: &Value,
376 pointer: &str,
377 findings: &mut Vec<LintFinding>,
378) {
379 let normalized = name.to_ascii_lowercase();
380 let (expected, description): (&[&str], &str) = if normalized.ends_with("_bytes") {
381 (&["integer"], "an integer byte count")
382 } else if normalized.ends_with("_epoch_s") || normalized.ends_with("_epoch_ms") {
383 (&["integer"], "an integer epoch timestamp")
384 } else if normalized.ends_with("_epoch_ns") {
385 (&["string"], "a decimal integer string")
386 } else if normalized.ends_with("_sats") || normalized.ends_with("_msats") {
387 (
388 &["integer", "string"],
389 "an integer or decimal integer string",
390 )
391 } else if normalized.ends_with("_percent") || is_duration_suffix(&normalized) {
392 (&["integer", "number"], "a numeric value")
393 } else if is_currency_minor_unit_suffix(&normalized) {
394 (&["integer"], "an integer currency amount")
395 } else if normalized.ends_with("_rfc3339")
396 || normalized.ends_with("_url")
397 || normalized.ends_with("_bcp47")
398 || normalized.ends_with("_rfc3339_date")
399 || normalized.ends_with("_rfc3339_time")
400 || normalized.ends_with("_utc_offset")
401 {
402 (&["string"], "a string")
403 } else {
404 return;
405 };
406
407 if !schema_accepts_any_type(schema, expected) {
408 findings.push(LintFinding::error(
409 "suffix_type_mismatch",
410 join_pointer(pointer, "type"),
411 format!("schema property {name:?} must allow {description}"),
412 ));
413 }
414}
415
416fn schema_accepts_any_type(schema: &Value, expected: &[&str]) -> bool {
417 let Some(object) = schema.as_object() else {
418 return true;
419 };
420
421 if let Some(schema_type) = object.get("type") {
422 return match schema_type {
423 Value::String(value) => expected.contains(&value.as_str()),
424 Value::Array(values) => values.iter().any(|value| {
425 value
426 .as_str()
427 .is_some_and(|value| expected.contains(&value))
428 }),
429 _ => true,
430 };
431 }
432
433 for keyword in ["anyOf", "oneOf"] {
434 if let Some(Value::Array(branches)) = object.get(keyword) {
435 return branches
436 .iter()
437 .any(|branch| schema_accepts_any_type(branch, expected));
438 }
439 }
440 if let Some(Value::Array(branches)) = object.get("allOf") {
441 return branches
442 .iter()
443 .all(|branch| schema_accepts_any_type(branch, expected));
444 }
445 true
446}
447
448fn is_redacted_secret_literal(value: &Value) -> bool {
449 matches!(value, Value::Null) || matches!(value, Value::String(text) if text == "***")
450}
451
452const REGISTERED_SUFFIXES: &[(&str, &[&str])] = &[
453 (
454 "duration",
455 &["_ns", "_us", "_ms", "_s", "_minutes", "_hours", "_days"],
456 ),
457 (
458 "timestamp",
459 &["_epoch_s", "_epoch_ms", "_epoch_ns", "_rfc3339"],
460 ),
461 (
462 "strict_string",
463 &["_rfc3339_date", "_rfc3339_time", "_bcp47", "_utc_offset"],
464 ),
465 ("size", &["_bytes"]),
466 ("percentage", &["_percent"]),
467 (
468 "currency",
469 &[
470 "_msats",
471 "_sats",
472 "_usd_cents",
473 "_eur_cents",
474 "_jpy",
475 "_{code}_cents",
476 "_{code}_micro",
477 ],
478 ),
479 ("sensitive", &["_secret", "_url"]),
480];
481
482const UNSUFFIXED_STEMS: &[(&str, &str)] = &[
483 ("timeout", "duration"),
484 ("elapsed", "duration"),
485 ("duration", "duration"),
486 ("ttl", "duration"),
487 ("interval", "duration"),
488 ("latency", "duration"),
489 ("delay", "duration"),
490 ("uptime", "duration"),
491 ("price", "currency"),
492 ("amount", "currency"),
493 ("cost", "currency"),
494 ("fee", "currency"),
495 ("balance", "currency"),
496 ("subtotal", "currency"),
497 ("revenue", "currency"),
498 ("created", "timestamp"),
499 ("updated", "timestamp"),
500 ("modified", "timestamp"),
501 ("expires", "timestamp"),
502 ("issued", "timestamp"),
503 ("timestamp", "timestamp"),
504 ("apikey", "sensitive"),
505 ("api_key", "sensitive"),
506 ("token", "sensitive"),
507 ("password", "sensitive"),
508 ("passwd", "sensitive"),
509 ("secret", "sensitive"),
510 ("credential", "sensitive"),
511 ("credentials", "sensitive"),
512];
513
514fn has_registered_suffix(key: &str) -> bool {
522 REGISTERED_SUFFIXES
523 .iter()
524 .flat_map(|(_, suffixes)| suffixes.iter())
525 .any(|suffix| match suffix.strip_prefix("_{code}") {
526 Some(tail) => crate::formatting::strip_suffix_ci(key, tail)
527 .as_deref()
528 .and_then(|rest| rest.rsplit_once('_').map(|(_, code)| code.to_string()))
529 .is_some_and(|code| {
530 (3..=4).contains(&code.len())
531 && code
532 .chars()
533 .all(|character| character.is_ascii_alphabetic())
534 }),
535 None => crate::formatting::has_suffix_ci(key, suffix),
536 })
537}
538
539fn lint_missing_suffix(key: &str, value: &Value, pointer: &str, findings: &mut Vec<LintFinding>) {
540 if value.is_null() || has_registered_suffix(key) {
541 return;
542 }
543 let lower = key.to_ascii_lowercase();
544 let category = if lower.ends_with("_at") {
545 Some("timestamp")
546 } else {
547 UNSUFFIXED_STEMS
548 .iter()
549 .find(|(stem, _)| lower == *stem || lower.ends_with(&format!("_{stem}")))
550 .map(|(_, category)| *category)
551 };
552 let Some(category) = category else {
553 return;
554 };
555 let plausible = match category {
556 "duration" | "currency" | "size" | "percentage" => value.is_number(),
557 "timestamp" => value.is_number() || value.is_string(),
558 _ => value.is_string(),
559 };
560 if !plausible {
561 return;
562 }
563 let suffixes = REGISTERED_SUFFIXES
564 .iter()
565 .find(|(name, _)| *name == category)
566 .map(|(_, suffixes)| suffixes.join(", "))
567 .unwrap_or_default();
568 let message = if category == "sensitive" {
569 format!(
570 "`{key}` looks like a credential but is not marked, so it is printed and logged in \
571 the clear. Rename it with one of: {suffixes}"
572 )
573 } else {
574 format!(
575 "`{key}` names a {category} but carries no unit, so a reader cannot tell what it \
576 means without asking. Rename it with one of: {suffixes}"
577 )
578 };
579 findings.push(LintFinding::warning(
580 "missing_suffix",
581 pointer.to_string(),
582 message,
583 ));
584}
585
586fn lint_suffix_type(key: &str, value: &Value, pointer: &str, findings: &mut Vec<LintFinding>) {
587 if value.is_null() {
588 return;
589 }
590 let has = |suffix: &str| crate::formatting::has_suffix_ci(key, suffix);
595 let message = if has("_bytes") {
596 (!is_non_negative_integer(value))
597 .then(|| format!("{key:?} must be a non-negative integer byte count"))
598 } else if has("_epoch_s") || has("_epoch_ms") {
599 (!is_integer(value)).then(|| format!("{key:?} must be an integer epoch timestamp"))
600 } else if has("_epoch_ns") {
601 (!is_decimal_integer_string(value))
602 .then(|| format!("{key:?} must be a decimal integer string"))
603 } else if has("_sats") || has("_msats") {
604 (!(is_integer(value) || is_decimal_integer_string(value)))
605 .then(|| format!("{key:?} must be an integer or decimal integer string"))
606 } else if has("_percent") {
607 (!value.is_number()).then(|| format!("{key:?} must be numeric"))
608 } else if is_duration_suffix(key) {
609 (!value.is_number()).then(|| format!("{key:?} must be a numeric duration"))
610 } else if is_currency_minor_unit_suffix(key) {
611 (!is_integer(value)).then(|| format!("{key:?} must be an integer currency amount"))
612 } else if has("_rfc3339") {
613 if value.as_str().is_some_and(is_valid_rfc3339) {
614 None
615 } else if value.is_string() {
616 Some(format!(
617 "{key:?} must be an RFC 3339 date-time with a mandatory offset (e.g. \
618 2026-02-14T10:30:00Z)"
619 ))
620 } else {
621 Some(format!("{key:?} must be a string"))
622 }
623 } else if has("_url") {
624 match url_field_violation(value) {
628 UrlFieldCheck::Ok => None,
629 UrlFieldCheck::NotAUrl => Some(format!(
630 "{key:?} must be a single URL (no internal whitespace or bare credentials)"
631 )),
632 UrlFieldCheck::NotAString => Some(format!(
633 "{key:?} must be a URL string, or a collection whose leaves are URL strings"
634 )),
635 }
636 } else if has("_bcp47") {
637 if value.as_str().is_some_and(is_valid_bcp47) {
638 None
639 } else if value.is_string() {
640 Some(format!("{key:?} must be a well-formed BCP 47 language tag"))
641 } else {
642 Some(format!("{key:?} must be a string"))
643 }
644 } else if has("_rfc3339_date") {
645 if value.as_str().is_some_and(is_valid_rfc3339_date) {
646 None
647 } else if value.is_string() {
648 Some(format!(
649 "{key:?} must be an RFC 3339 full-date (YYYY-MM-DD)"
650 ))
651 } else {
652 Some(format!("{key:?} must be a string"))
653 }
654 } else if has("_rfc3339_time") {
655 if value.as_str().is_some_and(is_valid_rfc3339_time) {
656 None
657 } else if value.is_string() {
658 Some(format!(
659 "{key:?} must be an RFC 3339 partial-time (HH:MM:SS[.fraction], no Z or offset)"
660 ))
661 } else {
662 Some(format!("{key:?} must be a string"))
663 }
664 } else if has("_utc_offset") {
665 if value.as_str().and_then(normalize_utc_offset).is_some() {
666 None
667 } else if value.is_string() {
668 Some(format!(
669 "{key:?} must be a fixed UTC offset (\"UTC\" or ±HH:MM)"
670 ))
671 } else {
672 Some(format!("{key:?} must be a string"))
673 }
674 } else {
675 None
676 };
677 if let Some(message) = message {
678 findings.push(LintFinding::error(
679 "suffix_type_mismatch",
680 pointer.to_string(),
681 message,
682 ));
683 }
684}
685
686fn lint_unsafe_integer(value: &Value, pointer: &str, findings: &mut Vec<LintFinding>) {
687 let Value::Number(number) = value else {
688 return;
689 };
690 if !number_is_integer_literal(number) {
691 return;
692 }
693 let exceeds_safe_range = if let Some(value) = number.as_i128() {
694 value.unsigned_abs() > u128::from(MAX_SAFE_INTEGER)
695 } else if let Some(value) = number.as_u128() {
696 value > u128::from(MAX_SAFE_INTEGER)
697 } else if let Some(value) = number.as_f64() {
698 value.abs() > MAX_SAFE_INTEGER as f64
699 } else {
700 true
703 };
704 if exceeds_safe_range {
705 findings.push(unsafe_integer_finding(pointer));
706 }
707}
708
709fn unsafe_integer_finding(pointer: &str) -> LintFinding {
710 LintFinding::error(
711 "unsafe_integer",
712 pointer.to_string(),
713 "integer exceeds JavaScript safe integer range ±(2^53-1)".to_string(),
714 )
715}
716
717fn is_integer(value: &Value) -> bool {
718 matches!(value, Value::Number(number) if number_is_integer(number))
719}
720
721fn is_non_negative_integer(value: &Value) -> bool {
722 let Value::Number(number) = value else {
723 return false;
724 };
725 if !number_is_integer(number) {
726 return false;
727 }
728 let text = number.to_string();
729 !text.starts_with('-') || decimal_number_is_zero(&text)
730}
731
732fn number_is_integer(number: &Number) -> bool {
738 decimal_number_is_integer(&number.to_string())
739}
740
741fn number_is_integer_literal(number: &Number) -> bool {
749 crate::formatting::number_is_integer_literal(number)
750}
751
752fn decimal_number_is_integer(text: &str) -> bool {
753 let unsigned = text.strip_prefix('-').unwrap_or(text);
754 let exponent_index = unsigned.find(['e', 'E']);
755 let (mantissa, exponent_text) = exponent_index.map_or((unsigned, None), |index| {
756 (&unsigned[..index], Some(&unsigned[index + 1..]))
757 });
758 if mantissa
759 .bytes()
760 .filter(|byte| *byte != b'.')
761 .all(|byte| byte == b'0')
762 {
763 return true;
764 }
765
766 let fraction_digits = mantissa
767 .split_once('.')
768 .map_or(0, |(_, fraction)| fraction.len());
769 let exponent = exponent_text.map_or(0, |value| {
770 value.parse::<i128>().unwrap_or_else(|_| {
771 if value.starts_with('-') {
772 i128::MIN
773 } else {
774 i128::MAX
775 }
776 })
777 });
778 let scale = exponent.saturating_sub(i128::try_from(fraction_digits).unwrap_or(i128::MAX));
779 if scale >= 0 {
780 return true;
781 }
782
783 let required_trailing_zeroes = scale.unsigned_abs();
784 let digit_count = mantissa.bytes().filter(|byte| *byte != b'.').count();
785 if required_trailing_zeroes > digit_count as u128 {
786 return false;
787 }
788 mantissa
789 .bytes()
790 .filter(|byte| *byte != b'.')
791 .rev()
792 .take(required_trailing_zeroes as usize)
793 .all(|byte| byte == b'0')
794}
795
796fn decimal_number_is_zero(text: &str) -> bool {
797 let unsigned = text.strip_prefix('-').unwrap_or(text);
798 let mantissa = unsigned
799 .split_once(['e', 'E'])
800 .map_or(unsigned, |(mantissa, _)| mantissa);
801 mantissa
802 .bytes()
803 .filter(|byte| *byte != b'.')
804 .all(|byte| byte == b'0')
805}
806
807fn is_decimal_integer_string(value: &Value) -> bool {
808 let Value::String(text) = value else {
809 return false;
810 };
811 let digits = text.strip_prefix('-').unwrap_or(text);
812 !digits.is_empty() && digits.chars().all(|character| character.is_ascii_digit())
813}
814
815fn is_duration_suffix(key: &str) -> bool {
816 ["_ns", "_us", "_ms", "_s", "_minutes", "_hours", "_days"]
817 .iter()
818 .any(|suffix| crate::formatting::has_suffix_ci(key, suffix))
819}
820
821fn is_currency_minor_unit_suffix(key: &str) -> bool {
827 crate::formatting::extract_currency_code(key).is_some()
828 || crate::formatting::extract_currency_code_micro(key).is_some()
829 || crate::formatting::has_suffix_ci(key, "_jpy")
830}
831
832enum UrlFieldCheck {
834 Ok,
835 NotAUrl,
836 NotAString,
837}
838
839fn url_field_violation(value: &Value) -> UrlFieldCheck {
842 match value {
843 Value::Null => UrlFieldCheck::Ok,
844 Value::String(text) => {
845 if is_wellformed_url_field(text) {
846 UrlFieldCheck::Ok
847 } else {
848 UrlFieldCheck::NotAUrl
849 }
850 }
851 Value::Array(items) => first_url_violation(items.iter()),
852 Value::Object(entries) => first_url_violation(entries.values()),
853 _ => UrlFieldCheck::NotAString,
854 }
855}
856
857fn first_url_violation<'value>(values: impl Iterator<Item = &'value Value>) -> UrlFieldCheck {
858 for value in values {
859 match url_field_violation(value) {
860 UrlFieldCheck::Ok => {}
861 violation => return violation,
862 }
863 }
864 UrlFieldCheck::Ok
865}
866
867fn is_wellformed_url_field(value: &str) -> bool {
868 if is_scheme_prefixed_url(value) || is_scheme_prefixed_url(value.trim()) {
869 return true;
870 }
871 !value.chars().any(char::is_whitespace) && !value.contains('@')
872}
873
874fn is_scheme_prefixed_url(value: &str) -> bool {
875 if value.bytes().any(|byte| byte.is_ascii_whitespace()) {
876 return false;
877 }
878 let bytes = value.as_bytes();
879 if !bytes.first().is_some_and(u8::is_ascii_alphabetic) {
880 return false;
881 }
882 let mut index = 1;
883 while index < bytes.len() {
884 let character = bytes[index];
885 if character.is_ascii_alphanumeric() || matches!(character, b'+' | b'-' | b'.') {
886 index += 1;
887 } else {
888 break;
889 }
890 }
891 value[index..].starts_with("://")
892}
893
894fn join_pointer(base: &str, token: &str) -> String {
895 let escaped = token.replace('~', "~0").replace('/', "~1");
896 if base.is_empty() {
897 format!("/{escaped}")
898 } else {
899 format!("{base}/{escaped}")
900 }
901}
902
903#[cfg(test)]
904mod tests {
905 use super::{
906 LintOptions, LintSeverity, REGISTERED_SUFFIXES, RedactionCanaryError,
907 assert_no_lint_findings, assert_redaction_canary_absent, assert_strict_event, lint_value,
908 };
909 use serde_json::{Value, json};
910
911 #[test]
912 fn public_lint_api_reports_and_filters_findings() {
913 let value = json!({
914 "timeout": 5,
915 "size_bytes": "large",
916 "created_rfc3339": "not-a-time"
917 });
918 let findings = lint_value(&value, LintOptions::default());
919 assert_eq!(findings.len(), 3);
920 assert!(
921 findings
922 .iter()
923 .any(|finding| finding.severity == LintSeverity::Warning)
924 );
925 let errors = lint_value(&value, LintOptions::errors_only());
926 assert_eq!(errors.len(), 2);
927 }
928
929 #[test]
930 fn integer_suffixes_accept_integral_decimals_and_signed_currency() {
931 let value: Value = serde_json::from_str(
932 r#"{
933 "price_usd_cents": -1,
934 "refund_eur_cents": -3.0,
935 "size_bytes": 3.0,
936 "created_epoch_ms": 3e0
937 }"#,
938 )
939 .unwrap_or_else(|error| panic!("{error}"));
940
941 assert!(lint_value(&value, LintOptions::errors_only()).is_empty());
942 }
943
944 #[test]
945 fn registered_currency_suffixes_accept_three_or_four_letter_codes() {
946 assert!(super::has_registered_suffix("fare_thb_cents"));
947 assert!(super::has_registered_suffix("deposit_usdt_cents"));
948 assert!(!super::has_registered_suffix("total_amount_cents"));
949 assert!(!super::has_registered_suffix("price_usdtx_cents"));
950 }
951
952 #[test]
953 fn integer_suffixes_reject_fractional_values_and_negative_bytes() {
954 let value: Value = serde_json::from_str(
955 r#"{
956 "price_usd_cents": 3.5,
957 "size_bytes": -1
958 }"#,
959 )
960 .unwrap_or_else(|error| panic!("{error}"));
961 let findings = lint_value(&value, LintOptions::errors_only());
962
963 assert_eq!(
964 findings
965 .iter()
966 .filter(|finding| finding.rule_id == "suffix_type_mismatch")
967 .count(),
968 2
969 );
970 }
971
972 #[test]
973 fn unsafe_integer_finds_values_beyond_u128() {
974 let value: Value =
975 serde_json::from_str(r#"{"huge_count":340282366920938463463374607431768211456}"#)
976 .unwrap_or_else(|error| panic!("{error}"));
977 let findings = lint_value(&value, LintOptions::errors_only());
978
979 assert!(
980 findings
981 .iter()
982 .any(|finding| finding.rule_id == "unsafe_integer"),
983 "{findings:?}"
984 );
985 }
986
987 #[test]
988 fn assertion_helpers_cover_protocol_lint_and_redaction() {
989 let event = json!({
990 "kind": "result",
991 "result": {"code": "ready"},
992 "trace": {}
993 });
994 assert!(assert_strict_event(&event).is_ok());
995 assert!(assert_no_lint_findings(&event).is_ok());
996 assert!(assert_redaction_canary_absent("{\"secret\":\"***\"}", "canary-42").is_ok());
997 assert_eq!(
998 assert_redaction_canary_absent("contains canary-42", "canary-42"),
999 Err(RedactionCanaryError::Exposed)
1000 );
1001 }
1002
1003 #[test]
1008 fn missing_suffix_reads_intent_from_the_name() {
1009 for (label, value) in [
1010 ("bare dimension name", json!({"timeout": 5000})),
1011 ("suffixed dimension name", json!({"request_timeout": 1})),
1012 ("_at timestamp", json!({"expires_at": 1})),
1013 ] {
1014 let findings = lint_value(&json!(value), LintOptions::default());
1015 assert!(
1016 findings.iter().any(|f| f.rule_id == "missing_suffix"),
1017 "{label}: expected a missing_suffix warning, got {findings:?}"
1018 );
1019 }
1020
1021 for (label, value) in [
1022 ("already labelled duration", json!({"timeout_ms": 5000})),
1023 ("already labelled currency", json!({"price_gbp_cents": 1})),
1024 ("already labelled secret", json!({"api_key_secret": "sk"})),
1025 (
1028 "dimension name over a container",
1029 json!({"timeout": {"connect": 1}}),
1030 ),
1031 ("credentials block", json!({"credentials": {"user": "x"}})),
1032 ("unrelated name", json!({"name": "demo"})),
1033 ] {
1034 let findings = lint_value(&json!(value), LintOptions::default());
1035 assert!(
1036 findings.is_empty(),
1037 "{label}: expected no findings, got {findings:?}"
1038 );
1039 }
1040 }
1041
1042 #[test]
1046 fn redaction_canary_survives_every_output_escape() {
1047 for (label, canary) in [
1048 (
1049 "pem newlines",
1050 "-----BEGIN KEY-----\nabc\n-----END KEY-----",
1051 ),
1052 ("double quote", "p@ss\"word"),
1053 ("backslash path", "C:\\Users\\me\\key"),
1054 ("tab", "a\tb"),
1055 ("non-bmp", "k\u{1f511}ey"),
1056 ] {
1057 let rendered = crate::render(
1058 &json!({ "note": canary }),
1059 crate::OutputFormat::Json,
1060 &crate::OutputOptions::default(),
1061 );
1062 assert!(
1063 rendered.contains("note"),
1064 "{label}: expected the field to survive rendering"
1065 );
1066 assert_eq!(
1067 assert_redaction_canary_absent(&rendered, canary),
1068 Err(RedactionCanaryError::Exposed),
1069 "{label}: escaped canary must still be reported, output was {rendered}"
1070 );
1071 }
1072 }
1073
1074 #[test]
1077 fn redaction_canary_is_found_percent_encoded() {
1078 assert_eq!(
1079 assert_redaction_canary_absent("{\"note\":\"https://h/p?q=a%20b%20c\"}", "a b c"),
1080 Err(RedactionCanaryError::Exposed)
1081 );
1082 }
1083
1084 #[test]
1085 fn redaction_canary_is_found_through_nested_and_mixed_encoding() {
1086 for serialized in [
1087 r#"{"note":"https://h/?next=https%3A%2F%2Fb%2F%3Fq%3D%2573ecret"}"#,
1088 r#"{"note":"\u002573ecret"}"#,
1089 r#"{"note":"%5Cu0073ecret"}"#,
1090 r#"{"note":"%255CuD83D%255CuDD11"}"#,
1091 ] {
1092 let canary = if serialized.contains("D83D") {
1093 "🔑"
1094 } else {
1095 "secret"
1096 };
1097 assert_eq!(
1098 assert_redaction_canary_absent(serialized, canary),
1099 Err(RedactionCanaryError::Exposed),
1100 "encoded output must still report the canary: {serialized}"
1101 );
1102 }
1103 assert_eq!(
1104 assert_redaction_canary_absent("%25not-a-canary", "secret"),
1105 Ok(())
1106 );
1107 }
1108
1109 #[test]
1110 fn redaction_canary_inspection_fails_closed_on_excessive_encoding_depth() {
1111 let ordinary = format!("%{}73afe", "25".repeat(15));
1112 assert_eq!(assert_redaction_canary_absent(&ordinary, "secret"), Ok(()));
1113 let deep = format!("%{}73afe{}", "25".repeat(1000), "x".repeat(4096));
1116 assert_eq!(
1117 assert_redaction_canary_absent(&deep, "secret"),
1118 Err(RedactionCanaryError::InspectionLimitExceeded)
1119 );
1120 }
1121
1122 #[test]
1125 fn redaction_canary_stays_quiet_when_the_value_was_redacted() {
1126 for canary in [
1127 "-----BEGIN KEY-----\nabc\n-----END KEY-----",
1128 "p@ss\"word",
1129 "C:\\Users\\me\\key",
1130 "k\u{1f511}ey",
1131 ] {
1132 for format in [
1133 crate::OutputFormat::Json,
1134 crate::OutputFormat::Yaml,
1135 crate::OutputFormat::Plain,
1136 ] {
1137 let rendered = crate::render(
1138 &json!({ "note_secret": canary }),
1139 format,
1140 &crate::OutputOptions::default(),
1141 );
1142 assert_eq!(
1143 assert_redaction_canary_absent(&rendered, canary),
1144 Ok(()),
1145 "redacted output must not report a leak, output was {rendered}"
1146 );
1147 }
1148 }
1149 }
1150
1151 #[test]
1152 fn registry_suffixes_match_the_lint_table() {
1153 const REGISTRY: &str =
1154 include_str!("../../skills/agent-first-data/references/registry.json");
1155 let registry: Value =
1156 serde_json::from_str(REGISTRY).unwrap_or_else(|error| panic!("{error}"));
1157 let suffixes = registry["suffixes"]
1158 .as_array()
1159 .map(Vec::as_slice)
1160 .unwrap_or(&[]);
1161 let mut from_registry: Vec<(String, String)> = suffixes
1162 .iter()
1163 .filter_map(|entry| {
1164 Some((
1165 entry["category"].as_str()?.to_string(),
1166 entry["suffix"].as_str()?.to_string(),
1167 ))
1168 })
1169 .collect();
1170 let mut from_table: Vec<(String, String)> = REGISTERED_SUFFIXES
1171 .iter()
1172 .flat_map(|(category, suffixes)| {
1173 suffixes
1174 .iter()
1175 .map(move |suffix| ((*category).to_string(), (*suffix).to_string()))
1176 })
1177 .collect();
1178 from_registry.sort();
1179 from_table.sort();
1180 assert_eq!(from_table, from_registry);
1181 }
1182}