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,
145}
146
147impl fmt::Display for RedactionCanaryError {
148 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149 match self {
150 Self::EmptyCanary => formatter.write_str("redaction canary must not be empty"),
151 Self::Exposed => formatter.write_str("redaction canary remains in serialized output"),
152 }
153 }
154}
155
156impl std::error::Error for RedactionCanaryError {}
157
158pub fn assert_redaction_canary_absent(
173 serialized: &str,
174 canary: &str,
175) -> Result<(), RedactionCanaryError> {
176 if canary.is_empty() {
177 return Err(RedactionCanaryError::EmptyCanary);
178 }
179 if serialized.contains(canary) || decode_output_escapes(serialized).contains(canary) {
180 Err(RedactionCanaryError::Exposed)
181 } else {
182 Ok(())
183 }
184}
185
186fn decode_output_escapes(text: &str) -> String {
192 let bytes = text.as_bytes();
193 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
194 let mut index = 0;
195 while index < bytes.len() {
196 match bytes[index] {
197 b'\\' => match decode_backslash_escape(&bytes[index + 1..]) {
198 Some((decoded, consumed)) => {
199 out.extend_from_slice(decoded.as_bytes());
200 index += 1 + consumed;
201 }
202 None => {
203 out.push(bytes[index]);
204 index += 1;
205 }
206 },
207 b'%' => match percent_byte(&bytes[index + 1..]) {
208 Some(byte) => {
209 out.push(byte);
210 index += 3;
211 }
212 None => {
213 out.push(bytes[index]);
214 index += 1;
215 }
216 },
217 other => {
218 out.push(other);
219 index += 1;
220 }
221 }
222 }
223 String::from_utf8_lossy(&out).into_owned()
224}
225
226fn decode_backslash_escape(rest: &[u8]) -> Option<(String, usize)> {
229 let simple = |character: char| Some((character.to_string(), 1));
230 match *rest.first()? {
231 b'n' => simple('\n'),
232 b'r' => simple('\r'),
233 b't' => simple('\t'),
234 b'b' => simple('\u{0008}'),
235 b'f' => simple('\u{000c}'),
236 b'v' => simple('\u{000b}'),
237 b'0' => simple('\0'),
238 b'\\' => simple('\\'),
239 b'"' => simple('"'),
240 b'/' => simple('/'),
241 b'u' => {
242 let first = hex4(rest.get(1..)?)?;
243 if (0xd800..0xdc00).contains(&first) {
246 let low = rest
247 .get(5..7)
248 .filter(|marker| *marker == b"\\u".as_slice())
249 .and_then(|_| rest.get(7..))
250 .and_then(hex4)
251 .filter(|value| (0xdc00..0xe000).contains(value))?;
252 let combined = 0x10000 + ((first - 0xd800) << 10) + (low - 0xdc00);
253 return char::from_u32(combined).map(|character| (character.to_string(), 11));
254 }
255 char::from_u32(first).map(|character| (character.to_string(), 5))
256 }
257 _ => None,
258 }
259}
260
261fn hex4(bytes: &[u8]) -> Option<u32> {
262 let digits = bytes.get(..4)?;
263 std::str::from_utf8(digits)
264 .ok()
265 .and_then(|text| u32::from_str_radix(text, 16).ok())
266}
267
268fn percent_byte(rest: &[u8]) -> Option<u8> {
269 let digits = rest.get(..2)?;
270 std::str::from_utf8(digits)
271 .ok()
272 .and_then(|text| u8::from_str_radix(text, 16).ok())
273}
274
275fn lint_value_at(value: &Value, pointer: &str, findings: &mut Vec<LintFinding>) {
276 lint_unsafe_integer(value, pointer, findings);
277 match value {
278 Value::Object(map) => {
279 if let Some(Value::Object(properties)) = map.get("properties") {
280 for (name, schema) in properties {
281 lint_schema_property(
282 name,
283 schema,
284 &join_pointer(pointer, "properties"),
285 findings,
286 );
287 }
288 }
289 for (key, child) in map {
290 if key == "properties" && child.is_object() {
292 continue;
293 }
294 let child_pointer = join_pointer(pointer, key);
295 lint_suffix_type(key, child, &child_pointer, findings);
296 lint_missing_suffix(key, child, &child_pointer, findings);
297 lint_value_at(child, &child_pointer, findings);
298 }
299 }
300 Value::Array(items) => {
301 for (index, item) in items.iter().enumerate() {
302 lint_value_at(item, &join_pointer(pointer, &index.to_string()), findings);
303 }
304 }
305 _ => {}
306 }
307}
308
309fn lint_schema_property(
310 name: &str,
311 schema: &Value,
312 properties_pointer: &str,
313 findings: &mut Vec<LintFinding>,
314) {
315 let property_pointer = join_pointer(properties_pointer, name);
316 let normalized_name = name.to_ascii_lowercase();
317 if !matches!(schema, Value::Object(_) | Value::Bool(_)) {
318 lint_suffix_type(name, schema, &property_pointer, findings);
319 lint_value_at(schema, &property_pointer, findings);
320 return;
321 }
322 if normalized_name.ends_with("_secret")
323 && let Some(object) = schema.as_object()
324 {
325 for field in ["default", "example"] {
326 if let Some(value) = object.get(field)
327 && !is_redacted_secret_literal(value)
328 {
329 findings.push(LintFinding::error(
330 "secret_schema_value_exposed",
331 join_pointer(&property_pointer, field),
332 format!("schema property {name:?} exposes secret {field}"),
333 ));
334 }
335 }
336 if let Some(Value::Array(examples)) = object.get("examples") {
337 for (index, value) in examples.iter().enumerate() {
338 if !is_redacted_secret_literal(value) {
339 findings.push(LintFinding::error(
340 "secret_schema_value_exposed",
341 join_pointer(
342 &join_pointer(&property_pointer, "examples"),
343 &index.to_string(),
344 ),
345 format!("schema property {name:?} exposes secret example"),
346 ));
347 }
348 }
349 }
350 }
351 lint_schema_suffix_type(name, schema, &property_pointer, findings);
352 lint_value_at(schema, &property_pointer, findings);
353}
354
355fn lint_schema_suffix_type(
356 name: &str,
357 schema: &Value,
358 pointer: &str,
359 findings: &mut Vec<LintFinding>,
360) {
361 let normalized = name.to_ascii_lowercase();
362 let (expected, description): (&[&str], &str) = if normalized.ends_with("_bytes") {
363 (&["integer"], "an integer byte count")
364 } else if normalized.ends_with("_epoch_s") || normalized.ends_with("_epoch_ms") {
365 (&["integer"], "an integer epoch timestamp")
366 } else if normalized.ends_with("_epoch_ns") {
367 (&["string"], "a decimal integer string")
368 } else if normalized.ends_with("_sats") || normalized.ends_with("_msats") {
369 (
370 &["integer", "string"],
371 "an integer or decimal integer string",
372 )
373 } else if normalized.ends_with("_percent") || is_duration_suffix(&normalized) {
374 (&["integer", "number"], "a numeric value")
375 } else if is_currency_minor_unit_suffix(&normalized) {
376 (&["integer"], "an integer currency amount")
377 } else if normalized.ends_with("_rfc3339")
378 || normalized.ends_with("_url")
379 || normalized.ends_with("_bcp47")
380 || normalized.ends_with("_rfc3339_date")
381 || normalized.ends_with("_rfc3339_time")
382 || normalized.ends_with("_utc_offset")
383 {
384 (&["string"], "a string")
385 } else {
386 return;
387 };
388
389 if !schema_accepts_any_type(schema, expected) {
390 findings.push(LintFinding::error(
391 "suffix_type_mismatch",
392 join_pointer(pointer, "type"),
393 format!("schema property {name:?} must allow {description}"),
394 ));
395 }
396}
397
398fn schema_accepts_any_type(schema: &Value, expected: &[&str]) -> bool {
399 let Some(object) = schema.as_object() else {
400 return true;
401 };
402
403 if let Some(schema_type) = object.get("type") {
404 return match schema_type {
405 Value::String(value) => expected.contains(&value.as_str()),
406 Value::Array(values) => values.iter().any(|value| {
407 value
408 .as_str()
409 .is_some_and(|value| expected.contains(&value))
410 }),
411 _ => true,
412 };
413 }
414
415 for keyword in ["anyOf", "oneOf"] {
416 if let Some(Value::Array(branches)) = object.get(keyword) {
417 return branches
418 .iter()
419 .any(|branch| schema_accepts_any_type(branch, expected));
420 }
421 }
422 if let Some(Value::Array(branches)) = object.get("allOf") {
423 return branches
424 .iter()
425 .all(|branch| schema_accepts_any_type(branch, expected));
426 }
427 true
428}
429
430fn is_redacted_secret_literal(value: &Value) -> bool {
431 matches!(value, Value::Null) || matches!(value, Value::String(text) if text == "***")
432}
433
434const REGISTERED_SUFFIXES: &[(&str, &[&str])] = &[
435 (
436 "duration",
437 &["_ns", "_us", "_ms", "_s", "_minutes", "_hours", "_days"],
438 ),
439 (
440 "timestamp",
441 &["_epoch_s", "_epoch_ms", "_epoch_ns", "_rfc3339"],
442 ),
443 (
444 "strict_string",
445 &["_rfc3339_date", "_rfc3339_time", "_bcp47", "_utc_offset"],
446 ),
447 ("size", &["_bytes"]),
448 ("percentage", &["_percent"]),
449 (
450 "currency",
451 &[
452 "_msats",
453 "_sats",
454 "_usd_cents",
455 "_eur_cents",
456 "_jpy",
457 "_{code}_cents",
458 "_{code}_micro",
459 ],
460 ),
461 ("sensitive", &["_secret", "_url"]),
462];
463
464const UNSUFFIXED_STEMS: &[(&str, &str)] = &[
465 ("timeout", "duration"),
466 ("elapsed", "duration"),
467 ("duration", "duration"),
468 ("ttl", "duration"),
469 ("interval", "duration"),
470 ("latency", "duration"),
471 ("delay", "duration"),
472 ("uptime", "duration"),
473 ("price", "currency"),
474 ("amount", "currency"),
475 ("cost", "currency"),
476 ("fee", "currency"),
477 ("balance", "currency"),
478 ("subtotal", "currency"),
479 ("revenue", "currency"),
480 ("created", "timestamp"),
481 ("updated", "timestamp"),
482 ("modified", "timestamp"),
483 ("expires", "timestamp"),
484 ("issued", "timestamp"),
485 ("timestamp", "timestamp"),
486 ("apikey", "sensitive"),
487 ("api_key", "sensitive"),
488 ("token", "sensitive"),
489 ("password", "sensitive"),
490 ("passwd", "sensitive"),
491 ("secret", "sensitive"),
492 ("credential", "sensitive"),
493 ("credentials", "sensitive"),
494];
495
496fn has_registered_suffix(key: &str) -> bool {
504 REGISTERED_SUFFIXES
505 .iter()
506 .flat_map(|(_, suffixes)| suffixes.iter())
507 .any(|suffix| match suffix.strip_prefix("_{code}") {
508 Some(tail) => crate::formatting::strip_suffix_ci(key, tail)
509 .as_deref()
510 .and_then(|rest| rest.rsplit_once('_').map(|(_, code)| code.to_string()))
511 .is_some_and(|code| {
512 (3..=4).contains(&code.len())
513 && code
514 .chars()
515 .all(|character| character.is_ascii_alphabetic())
516 }),
517 None => crate::formatting::has_suffix_ci(key, suffix),
518 })
519}
520
521fn lint_missing_suffix(key: &str, value: &Value, pointer: &str, findings: &mut Vec<LintFinding>) {
522 if value.is_null() || has_registered_suffix(key) {
523 return;
524 }
525 let lower = key.to_ascii_lowercase();
526 let category = if lower.ends_with("_at") {
527 Some("timestamp")
528 } else {
529 UNSUFFIXED_STEMS
530 .iter()
531 .find(|(stem, _)| lower == *stem || lower.ends_with(&format!("_{stem}")))
532 .map(|(_, category)| *category)
533 };
534 let Some(category) = category else {
535 return;
536 };
537 let plausible = match category {
538 "duration" | "currency" | "size" | "percentage" => value.is_number(),
539 "timestamp" => value.is_number() || value.is_string(),
540 _ => value.is_string(),
541 };
542 if !plausible {
543 return;
544 }
545 let suffixes = REGISTERED_SUFFIXES
546 .iter()
547 .find(|(name, _)| *name == category)
548 .map(|(_, suffixes)| suffixes.join(", "))
549 .unwrap_or_default();
550 let message = if category == "sensitive" {
551 format!(
552 "`{key}` looks like a credential but is not marked, so it is printed and logged in \
553 the clear. Rename it with one of: {suffixes}"
554 )
555 } else {
556 format!(
557 "`{key}` names a {category} but carries no unit, so a reader cannot tell what it \
558 means without asking. Rename it with one of: {suffixes}"
559 )
560 };
561 findings.push(LintFinding::warning(
562 "missing_suffix",
563 pointer.to_string(),
564 message,
565 ));
566}
567
568fn lint_suffix_type(key: &str, value: &Value, pointer: &str, findings: &mut Vec<LintFinding>) {
569 if value.is_null() {
570 return;
571 }
572 let has = |suffix: &str| crate::formatting::has_suffix_ci(key, suffix);
577 let message = if has("_bytes") {
578 (!is_non_negative_integer(value))
579 .then(|| format!("{key:?} must be a non-negative integer byte count"))
580 } else if has("_epoch_s") || has("_epoch_ms") {
581 (!is_integer(value)).then(|| format!("{key:?} must be an integer epoch timestamp"))
582 } else if has("_epoch_ns") {
583 (!is_decimal_integer_string(value))
584 .then(|| format!("{key:?} must be a decimal integer string"))
585 } else if has("_sats") || has("_msats") {
586 (!(is_integer(value) || is_decimal_integer_string(value)))
587 .then(|| format!("{key:?} must be an integer or decimal integer string"))
588 } else if has("_percent") {
589 (!value.is_number()).then(|| format!("{key:?} must be numeric"))
590 } else if is_duration_suffix(key) {
591 (!value.is_number()).then(|| format!("{key:?} must be a numeric duration"))
592 } else if is_currency_minor_unit_suffix(key) {
593 (!is_integer(value)).then(|| format!("{key:?} must be an integer currency amount"))
594 } else if has("_rfc3339") {
595 if value.as_str().is_some_and(is_valid_rfc3339) {
596 None
597 } else if value.is_string() {
598 Some(format!(
599 "{key:?} must be an RFC 3339 date-time with a mandatory offset (e.g. \
600 2026-02-14T10:30:00Z)"
601 ))
602 } else {
603 Some(format!("{key:?} must be a string"))
604 }
605 } else if has("_url") {
606 match url_field_violation(value) {
610 UrlFieldCheck::Ok => None,
611 UrlFieldCheck::NotAUrl => Some(format!(
612 "{key:?} must be a single URL (no internal whitespace or bare credentials)"
613 )),
614 UrlFieldCheck::NotAString => Some(format!(
615 "{key:?} must be a URL string, or a collection whose leaves are URL strings"
616 )),
617 }
618 } else if has("_bcp47") {
619 if value.as_str().is_some_and(is_valid_bcp47) {
620 None
621 } else if value.is_string() {
622 Some(format!("{key:?} must be a well-formed BCP 47 language tag"))
623 } else {
624 Some(format!("{key:?} must be a string"))
625 }
626 } else if has("_rfc3339_date") {
627 if value.as_str().is_some_and(is_valid_rfc3339_date) {
628 None
629 } else if value.is_string() {
630 Some(format!(
631 "{key:?} must be an RFC 3339 full-date (YYYY-MM-DD)"
632 ))
633 } else {
634 Some(format!("{key:?} must be a string"))
635 }
636 } else if has("_rfc3339_time") {
637 if value.as_str().is_some_and(is_valid_rfc3339_time) {
638 None
639 } else if value.is_string() {
640 Some(format!(
641 "{key:?} must be an RFC 3339 partial-time (HH:MM:SS[.fraction], no Z or offset)"
642 ))
643 } else {
644 Some(format!("{key:?} must be a string"))
645 }
646 } else if has("_utc_offset") {
647 if value.as_str().and_then(normalize_utc_offset).is_some() {
648 None
649 } else if value.is_string() {
650 Some(format!(
651 "{key:?} must be a fixed UTC offset (\"UTC\" or ±HH:MM)"
652 ))
653 } else {
654 Some(format!("{key:?} must be a string"))
655 }
656 } else {
657 None
658 };
659 if let Some(message) = message {
660 findings.push(LintFinding::error(
661 "suffix_type_mismatch",
662 pointer.to_string(),
663 message,
664 ));
665 }
666}
667
668fn lint_unsafe_integer(value: &Value, pointer: &str, findings: &mut Vec<LintFinding>) {
669 let Value::Number(number) = value else {
670 return;
671 };
672 if !number_is_integer_literal(number) {
673 return;
674 }
675 let exceeds_safe_range = if let Some(value) = number.as_i128() {
676 value.unsigned_abs() > u128::from(MAX_SAFE_INTEGER)
677 } else if let Some(value) = number.as_u128() {
678 value > u128::from(MAX_SAFE_INTEGER)
679 } else if let Some(value) = number.as_f64() {
680 value.abs() > MAX_SAFE_INTEGER as f64
681 } else {
682 true
685 };
686 if exceeds_safe_range {
687 findings.push(unsafe_integer_finding(pointer));
688 }
689}
690
691fn unsafe_integer_finding(pointer: &str) -> LintFinding {
692 LintFinding::error(
693 "unsafe_integer",
694 pointer.to_string(),
695 "integer exceeds JavaScript safe integer range ±(2^53-1)".to_string(),
696 )
697}
698
699fn is_integer(value: &Value) -> bool {
700 matches!(value, Value::Number(number) if number_is_integer(number))
701}
702
703fn is_non_negative_integer(value: &Value) -> bool {
704 let Value::Number(number) = value else {
705 return false;
706 };
707 if !number_is_integer(number) {
708 return false;
709 }
710 let text = number.to_string();
711 !text.starts_with('-') || decimal_number_is_zero(&text)
712}
713
714fn number_is_integer(number: &Number) -> bool {
720 decimal_number_is_integer(&number.to_string())
721}
722
723fn number_is_integer_literal(number: &Number) -> bool {
731 crate::formatting::number_is_integer_literal(number)
732}
733
734fn decimal_number_is_integer(text: &str) -> bool {
735 let unsigned = text.strip_prefix('-').unwrap_or(text);
736 let exponent_index = unsigned.find(['e', 'E']);
737 let (mantissa, exponent_text) = exponent_index.map_or((unsigned, None), |index| {
738 (&unsigned[..index], Some(&unsigned[index + 1..]))
739 });
740 if mantissa
741 .bytes()
742 .filter(|byte| *byte != b'.')
743 .all(|byte| byte == b'0')
744 {
745 return true;
746 }
747
748 let fraction_digits = mantissa
749 .split_once('.')
750 .map_or(0, |(_, fraction)| fraction.len());
751 let exponent = exponent_text.map_or(0, |value| {
752 value.parse::<i128>().unwrap_or_else(|_| {
753 if value.starts_with('-') {
754 i128::MIN
755 } else {
756 i128::MAX
757 }
758 })
759 });
760 let scale = exponent.saturating_sub(i128::try_from(fraction_digits).unwrap_or(i128::MAX));
761 if scale >= 0 {
762 return true;
763 }
764
765 let required_trailing_zeroes = scale.unsigned_abs();
766 let digit_count = mantissa.bytes().filter(|byte| *byte != b'.').count();
767 if required_trailing_zeroes > digit_count as u128 {
768 return false;
769 }
770 mantissa
771 .bytes()
772 .filter(|byte| *byte != b'.')
773 .rev()
774 .take(required_trailing_zeroes as usize)
775 .all(|byte| byte == b'0')
776}
777
778fn decimal_number_is_zero(text: &str) -> bool {
779 let unsigned = text.strip_prefix('-').unwrap_or(text);
780 let mantissa = unsigned
781 .split_once(['e', 'E'])
782 .map_or(unsigned, |(mantissa, _)| mantissa);
783 mantissa
784 .bytes()
785 .filter(|byte| *byte != b'.')
786 .all(|byte| byte == b'0')
787}
788
789fn is_decimal_integer_string(value: &Value) -> bool {
790 let Value::String(text) = value else {
791 return false;
792 };
793 let digits = text.strip_prefix('-').unwrap_or(text);
794 !digits.is_empty() && digits.chars().all(|character| character.is_ascii_digit())
795}
796
797fn is_duration_suffix(key: &str) -> bool {
798 ["_ns", "_us", "_ms", "_s", "_minutes", "_hours", "_days"]
799 .iter()
800 .any(|suffix| crate::formatting::has_suffix_ci(key, suffix))
801}
802
803fn is_currency_minor_unit_suffix(key: &str) -> bool {
809 crate::formatting::extract_currency_code(key).is_some()
810 || crate::formatting::extract_currency_code_micro(key).is_some()
811 || crate::formatting::has_suffix_ci(key, "_jpy")
812}
813
814enum UrlFieldCheck {
816 Ok,
817 NotAUrl,
818 NotAString,
819}
820
821fn url_field_violation(value: &Value) -> UrlFieldCheck {
824 match value {
825 Value::Null => UrlFieldCheck::Ok,
826 Value::String(text) => {
827 if is_wellformed_url_field(text) {
828 UrlFieldCheck::Ok
829 } else {
830 UrlFieldCheck::NotAUrl
831 }
832 }
833 Value::Array(items) => first_url_violation(items.iter()),
834 Value::Object(entries) => first_url_violation(entries.values()),
835 _ => UrlFieldCheck::NotAString,
836 }
837}
838
839fn first_url_violation<'value>(values: impl Iterator<Item = &'value Value>) -> UrlFieldCheck {
840 for value in values {
841 match url_field_violation(value) {
842 UrlFieldCheck::Ok => {}
843 violation => return violation,
844 }
845 }
846 UrlFieldCheck::Ok
847}
848
849fn is_wellformed_url_field(value: &str) -> bool {
850 if is_scheme_prefixed_url(value) || is_scheme_prefixed_url(value.trim()) {
851 return true;
852 }
853 !value.chars().any(char::is_whitespace) && !value.contains('@')
854}
855
856fn is_scheme_prefixed_url(value: &str) -> bool {
857 if value.bytes().any(|byte| byte.is_ascii_whitespace()) {
858 return false;
859 }
860 let bytes = value.as_bytes();
861 if !bytes.first().is_some_and(u8::is_ascii_alphabetic) {
862 return false;
863 }
864 let mut index = 1;
865 while index < bytes.len() {
866 let character = bytes[index];
867 if character.is_ascii_alphanumeric() || matches!(character, b'+' | b'-' | b'.') {
868 index += 1;
869 } else {
870 break;
871 }
872 }
873 value[index..].starts_with("://")
874}
875
876fn join_pointer(base: &str, token: &str) -> String {
877 let escaped = token.replace('~', "~0").replace('/', "~1");
878 if base.is_empty() {
879 format!("/{escaped}")
880 } else {
881 format!("{base}/{escaped}")
882 }
883}
884
885#[cfg(test)]
886mod tests {
887 use super::{
888 LintOptions, LintSeverity, REGISTERED_SUFFIXES, RedactionCanaryError,
889 assert_no_lint_findings, assert_redaction_canary_absent, assert_strict_event, lint_value,
890 };
891 use serde_json::{Value, json};
892
893 #[test]
894 fn public_lint_api_reports_and_filters_findings() {
895 let value = json!({
896 "timeout": 5,
897 "size_bytes": "large",
898 "created_rfc3339": "not-a-time"
899 });
900 let findings = lint_value(&value, LintOptions::default());
901 assert_eq!(findings.len(), 3);
902 assert!(
903 findings
904 .iter()
905 .any(|finding| finding.severity == LintSeverity::Warning)
906 );
907 let errors = lint_value(&value, LintOptions::errors_only());
908 assert_eq!(errors.len(), 2);
909 }
910
911 #[test]
912 fn integer_suffixes_accept_integral_decimals_and_signed_currency() {
913 let value: Value = serde_json::from_str(
914 r#"{
915 "price_usd_cents": -1,
916 "refund_eur_cents": -3.0,
917 "size_bytes": 3.0,
918 "created_epoch_ms": 3e0
919 }"#,
920 )
921 .unwrap_or_else(|error| panic!("{error}"));
922
923 assert!(lint_value(&value, LintOptions::errors_only()).is_empty());
924 }
925
926 #[test]
927 fn registered_currency_suffixes_accept_three_or_four_letter_codes() {
928 assert!(super::has_registered_suffix("fare_thb_cents"));
929 assert!(super::has_registered_suffix("deposit_usdt_cents"));
930 assert!(!super::has_registered_suffix("total_amount_cents"));
931 assert!(!super::has_registered_suffix("price_usdtx_cents"));
932 }
933
934 #[test]
935 fn integer_suffixes_reject_fractional_values_and_negative_bytes() {
936 let value: Value = serde_json::from_str(
937 r#"{
938 "price_usd_cents": 3.5,
939 "size_bytes": -1
940 }"#,
941 )
942 .unwrap_or_else(|error| panic!("{error}"));
943 let findings = lint_value(&value, LintOptions::errors_only());
944
945 assert_eq!(
946 findings
947 .iter()
948 .filter(|finding| finding.rule_id == "suffix_type_mismatch")
949 .count(),
950 2
951 );
952 }
953
954 #[test]
955 fn unsafe_integer_finds_values_beyond_u128() {
956 let value: Value =
957 serde_json::from_str(r#"{"huge_count":340282366920938463463374607431768211456}"#)
958 .unwrap_or_else(|error| panic!("{error}"));
959 let findings = lint_value(&value, LintOptions::errors_only());
960
961 assert!(
962 findings
963 .iter()
964 .any(|finding| finding.rule_id == "unsafe_integer"),
965 "{findings:?}"
966 );
967 }
968
969 #[test]
970 fn assertion_helpers_cover_protocol_lint_and_redaction() {
971 let event = json!({
972 "kind": "result",
973 "result": {"code": "ready"},
974 "trace": {}
975 });
976 assert!(assert_strict_event(&event).is_ok());
977 assert!(assert_no_lint_findings(&event).is_ok());
978 assert!(assert_redaction_canary_absent("{\"secret\":\"***\"}", "canary-42").is_ok());
979 assert_eq!(
980 assert_redaction_canary_absent("contains canary-42", "canary-42"),
981 Err(RedactionCanaryError::Exposed)
982 );
983 }
984
985 #[test]
990 fn missing_suffix_reads_intent_from_the_name() {
991 for (label, value) in [
992 ("bare dimension name", json!({"timeout": 5000})),
993 ("suffixed dimension name", json!({"request_timeout": 1})),
994 ("_at timestamp", json!({"expires_at": 1})),
995 ] {
996 let findings = lint_value(&json!(value), LintOptions::default());
997 assert!(
998 findings.iter().any(|f| f.rule_id == "missing_suffix"),
999 "{label}: expected a missing_suffix warning, got {findings:?}"
1000 );
1001 }
1002
1003 for (label, value) in [
1004 ("already labelled duration", json!({"timeout_ms": 5000})),
1005 ("already labelled currency", json!({"price_gbp_cents": 1})),
1006 ("already labelled secret", json!({"api_key_secret": "sk"})),
1007 (
1010 "dimension name over a container",
1011 json!({"timeout": {"connect": 1}}),
1012 ),
1013 ("credentials block", json!({"credentials": {"user": "x"}})),
1014 ("unrelated name", json!({"name": "demo"})),
1015 ] {
1016 let findings = lint_value(&json!(value), LintOptions::default());
1017 assert!(
1018 findings.is_empty(),
1019 "{label}: expected no findings, got {findings:?}"
1020 );
1021 }
1022 }
1023
1024 #[test]
1028 fn redaction_canary_survives_every_output_escape() {
1029 for (label, canary) in [
1030 (
1031 "pem newlines",
1032 "-----BEGIN KEY-----\nabc\n-----END KEY-----",
1033 ),
1034 ("double quote", "p@ss\"word"),
1035 ("backslash path", "C:\\Users\\me\\key"),
1036 ("tab", "a\tb"),
1037 ("non-bmp", "k\u{1f511}ey"),
1038 ] {
1039 let rendered = crate::render(
1040 &json!({ "note": canary }),
1041 crate::OutputFormat::Json,
1042 &crate::OutputOptions::default(),
1043 );
1044 assert!(
1045 rendered.contains("note"),
1046 "{label}: expected the field to survive rendering"
1047 );
1048 assert_eq!(
1049 assert_redaction_canary_absent(&rendered, canary),
1050 Err(RedactionCanaryError::Exposed),
1051 "{label}: escaped canary must still be reported, output was {rendered}"
1052 );
1053 }
1054 }
1055
1056 #[test]
1059 fn redaction_canary_is_found_percent_encoded() {
1060 assert_eq!(
1061 assert_redaction_canary_absent("{\"note\":\"https://h/p?q=a%20b%20c\"}", "a b c"),
1062 Err(RedactionCanaryError::Exposed)
1063 );
1064 }
1065
1066 #[test]
1069 fn redaction_canary_stays_quiet_when_the_value_was_redacted() {
1070 for canary in [
1071 "-----BEGIN KEY-----\nabc\n-----END KEY-----",
1072 "p@ss\"word",
1073 "C:\\Users\\me\\key",
1074 "k\u{1f511}ey",
1075 ] {
1076 for format in [
1077 crate::OutputFormat::Json,
1078 crate::OutputFormat::Yaml,
1079 crate::OutputFormat::Plain,
1080 ] {
1081 let rendered = crate::render(
1082 &json!({ "note_secret": canary }),
1083 format,
1084 &crate::OutputOptions::default(),
1085 );
1086 assert_eq!(
1087 assert_redaction_canary_absent(&rendered, canary),
1088 Ok(()),
1089 "redacted output must not report a leak, output was {rendered}"
1090 );
1091 }
1092 }
1093 }
1094
1095 #[test]
1096 fn registry_suffixes_match_the_lint_table() {
1097 const REGISTRY: &str =
1098 include_str!("../../skills/agent-first-data/references/registry.json");
1099 let registry: Value =
1100 serde_json::from_str(REGISTRY).unwrap_or_else(|error| panic!("{error}"));
1101 let suffixes = registry["suffixes"]
1102 .as_array()
1103 .map(Vec::as_slice)
1104 .unwrap_or(&[]);
1105 let mut from_registry: Vec<(String, String)> = suffixes
1106 .iter()
1107 .filter_map(|entry| {
1108 Some((
1109 entry["category"].as_str()?.to_string(),
1110 entry["suffix"].as_str()?.to_string(),
1111 ))
1112 })
1113 .collect();
1114 let mut from_table: Vec<(String, String)> = REGISTERED_SUFFIXES
1115 .iter()
1116 .flat_map(|(category, suffixes)| {
1117 suffixes
1118 .iter()
1119 .map(move |suffix| ((*category).to_string(), (*suffix).to_string()))
1120 })
1121 .collect();
1122 from_registry.sort();
1123 from_table.sort();
1124 assert_eq!(from_table, from_registry);
1125 }
1126}