Skip to main content

agent_first_data/
lint.rs

1//! In-process AFDATA naming and serialized-output checks.
2
3use 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/// Finding severity, ordered from advisory to failing.
14#[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/// One deterministic AFDATA lint finding.
37#[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/// Controls which deterministic findings are returned.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub struct LintOptions {
81    minimum_severity: LintSeverity,
82}
83
84impl LintOptions {
85    /// Include warnings and errors.
86    pub const fn new() -> Self {
87        Self {
88            minimum_severity: LintSeverity::Warning,
89        }
90    }
91
92    /// Return only findings at or above `minimum_severity`.
93    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
109/// Lint an actual serialized JSON value in-process.
110pub 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
117/// Assert the recommended strict protocol profile without spawning the CLI.
118pub fn assert_strict_event(value: &Value) -> Result<(), ProtocolViolation> {
119    validate_protocol_event(value, true)
120}
121
122/// Return every default lint finding as `Err`; useful in unit tests.
123pub fn assert_no_lint_findings(value: &Value) -> Result<(), Vec<LintFinding>> {
124    assert_no_lint_findings_with_options(value, LintOptions::default())
125}
126
127/// As [`assert_no_lint_findings`], with explicit severity filtering.
128pub 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/// Failure from [`assert_redaction_canary_absent`].
141#[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
158/// Verify that a unique test canary is absent from final serialized output.
159///
160/// Pass the string returned by [`crate::render`] or an HTTP/body serializer,
161/// not the pre-redaction input value.
162///
163/// Every renderer escapes a string before it reaches the stream, so a canary
164/// can be present verbatim yet unfindable by a raw substring search: a PEM key
165/// carries newlines, a password may carry `"`, a Windows path carries `\`, and
166/// a canary inside a `_url` value is percent-encoded. This checks the raw text
167/// *and* a decoded copy of it, so the escaping a renderer applies cannot hide a
168/// leak. Decoding the haystack rather than enumerating the escaped spellings of
169/// the canary keeps the check from drifting when a renderer changes, and biases
170/// it the safe way: an over-eager decode can only raise a false alarm, never
171/// pass a real leak.
172pub 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
186/// Undo every escape AFDATA output can introduce: JSON and YAML string escapes,
187/// logfmt quoting, and percent-encoding inside a URL.
188///
189/// Only used to widen the canary search, so an unrecognized escape is left
190/// alone rather than guessed at.
191fn 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
226/// Decode one escape body (the bytes after a `\`), returning it with the number
227/// of bytes consumed.
228fn 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            // A non-BMP character is escaped as a surrogate pair; decoding the
244            // halves separately would lose the character entirely.
245            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                // JSON Schema property descriptors are not runtime values.
291                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
496/// Whether a key already carries a suffix the renderer and redactor act on.
497///
498/// Matching is the convention's own rule — the suffix spelled all-lowercase or
499/// all-uppercase, never mixed — because this decides whether the linter stays
500/// quiet. Accepting `api_Secret` here would report a field as marked while
501/// `redaction.rs` leaves it in the clear, which is the one mistake a linter
502/// used as a leak guard must not make.
503fn 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    // Suffix recognition uses the convention's own matching rule, not a
573    // case-folded approximation: a key the renderer treats as plain must not be
574    // held to a suffix's type contract, or the fix the message asks for leaves
575    // the field just as untreated as before.
576    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        // Redaction walks a URL-marked collection into its string leaves, so a
607        // collection is a shape the convention supports, not a type error. Only
608        // the leaves are held to the single-URL rule.
609        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        // An exact integer too large even for a finite f64 is necessarily
683        // outside JavaScript's safe-integer range.
684        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
714/// Whether a JSON number's exact mathematical value is an integer.
715///
716/// `serde_json/arbitrary_precision` preserves the source decimal, so this
717/// handles integral-valued decimals and exponents without rounding through
718/// f64, while also accepting integers larger than u128.
719fn number_is_integer(number: &Number) -> bool {
720    decimal_number_is_integer(&number.to_string())
721}
722
723/// Whether the producer wrote this number as an integer literal.
724///
725/// The unsafe-integer rule is about an integer that cannot survive a JavaScript
726/// round trip, so it keys off the written form rather than the mathematical
727/// value. `1.5e300` and `1.0e17` are float literals: already doubles by
728/// construction, carrying no exactness promise to break. `100000000000000000`
729/// does carry one, and breaks it.
730fn 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
803/// Whether a key names a currency minor unit the formatter actually formats.
804///
805/// `_cents` and `_micro` carry a currency code (`_usd_cents`); a bare
806/// `total_cents` is not a registered suffix, and holding it to the integer rule
807/// would demand a fix that changes nothing about how the field is rendered.
808fn 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
814/// How a `_url`-marked value compares to the shapes redaction handles.
815enum UrlFieldCheck {
816    Ok,
817    NotAUrl,
818    NotAString,
819}
820
821/// Redaction gives URL treatment to string leaves and walks collections to
822/// reach them, so the linter accepts the same shapes and checks the leaves.
823fn 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    /// The missing-suffix rule reads intent from the name, so what it stays
986    /// quiet about matters as much as what it reports. Driven through the
987    /// public entry point rather than the private helper, so a later move of
988    /// the rule cannot take the pin with it.
989    #[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            // A dimension name over a container is a config block, not a bare
1008            // number. This is the guard that has no other test.
1009            (
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    /// A canary is only useful if escaping cannot hide it. Every case here is a
1025    /// canary sitting verbatim in the output while a raw substring search
1026    /// misses it, which is how a leak used to pass this assertion.
1027    #[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    /// The percent-encoded spelling counts too: a canary can reach the stream
1057    /// through a URL without ever appearing literally.
1058    #[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    /// The widened search must not cry wolf: a genuinely redacted secret
1067    /// reports clean in every format, whatever characters it contained.
1068    #[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}