Skip to main content

celers_core/
sanitize.rs

1//! Task argument sanitization.
2//!
3//! Task arguments and keyword arguments frequently originate from untrusted
4//! callers (HTTP handlers, message brokers, user input). Before such a payload
5//! is logged, persisted, or executed it is prudent to:
6//!
7//! * **bound its size** — reject pathologically large strings/blobs and
8//!   excessively long argument lists (a cheap denial-of-service vector);
9//! * **strip control characters** from strings so they cannot corrupt logs,
10//!   terminals, or downstream parsers (log injection / ANSI escape attacks);
11//! * **reject disallowed value kinds** — e.g. forbid raw binary blobs or
12//!   floating-point arguments in contexts that only expect scalars;
13//! * **redact secret-looking keys** — values stored under keys such as
14//!   `password`, `token`, `secret`, or `api_key` should never be retained in
15//!   plaintext within stored task metadata.
16//!
17//! The work is driven by a [`SanitizerConfig`] so each deployment can tune the
18//! policy, and the [`Sanitizer`] returns a [`SanitizeReport`] describing every
19//! action it took (useful for audit logging and tests).
20//!
21//! All of this operates over a small, JSON-like value model — [`TaskValue`] —
22//! which is shared by the signature and PII modules so the three security
23//! features compose over a single representation.
24//!
25//! # Example
26//!
27//! ```rust
28//! use celers_core::sanitize::{Sanitizer, SanitizerConfig, TaskValue};
29//!
30//! let sanitizer = Sanitizer::new(SanitizerConfig::default());
31//!
32//! let mut kwargs = vec![
33//!     ("username".to_string(), TaskValue::from("alice")),
34//!     ("password".to_string(), TaskValue::from("hunter2")),
35//! ];
36//! let report = sanitizer.sanitize_kwargs(&mut kwargs).unwrap();
37//!
38//! // The secret value is redacted in place.
39//! assert_eq!(kwargs[1].1, TaskValue::from("[REDACTED]"));
40//! assert_eq!(report.redacted_keys, 1);
41//! ```
42
43use serde::{Deserialize, Serialize};
44use std::collections::BTreeSet;
45use std::fmt;
46
47/// A JSON-like value used to represent a single task argument.
48///
49/// This intentionally mirrors the shape of a serialized argument (the kind of
50/// thing you would get from `serde_json::Value`) while keeping integer and
51/// byte information that JSON would lose. It is the common currency of the
52/// security modules: signatures canonicalize it, the sanitizer cleans it, and
53/// the PII detector scans the strings inside it.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[serde(untagged)]
56pub enum TaskValue {
57    /// Absence of a value (`null`).
58    Null,
59    /// Boolean.
60    Bool(bool),
61    /// Signed 64-bit integer.
62    Int(i64),
63    /// Unsigned 64-bit integer (for values exceeding `i64::MAX`).
64    UInt(u64),
65    /// 64-bit floating point.
66    Float(f64),
67    /// UTF-8 string.
68    String(String),
69    /// Raw bytes (binary blob).
70    Bytes(Vec<u8>),
71    /// Ordered list of values.
72    Array(Vec<TaskValue>),
73    /// Ordered key/value map. Kept as a `Vec` of pairs so insertion order is
74    /// preserved for display; canonicalization and lookups sort by key.
75    Object(Vec<(String, TaskValue)>),
76}
77
78impl TaskValue {
79    /// Human-readable name of this value's kind.
80    #[must_use]
81    pub const fn kind(&self) -> ValueKind {
82        match self {
83            TaskValue::Null => ValueKind::Null,
84            TaskValue::Bool(_) => ValueKind::Bool,
85            TaskValue::Int(_) | TaskValue::UInt(_) => ValueKind::Integer,
86            TaskValue::Float(_) => ValueKind::Float,
87            TaskValue::String(_) => ValueKind::String,
88            TaskValue::Bytes(_) => ValueKind::Bytes,
89            TaskValue::Array(_) => ValueKind::Array,
90            TaskValue::Object(_) => ValueKind::Object,
91        }
92    }
93
94    /// Borrow the inner string, if this is a [`TaskValue::String`].
95    #[must_use]
96    pub fn as_str(&self) -> Option<&str> {
97        match self {
98            TaskValue::String(s) => Some(s.as_str()),
99            _ => None,
100        }
101    }
102
103    /// Approximate in-memory "weight" of this value in bytes, used for size
104    /// limiting. Containers sum their children plus a small per-element
105    /// overhead so deeply nested structures are accounted for.
106    #[must_use]
107    pub fn approx_size(&self) -> usize {
108        match self {
109            TaskValue::Null => 1,
110            TaskValue::Bool(_) => 1,
111            TaskValue::Int(_) | TaskValue::UInt(_) | TaskValue::Float(_) => 8,
112            TaskValue::String(s) => s.len(),
113            TaskValue::Bytes(b) => b.len(),
114            TaskValue::Array(items) => items.iter().map(|v| v.approx_size() + 1).sum::<usize>() + 1,
115            TaskValue::Object(entries) => {
116                entries
117                    .iter()
118                    .map(|(k, v)| k.len() + v.approx_size() + 1)
119                    .sum::<usize>()
120                    + 1
121            }
122        }
123    }
124
125    /// Maximum container nesting depth (a scalar has depth 1).
126    #[must_use]
127    pub fn depth(&self) -> usize {
128        match self {
129            TaskValue::Array(items) => 1 + items.iter().map(TaskValue::depth).max().unwrap_or(0),
130            TaskValue::Object(entries) => {
131                1 + entries.iter().map(|(_, v)| v.depth()).max().unwrap_or(0)
132            }
133            _ => 1,
134        }
135    }
136}
137
138impl From<bool> for TaskValue {
139    fn from(v: bool) -> Self {
140        TaskValue::Bool(v)
141    }
142}
143impl From<i64> for TaskValue {
144    fn from(v: i64) -> Self {
145        TaskValue::Int(v)
146    }
147}
148impl From<i32> for TaskValue {
149    fn from(v: i32) -> Self {
150        TaskValue::Int(i64::from(v))
151    }
152}
153impl From<u64> for TaskValue {
154    fn from(v: u64) -> Self {
155        TaskValue::UInt(v)
156    }
157}
158impl From<f64> for TaskValue {
159    fn from(v: f64) -> Self {
160        TaskValue::Float(v)
161    }
162}
163impl From<&str> for TaskValue {
164    fn from(v: &str) -> Self {
165        TaskValue::String(v.to_string())
166    }
167}
168impl From<String> for TaskValue {
169    fn from(v: String) -> Self {
170        TaskValue::String(v)
171    }
172}
173impl From<Vec<u8>> for TaskValue {
174    fn from(v: Vec<u8>) -> Self {
175        TaskValue::Bytes(v)
176    }
177}
178
179impl From<serde_json::Value> for TaskValue {
180    fn from(v: serde_json::Value) -> Self {
181        match v {
182            serde_json::Value::Null => TaskValue::Null,
183            serde_json::Value::Bool(b) => TaskValue::Bool(b),
184            serde_json::Value::Number(n) => {
185                if let Some(i) = n.as_i64() {
186                    TaskValue::Int(i)
187                } else if let Some(u) = n.as_u64() {
188                    TaskValue::UInt(u)
189                } else if let Some(f) = n.as_f64() {
190                    TaskValue::Float(f)
191                } else {
192                    // Unreachable in practice, but stay total.
193                    TaskValue::Null
194                }
195            }
196            serde_json::Value::String(s) => TaskValue::String(s),
197            serde_json::Value::Array(items) => {
198                TaskValue::Array(items.into_iter().map(TaskValue::from).collect())
199            }
200            serde_json::Value::Object(map) => TaskValue::Object(
201                map.into_iter()
202                    .map(|(k, v)| (k, TaskValue::from(v)))
203                    .collect(),
204            ),
205        }
206    }
207}
208
209/// The discriminant of a [`TaskValue`], independent of its contents.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
211pub enum ValueKind {
212    /// `null`.
213    Null,
214    /// Boolean.
215    Bool,
216    /// Any integer (signed or unsigned).
217    Integer,
218    /// Floating point.
219    Float,
220    /// UTF-8 string.
221    String,
222    /// Binary blob.
223    Bytes,
224    /// List.
225    Array,
226    /// Map.
227    Object,
228}
229
230impl fmt::Display for ValueKind {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        let name = match self {
233            ValueKind::Null => "null",
234            ValueKind::Bool => "bool",
235            ValueKind::Integer => "integer",
236            ValueKind::Float => "float",
237            ValueKind::String => "string",
238            ValueKind::Bytes => "bytes",
239            ValueKind::Array => "array",
240            ValueKind::Object => "object",
241        };
242        f.write_str(name)
243    }
244}
245
246/// Error returned when a value violates a hard sanitizer limit.
247///
248/// "Soft" actions (stripping control characters, redacting secrets, truncating
249/// when configured to do so) do not error — they are recorded in the
250/// [`SanitizeReport`]. Errors are reserved for policy violations the caller
251/// asked to be treated as fatal.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum SanitizeError {
254    /// A string or blob exceeded `max_string_bytes` and truncation was
255    /// disabled.
256    ValueTooLarge {
257        /// Observed size in bytes.
258        size: usize,
259        /// Configured limit in bytes.
260        limit: usize,
261    },
262    /// The total number of positional + keyword arguments exceeded
263    /// `max_arg_count`.
264    TooManyArgs {
265        /// Observed argument count.
266        count: usize,
267        /// Configured limit.
268        limit: usize,
269    },
270    /// Container nesting exceeded `max_depth`.
271    TooDeep {
272        /// Observed depth.
273        depth: usize,
274        /// Configured limit.
275        limit: usize,
276    },
277    /// A value of a disallowed kind was encountered.
278    DisallowedKind(ValueKind),
279    /// A key was longer than `max_key_bytes`.
280    KeyTooLong {
281        /// Observed key length in bytes.
282        size: usize,
283        /// Configured limit.
284        limit: usize,
285    },
286}
287
288impl fmt::Display for SanitizeError {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        match self {
291            SanitizeError::ValueTooLarge { size, limit } => {
292                write!(f, "value too large: {size} bytes exceeds limit of {limit}")
293            }
294            SanitizeError::TooManyArgs { count, limit } => {
295                write!(f, "too many arguments: {count} exceeds limit of {limit}")
296            }
297            SanitizeError::TooDeep { depth, limit } => {
298                write!(
299                    f,
300                    "value nested too deeply: {depth} exceeds limit of {limit}"
301                )
302            }
303            SanitizeError::DisallowedKind(kind) => {
304                write!(f, "disallowed argument kind: {kind}")
305            }
306            SanitizeError::KeyTooLong { size, limit } => {
307                write!(f, "key too long: {size} bytes exceeds limit of {limit}")
308            }
309        }
310    }
311}
312
313impl std::error::Error for SanitizeError {}
314
315impl From<SanitizeError> for crate::CelersError {
316    fn from(err: SanitizeError) -> Self {
317        crate::CelersError::Other(format!("sanitize error: {err}"))
318    }
319}
320
321/// What to do when a string exceeds `max_string_bytes`.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323pub enum OversizeAction {
324    /// Return a [`SanitizeError::ValueTooLarge`].
325    Reject,
326    /// Truncate the string/blob to the limit and record it in the report.
327    Truncate,
328}
329
330/// Configuration controlling how a [`Sanitizer`] processes arguments.
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct SanitizerConfig {
333    /// Maximum number of positional + keyword arguments allowed.
334    pub max_arg_count: usize,
335
336    /// Maximum size, in bytes, of any individual string or byte blob.
337    pub max_string_bytes: usize,
338
339    /// Maximum length, in bytes, of any keyword-argument key.
340    pub max_key_bytes: usize,
341
342    /// Maximum container nesting depth.
343    pub max_depth: usize,
344
345    /// What to do with strings/blobs exceeding `max_string_bytes`.
346    pub oversize_action: OversizeAction,
347
348    /// Whether to strip ASCII/Unicode control characters from strings.
349    pub strip_control_chars: bool,
350
351    /// Value kinds that are rejected outright. Empty means "allow all".
352    pub disallowed_kinds: BTreeSet<ValueKind>,
353
354    /// Lower-cased substrings that mark a key as secret; matching keys have
355    /// their values replaced with [`SanitizerConfig::redaction_placeholder`].
356    pub secret_key_markers: BTreeSet<String>,
357
358    /// Placeholder value substituted for redacted secrets.
359    pub redaction_placeholder: String,
360}
361
362impl Default for SanitizerConfig {
363    fn default() -> Self {
364        let mut disallowed = BTreeSet::new();
365        // Binary blobs are rejected by default: most task arguments should be
366        // structured, and large opaque blobs are a common abuse vector.
367        disallowed.insert(ValueKind::Bytes);
368
369        let markers = [
370            "password",
371            "passwd",
372            "secret",
373            "token",
374            "api_key",
375            "apikey",
376            "access_key",
377            "private",
378            "credential",
379            "auth",
380            "session",
381            "cookie",
382        ];
383        let secret_key_markers = markers.iter().map(|s| (*s).to_string()).collect();
384
385        Self {
386            max_arg_count: 64,
387            max_string_bytes: 64 * 1024,
388            max_key_bytes: 256,
389            max_depth: 16,
390            oversize_action: OversizeAction::Reject,
391            strip_control_chars: true,
392            disallowed_kinds: disallowed,
393            secret_key_markers,
394            redaction_placeholder: "[REDACTED]".to_string(),
395        }
396    }
397}
398
399impl SanitizerConfig {
400    /// A permissive configuration: high limits, no disallowed kinds, truncate
401    /// rather than reject, but still strip control characters and redact
402    /// secrets (these are cheap and almost always desirable).
403    #[must_use]
404    pub fn permissive() -> Self {
405        Self {
406            max_arg_count: 4096,
407            max_string_bytes: 8 * 1024 * 1024,
408            max_key_bytes: 4096,
409            max_depth: 64,
410            oversize_action: OversizeAction::Truncate,
411            strip_control_chars: true,
412            disallowed_kinds: BTreeSet::new(),
413            ..Self::default()
414        }
415    }
416
417    /// Set the maximum argument count.
418    #[must_use]
419    pub const fn with_max_arg_count(mut self, n: usize) -> Self {
420        self.max_arg_count = n;
421        self
422    }
423
424    /// Set the maximum per-string size in bytes.
425    #[must_use]
426    pub const fn with_max_string_bytes(mut self, n: usize) -> Self {
427        self.max_string_bytes = n;
428        self
429    }
430
431    /// Set the maximum container nesting depth.
432    #[must_use]
433    pub const fn with_max_depth(mut self, n: usize) -> Self {
434        self.max_depth = n;
435        self
436    }
437
438    /// Set the oversize action.
439    #[must_use]
440    pub const fn with_oversize_action(mut self, action: OversizeAction) -> Self {
441        self.oversize_action = action;
442        self
443    }
444
445    /// Enable or disable control-character stripping.
446    #[must_use]
447    pub const fn with_strip_control_chars(mut self, strip: bool) -> Self {
448        self.strip_control_chars = strip;
449        self
450    }
451
452    /// Replace the set of disallowed value kinds.
453    #[must_use]
454    pub fn with_disallowed_kinds(mut self, kinds: impl IntoIterator<Item = ValueKind>) -> Self {
455        self.disallowed_kinds = kinds.into_iter().collect();
456        self
457    }
458
459    /// Add a single secret-key marker (matched case-insensitively as a
460    /// substring of the key).
461    #[must_use]
462    pub fn with_secret_marker(mut self, marker: impl Into<String>) -> Self {
463        self.secret_key_markers.insert(marker.into().to_lowercase());
464        self
465    }
466
467    /// Replace the redaction placeholder.
468    #[must_use]
469    pub fn with_redaction_placeholder(mut self, placeholder: impl Into<String>) -> Self {
470        self.redaction_placeholder = placeholder.into();
471        self
472    }
473
474    /// Whether `key` looks like a secret according to the configured markers.
475    #[must_use]
476    pub fn is_secret_key(&self, key: &str) -> bool {
477        let lowered = key.to_lowercase();
478        self.secret_key_markers
479            .iter()
480            .any(|marker| lowered.contains(marker.as_str()))
481    }
482}
483
484/// Summary of the actions a [`Sanitizer`] performed on a payload.
485#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
486pub struct SanitizeReport {
487    /// Number of strings that had at least one control character removed.
488    pub strings_stripped: usize,
489    /// Total number of control characters removed across all strings.
490    pub control_chars_removed: usize,
491    /// Number of strings/blobs truncated to the size limit.
492    pub values_truncated: usize,
493    /// Number of secret-looking keyword values redacted.
494    pub redacted_keys: usize,
495}
496
497impl SanitizeReport {
498    /// `true` if the sanitizer modified the payload in any way.
499    #[must_use]
500    pub const fn made_changes(&self) -> bool {
501        self.strings_stripped > 0
502            || self.values_truncated > 0
503            || self.redacted_keys > 0
504            || self.control_chars_removed > 0
505    }
506
507    /// Merge another report into this one.
508    pub fn merge(&mut self, other: &SanitizeReport) {
509        self.strings_stripped += other.strings_stripped;
510        self.control_chars_removed += other.control_chars_removed;
511        self.values_truncated += other.values_truncated;
512        self.redacted_keys += other.redacted_keys;
513    }
514}
515
516/// Configurable sanitizer over task arguments and keyword arguments.
517#[derive(Debug, Clone)]
518pub struct Sanitizer {
519    config: SanitizerConfig,
520}
521
522impl Default for Sanitizer {
523    fn default() -> Self {
524        Self::new(SanitizerConfig::default())
525    }
526}
527
528impl Sanitizer {
529    /// Create a sanitizer with the given configuration.
530    #[must_use]
531    pub fn new(config: SanitizerConfig) -> Self {
532        Self { config }
533    }
534
535    /// Borrow the active configuration.
536    #[must_use]
537    pub const fn config(&self) -> &SanitizerConfig {
538        &self.config
539    }
540
541    /// Sanitize a list of positional arguments in place.
542    ///
543    /// # Errors
544    ///
545    /// Returns a [`SanitizeError`] if a hard limit is violated (too many
546    /// arguments, disallowed kind, oversize value when configured to reject,
547    /// or excessive nesting).
548    pub fn sanitize_args(&self, args: &mut [TaskValue]) -> Result<SanitizeReport, SanitizeError> {
549        self.check_arg_count(args.len(), 0)?;
550        let mut report = SanitizeReport::default();
551        for value in args.iter_mut() {
552            self.sanitize_value(value, 1, &mut report)?;
553        }
554        Ok(report)
555    }
556
557    /// Sanitize a list of keyword arguments in place, redacting secret keys.
558    ///
559    /// # Errors
560    ///
561    /// Returns a [`SanitizeError`] as for [`Sanitizer::sanitize_args`], plus
562    /// [`SanitizeError::KeyTooLong`] for over-long keys.
563    pub fn sanitize_kwargs(
564        &self,
565        kwargs: &mut [(String, TaskValue)],
566    ) -> Result<SanitizeReport, SanitizeError> {
567        self.check_arg_count(kwargs.len(), 0)?;
568        let mut report = SanitizeReport::default();
569        for (key, value) in kwargs.iter_mut() {
570            if key.len() > self.config.max_key_bytes {
571                return Err(SanitizeError::KeyTooLong {
572                    size: key.len(),
573                    limit: self.config.max_key_bytes,
574                });
575            }
576            if self.config.is_secret_key(key) {
577                *value = TaskValue::String(self.config.redaction_placeholder.clone());
578                report.redacted_keys += 1;
579                // Redacted values are not scanned/stripped further.
580                continue;
581            }
582            self.sanitize_value(value, 1, &mut report)?;
583        }
584        Ok(report)
585    }
586
587    /// Sanitize both positional and keyword arguments together, enforcing the
588    /// `max_arg_count` limit against their *combined* length.
589    ///
590    /// # Errors
591    ///
592    /// Returns a [`SanitizeError`] if any hard limit is violated.
593    pub fn sanitize_call(
594        &self,
595        args: &mut [TaskValue],
596        kwargs: &mut [(String, TaskValue)],
597    ) -> Result<SanitizeReport, SanitizeError> {
598        let total = args.len().saturating_add(kwargs.len());
599        if total > self.config.max_arg_count {
600            return Err(SanitizeError::TooManyArgs {
601                count: total,
602                limit: self.config.max_arg_count,
603            });
604        }
605
606        let mut report = SanitizeReport::default();
607        for value in args.iter_mut() {
608            self.sanitize_value(value, 1, &mut report)?;
609        }
610        for (key, value) in kwargs.iter_mut() {
611            if key.len() > self.config.max_key_bytes {
612                return Err(SanitizeError::KeyTooLong {
613                    size: key.len(),
614                    limit: self.config.max_key_bytes,
615                });
616            }
617            if self.config.is_secret_key(key) {
618                *value = TaskValue::String(self.config.redaction_placeholder.clone());
619                report.redacted_keys += 1;
620                continue;
621            }
622            self.sanitize_value(value, 1, &mut report)?;
623        }
624        Ok(report)
625    }
626
627    /// Recursively sanitize a single value at the given nesting `depth`.
628    fn sanitize_value(
629        &self,
630        value: &mut TaskValue,
631        depth: usize,
632        report: &mut SanitizeReport,
633    ) -> Result<(), SanitizeError> {
634        if depth > self.config.max_depth {
635            return Err(SanitizeError::TooDeep {
636                depth,
637                limit: self.config.max_depth,
638            });
639        }
640
641        let kind = value.kind();
642        if self.config.disallowed_kinds.contains(&kind) {
643            return Err(SanitizeError::DisallowedKind(kind));
644        }
645
646        match value {
647            TaskValue::String(s) => self.sanitize_string(s, report)?,
648            TaskValue::Bytes(b) => {
649                if b.len() > self.config.max_string_bytes {
650                    match self.config.oversize_action {
651                        OversizeAction::Reject => {
652                            return Err(SanitizeError::ValueTooLarge {
653                                size: b.len(),
654                                limit: self.config.max_string_bytes,
655                            });
656                        }
657                        OversizeAction::Truncate => {
658                            b.truncate(self.config.max_string_bytes);
659                            report.values_truncated += 1;
660                        }
661                    }
662                }
663            }
664            TaskValue::Array(items) => {
665                for item in items.iter_mut() {
666                    self.sanitize_value(item, depth + 1, report)?;
667                }
668            }
669            TaskValue::Object(entries) => {
670                for (key, item) in entries.iter_mut() {
671                    if key.len() > self.config.max_key_bytes {
672                        return Err(SanitizeError::KeyTooLong {
673                            size: key.len(),
674                            limit: self.config.max_key_bytes,
675                        });
676                    }
677                    if self.config.is_secret_key(key) {
678                        *item = TaskValue::String(self.config.redaction_placeholder.clone());
679                        report.redacted_keys += 1;
680                        continue;
681                    }
682                    self.sanitize_value(item, depth + 1, report)?;
683                }
684            }
685            // Scalars (other than strings/bytes handled above) need no work.
686            TaskValue::Null
687            | TaskValue::Bool(_)
688            | TaskValue::Int(_)
689            | TaskValue::UInt(_)
690            | TaskValue::Float(_) => {}
691        }
692
693        Ok(())
694    }
695
696    /// Strip control characters from and size-limit a single string.
697    fn sanitize_string(
698        &self,
699        s: &mut String,
700        report: &mut SanitizeReport,
701    ) -> Result<(), SanitizeError> {
702        if self.config.strip_control_chars {
703            let removed = strip_control_chars(s);
704            if removed > 0 {
705                report.strings_stripped += 1;
706                report.control_chars_removed += removed;
707            }
708        }
709
710        if s.len() > self.config.max_string_bytes {
711            match self.config.oversize_action {
712                OversizeAction::Reject => {
713                    return Err(SanitizeError::ValueTooLarge {
714                        size: s.len(),
715                        limit: self.config.max_string_bytes,
716                    });
717                }
718                OversizeAction::Truncate => {
719                    truncate_str_bytes(s, self.config.max_string_bytes);
720                    report.values_truncated += 1;
721                }
722            }
723        }
724
725        Ok(())
726    }
727
728    /// Enforce `max_arg_count` against `count` (plus an `extra` offset used
729    /// when combining lists).
730    fn check_arg_count(&self, count: usize, extra: usize) -> Result<(), SanitizeError> {
731        let total = count.saturating_add(extra);
732        if total > self.config.max_arg_count {
733            return Err(SanitizeError::TooManyArgs {
734                count: total,
735                limit: self.config.max_arg_count,
736            });
737        }
738        Ok(())
739    }
740}
741
742/// Remove control characters from a string in place, returning how many were
743/// removed.
744///
745/// "Control character" means any [`char::is_control`] codepoint **except**
746/// the common whitespace characters tab (`\t`), newline (`\n`), and carriage
747/// return (`\r`), which are preserved because they are legitimate in most
748/// textual arguments. This neutralizes ANSI escape sequences, NUL bytes, and
749/// other terminal/log-injection vectors.
750#[must_use]
751pub fn strip_control_chars(s: &mut String) -> usize {
752    let mut removed = 0usize;
753    let needs_work = s
754        .chars()
755        .any(|c| c.is_control() && !matches!(c, '\t' | '\n' | '\r'));
756    if !needs_work {
757        return 0;
758    }
759    let cleaned: String = s
760        .chars()
761        .filter(|c| {
762            let keep = !c.is_control() || matches!(c, '\t' | '\n' | '\r');
763            if !keep {
764                removed += 1;
765            }
766            keep
767        })
768        .collect();
769    *s = cleaned;
770    removed
771}
772
773/// Truncate a string to at most `max_bytes` bytes without splitting a UTF-8
774/// codepoint.
775fn truncate_str_bytes(s: &mut String, max_bytes: usize) {
776    if s.len() <= max_bytes {
777        return;
778    }
779    let mut end = max_bytes;
780    while end > 0 && !s.is_char_boundary(end) {
781        end -= 1;
782    }
783    s.truncate(end);
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789
790    #[test]
791    fn kind_and_size() {
792        assert_eq!(TaskValue::from(1_i64).kind(), ValueKind::Integer);
793        assert_eq!(TaskValue::from(1.5_f64).kind(), ValueKind::Float);
794        assert_eq!(TaskValue::from("hi").kind(), ValueKind::String);
795        assert_eq!(TaskValue::from("hello").approx_size(), 5);
796        assert!(TaskValue::Array(vec![TaskValue::from("ab")]).approx_size() >= 3);
797    }
798
799    #[test]
800    fn depth_calculation() {
801        let nested = TaskValue::Array(vec![TaskValue::Array(vec![TaskValue::from(1_i64)])]);
802        assert_eq!(nested.depth(), 3);
803        assert_eq!(TaskValue::from(1_i64).depth(), 1);
804    }
805
806    #[test]
807    fn from_serde_json() {
808        let v: serde_json::Value = serde_json::json!({
809            "a": 1,
810            "b": [true, "x", null],
811            "c": 2.5
812        });
813        let tv = TaskValue::from(v);
814        match tv {
815            TaskValue::Object(entries) => {
816                assert_eq!(entries.len(), 3);
817            }
818            _ => panic!("expected object"),
819        }
820    }
821
822    #[test]
823    fn strip_control_chars_removes_escapes_keeps_whitespace() {
824        let mut s = "hello\x1b[31mworld\x00\tok\nline".to_string();
825        let removed = strip_control_chars(&mut s);
826        // ESC, NUL removed; tab and newline kept.
827        assert_eq!(removed, 2);
828        assert_eq!(s, "hello[31mworld\tok\nline");
829    }
830
831    #[test]
832    fn strip_control_chars_noop_on_clean() {
833        let mut s = "perfectly clean\tstring\n".to_string();
834        assert_eq!(strip_control_chars(&mut s), 0);
835        assert_eq!(s, "perfectly clean\tstring\n");
836    }
837
838    #[test]
839    fn sanitize_strips_strings_in_args() {
840        let sanitizer = Sanitizer::default();
841        let mut args = vec![TaskValue::from("a\x07b"), TaskValue::from(5_i64)];
842        let report = sanitizer.sanitize_args(&mut args).unwrap();
843        assert_eq!(args[0], TaskValue::from("ab"));
844        assert_eq!(report.strings_stripped, 1);
845        assert_eq!(report.control_chars_removed, 1);
846    }
847
848    #[test]
849    fn sanitize_redacts_secret_kwargs() {
850        let sanitizer = Sanitizer::default();
851        let mut kwargs = vec![
852            ("username".to_string(), TaskValue::from("alice")),
853            ("password".to_string(), TaskValue::from("hunter2")),
854            ("API_KEY".to_string(), TaskValue::from("sk-123")),
855            ("auth_token".to_string(), TaskValue::from("xyz")),
856        ];
857        let report = sanitizer.sanitize_kwargs(&mut kwargs).unwrap();
858        assert_eq!(kwargs[0].1, TaskValue::from("alice"));
859        assert_eq!(kwargs[1].1, TaskValue::from("[REDACTED]"));
860        assert_eq!(kwargs[2].1, TaskValue::from("[REDACTED]"));
861        assert_eq!(kwargs[3].1, TaskValue::from("[REDACTED]"));
862        assert_eq!(report.redacted_keys, 3);
863    }
864
865    #[test]
866    fn sanitize_redacts_nested_secret_keys() {
867        let sanitizer = Sanitizer::default();
868        let mut args = vec![TaskValue::Object(vec![
869            ("host".to_string(), TaskValue::from("db")),
870            ("secret".to_string(), TaskValue::from("top")),
871        ])];
872        let report = sanitizer.sanitize_args(&mut args).unwrap();
873        assert_eq!(report.redacted_keys, 1);
874        if let TaskValue::Object(entries) = &args[0] {
875            assert_eq!(entries[1].1, TaskValue::from("[REDACTED]"));
876        } else {
877            panic!("expected object");
878        }
879    }
880
881    #[test]
882    fn sanitize_rejects_too_many_args() {
883        let config = SanitizerConfig::default().with_max_arg_count(2);
884        let sanitizer = Sanitizer::new(config);
885        let mut args = vec![
886            TaskValue::from(1_i64),
887            TaskValue::from(2_i64),
888            TaskValue::from(3_i64),
889        ];
890        assert_eq!(
891            sanitizer.sanitize_args(&mut args),
892            Err(SanitizeError::TooManyArgs { count: 3, limit: 2 })
893        );
894    }
895
896    #[test]
897    fn sanitize_combined_arg_count() {
898        let config = SanitizerConfig::default().with_max_arg_count(3);
899        let sanitizer = Sanitizer::new(config);
900        let mut args = vec![TaskValue::from(1_i64), TaskValue::from(2_i64)];
901        let mut kwargs = vec![
902            ("a".to_string(), TaskValue::from(1_i64)),
903            ("b".to_string(), TaskValue::from(2_i64)),
904        ];
905        // 2 + 2 = 4 > 3
906        assert!(matches!(
907            sanitizer.sanitize_call(&mut args, &mut kwargs),
908            Err(SanitizeError::TooManyArgs { count: 4, limit: 3 })
909        ));
910    }
911
912    #[test]
913    fn sanitize_rejects_oversize_string() {
914        let config = SanitizerConfig::default().with_max_string_bytes(4);
915        let sanitizer = Sanitizer::new(config);
916        let mut args = vec![TaskValue::from("toolong")];
917        assert_eq!(
918            sanitizer.sanitize_args(&mut args),
919            Err(SanitizeError::ValueTooLarge { size: 7, limit: 4 })
920        );
921    }
922
923    #[test]
924    fn sanitize_truncates_oversize_string() {
925        let config = SanitizerConfig::default()
926            .with_max_string_bytes(4)
927            .with_oversize_action(OversizeAction::Truncate);
928        let sanitizer = Sanitizer::new(config);
929        let mut args = vec![TaskValue::from("toolong")];
930        let report = sanitizer.sanitize_args(&mut args).unwrap();
931        assert_eq!(args[0], TaskValue::from("tool"));
932        assert_eq!(report.values_truncated, 1);
933    }
934
935    #[test]
936    fn truncate_respects_utf8_boundary() {
937        let config = SanitizerConfig::default()
938            .with_max_string_bytes(3)
939            .with_oversize_action(OversizeAction::Truncate);
940        let sanitizer = Sanitizer::new(config);
941        // "é" is 2 bytes; "aé" is 3 bytes; limit 3 keeps both, limit 2 keeps "a".
942        let mut args = vec![TaskValue::from("aéb")];
943        sanitizer.sanitize_args(&mut args).unwrap();
944        // Must remain valid UTF-8 and not exceed the limit.
945        if let TaskValue::String(s) = &args[0] {
946            assert!(s.len() <= 3);
947            assert_eq!(s, "aé");
948        } else {
949            panic!("expected string");
950        }
951    }
952
953    #[test]
954    fn sanitize_rejects_disallowed_bytes_by_default() {
955        let sanitizer = Sanitizer::default();
956        let mut args = vec![TaskValue::Bytes(vec![1, 2, 3])];
957        assert_eq!(
958            sanitizer.sanitize_args(&mut args),
959            Err(SanitizeError::DisallowedKind(ValueKind::Bytes))
960        );
961    }
962
963    #[test]
964    fn sanitize_allows_bytes_when_permitted() {
965        let config = SanitizerConfig::default().with_disallowed_kinds([]);
966        let sanitizer = Sanitizer::new(config);
967        let mut args = vec![TaskValue::Bytes(vec![1, 2, 3])];
968        assert!(sanitizer.sanitize_args(&mut args).is_ok());
969    }
970
971    #[test]
972    fn sanitize_rejects_too_deep() {
973        let config = SanitizerConfig::default().with_max_depth(2);
974        let sanitizer = Sanitizer::new(config);
975        // depth 3: Array -> Array -> Int
976        let mut args = vec![TaskValue::Array(vec![TaskValue::Array(vec![
977            TaskValue::from(1_i64),
978        ])])];
979        assert!(matches!(
980            sanitizer.sanitize_args(&mut args),
981            Err(SanitizeError::TooDeep { .. })
982        ));
983    }
984
985    #[test]
986    fn sanitize_rejects_long_key() {
987        let config = SanitizerConfig::default();
988        let sanitizer = Sanitizer::new(config);
989        let long_key = "k".repeat(1000);
990        let mut kwargs = vec![(long_key, TaskValue::from(1_i64))];
991        assert!(matches!(
992            sanitizer.sanitize_kwargs(&mut kwargs),
993            Err(SanitizeError::KeyTooLong { .. })
994        ));
995    }
996
997    #[test]
998    fn report_made_changes_and_merge() {
999        let mut a = SanitizeReport::default();
1000        assert!(!a.made_changes());
1001        a.redacted_keys = 1;
1002        assert!(a.made_changes());
1003        let b = SanitizeReport {
1004            strings_stripped: 2,
1005            control_chars_removed: 5,
1006            values_truncated: 1,
1007            redacted_keys: 3,
1008        };
1009        a.merge(&b);
1010        assert_eq!(a.redacted_keys, 4);
1011        assert_eq!(a.control_chars_removed, 5);
1012        assert_eq!(a.values_truncated, 1);
1013    }
1014
1015    #[test]
1016    fn config_serde_roundtrip() {
1017        let config = SanitizerConfig::default().with_max_arg_count(7);
1018        let json = serde_json::to_string(&config).expect("serialize");
1019        let restored: SanitizerConfig = serde_json::from_str(&json).expect("deserialize");
1020        assert_eq!(restored.max_arg_count, 7);
1021        assert!(restored.disallowed_kinds.contains(&ValueKind::Bytes));
1022    }
1023
1024    #[test]
1025    fn permissive_truncates_and_allows_bytes() {
1026        let sanitizer = Sanitizer::new(SanitizerConfig::permissive());
1027        let mut args = vec![TaskValue::Bytes(vec![1, 2, 3])];
1028        assert!(sanitizer.sanitize_args(&mut args).is_ok());
1029    }
1030}