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    /// The canary is present in the output or in one of its decoded layers.
145    Exposed,
146    /// Layered encoding was still changing when the decoding budget ran out,
147    /// so absence could not be shown. A failure, but not a confirmed leak.
148    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
165/// Verify that a unique test canary is absent from final serialized output.
166///
167/// Pass the string returned by [`crate::render`] or an HTTP/body serializer,
168/// not the pre-redaction input value.
169///
170/// Every renderer escapes a string before it reaches the stream, so a canary
171/// can be present verbatim yet unfindable by a raw substring search: a PEM key
172/// carries newlines, a password may carry `"`, a Windows path carries `\`, and
173/// a canary inside a `_url` value is percent-encoded. This checks the raw text
174/// *and* decoded copies until no escapes change, so nested URL encoding and
175/// serialization escapes cannot hide a leak. Decoding the haystack rather than
176/// enumerating the escaped spellings of the canary keeps the check from
177/// drifting when a renderer changes, and biases it the safe way: an over-eager
178/// decode can only raise a false alarm, never pass a real leak. Inspection is
179/// bounded to 64 decoding passes; encoding that still changes at that bound
180/// fails closed as [`RedactionCanaryError::InspectionLimitExceeded`].
181pub 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    // Deep percent chains otherwise remove only two bytes per full scan,
190    // making a small encoded prefix plus a large body quadratic to inspect.
191    for _ in 0..64 {
192        if current.contains(canary) {
193            return Err(RedactionCanaryError::Exposed);
194        }
195        let decoded = decode_output_escapes(&current);
196        if decoded == current {
197            return Ok(());
198        }
199        current = std::borrow::Cow::Owned(decoded);
200    }
201    Err(RedactionCanaryError::InspectionLimitExceeded)
202}
203
204/// Undo every escape AFDATA output can introduce: JSON and YAML string escapes,
205/// logfmt quoting, and percent-encoding inside a URL.
206///
207/// Only used to widen the canary search, so an unrecognized escape is left
208/// alone rather than guessed at.
209fn 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(&out).into_owned()
242}
243
244/// Decode one escape body (the bytes after a `\`), returning it with the number
245/// of bytes consumed.
246fn 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            // A non-BMP character is escaped as a surrogate pair; decoding the
262            // halves separately would lose the character entirely.
263            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                // JSON Schema property descriptors are not runtime values.
309                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
514/// Whether a key already carries a suffix the renderer and redactor act on.
515///
516/// Matching is the convention's own rule — the suffix spelled all-lowercase or
517/// all-uppercase, never mixed — because this decides whether the linter stays
518/// quiet. Accepting `api_Secret` here would report a field as marked while
519/// `redaction.rs` leaves it in the clear, which is the one mistake a linter
520/// used as a leak guard must not make.
521fn 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    // Suffix recognition uses the convention's own matching rule, not a
591    // case-folded approximation: a key the renderer treats as plain must not be
592    // held to a suffix's type contract, or the fix the message asks for leaves
593    // the field just as untreated as before.
594    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        // Redaction walks a URL-marked collection into its string leaves, so a
625        // collection is a shape the convention supports, not a type error. Only
626        // the leaves are held to the single-URL rule.
627        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        // An exact integer too large even for a finite f64 is necessarily
701        // outside JavaScript's safe-integer range.
702        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
732/// Whether a JSON number's exact mathematical value is an integer.
733///
734/// `serde_json/arbitrary_precision` preserves the source decimal, so this
735/// handles integral-valued decimals and exponents without rounding through
736/// f64, while also accepting integers larger than u128.
737fn number_is_integer(number: &Number) -> bool {
738    decimal_number_is_integer(&number.to_string())
739}
740
741/// Whether the producer wrote this number as an integer literal.
742///
743/// The unsafe-integer rule is about an integer that cannot survive a JavaScript
744/// round trip, so it keys off the written form rather than the mathematical
745/// value. `1.5e300` and `1.0e17` are float literals: already doubles by
746/// construction, carrying no exactness promise to break. `100000000000000000`
747/// does carry one, and breaks it.
748fn 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
821/// Whether a key names a currency minor unit the formatter actually formats.
822///
823/// `_cents` and `_micro` carry a currency code (`_usd_cents`); a bare
824/// `total_cents` is not a registered suffix, and holding it to the integer rule
825/// would demand a fix that changes nothing about how the field is rendered.
826fn 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
832/// How a `_url`-marked value compares to the shapes redaction handles.
833enum UrlFieldCheck {
834    Ok,
835    NotAUrl,
836    NotAString,
837}
838
839/// Redaction gives URL treatment to string leaves and walks collections to
840/// reach them, so the linter accepts the same shapes and checks the leaves.
841fn 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    /// The missing-suffix rule reads intent from the name, so what it stays
1004    /// quiet about matters as much as what it reports. Driven through the
1005    /// public entry point rather than the private helper, so a later move of
1006    /// the rule cannot take the pin with it.
1007    #[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            // A dimension name over a container is a config block, not a bare
1026            // number. This is the guard that has no other test.
1027            (
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    /// A canary is only useful if escaping cannot hide it. Every case here is a
1043    /// canary sitting verbatim in the output while a raw substring search
1044    /// misses it, which is how a leak used to pass this assertion.
1045    #[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    /// The percent-encoded spelling counts too: a canary can reach the stream
1075    /// through a URL without ever appearing literally.
1076    #[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        // No canary is present, but inspection may not silently pass an output
1114        // whose encoding cannot settle inside its fixed work budget.
1115        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    /// The widened search must not cry wolf: a genuinely redacted secret
1123    /// reports clean in every format, whatever characters it contained.
1124    #[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}