1use serde::{Deserialize, Serialize};
44use std::collections::BTreeSet;
45use std::fmt;
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[serde(untagged)]
56pub enum TaskValue {
57 Null,
59 Bool(bool),
61 Int(i64),
63 UInt(u64),
65 Float(f64),
67 String(String),
69 Bytes(Vec<u8>),
71 Array(Vec<TaskValue>),
73 Object(Vec<(String, TaskValue)>),
76}
77
78impl TaskValue {
79 #[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 #[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 #[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 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
211pub enum ValueKind {
212 Null,
214 Bool,
216 Integer,
218 Float,
220 String,
222 Bytes,
224 Array,
226 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#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum SanitizeError {
254 ValueTooLarge {
257 size: usize,
259 limit: usize,
261 },
262 TooManyArgs {
265 count: usize,
267 limit: usize,
269 },
270 TooDeep {
272 depth: usize,
274 limit: usize,
276 },
277 DisallowedKind(ValueKind),
279 KeyTooLong {
281 size: usize,
283 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323pub enum OversizeAction {
324 Reject,
326 Truncate,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct SanitizerConfig {
333 pub max_arg_count: usize,
335
336 pub max_string_bytes: usize,
338
339 pub max_key_bytes: usize,
341
342 pub max_depth: usize,
344
345 pub oversize_action: OversizeAction,
347
348 pub strip_control_chars: bool,
350
351 pub disallowed_kinds: BTreeSet<ValueKind>,
353
354 pub secret_key_markers: BTreeSet<String>,
357
358 pub redaction_placeholder: String,
360}
361
362impl Default for SanitizerConfig {
363 fn default() -> Self {
364 let mut disallowed = BTreeSet::new();
365 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 #[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 #[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 #[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 #[must_use]
433 pub const fn with_max_depth(mut self, n: usize) -> Self {
434 self.max_depth = n;
435 self
436 }
437
438 #[must_use]
440 pub const fn with_oversize_action(mut self, action: OversizeAction) -> Self {
441 self.oversize_action = action;
442 self
443 }
444
445 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
486pub struct SanitizeReport {
487 pub strings_stripped: usize,
489 pub control_chars_removed: usize,
491 pub values_truncated: usize,
493 pub redacted_keys: usize,
495}
496
497impl SanitizeReport {
498 #[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 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#[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 #[must_use]
531 pub fn new(config: SanitizerConfig) -> Self {
532 Self { config }
533 }
534
535 #[must_use]
537 pub const fn config(&self) -> &SanitizerConfig {
538 &self.config
539 }
540
541 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 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 continue;
581 }
582 self.sanitize_value(value, 1, &mut report)?;
583 }
584 Ok(report)
585 }
586
587 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 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 TaskValue::Null
687 | TaskValue::Bool(_)
688 | TaskValue::Int(_)
689 | TaskValue::UInt(_)
690 | TaskValue::Float(_) => {}
691 }
692
693 Ok(())
694 }
695
696 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 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#[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
773fn 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 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 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 let mut args = vec![TaskValue::from("aéb")];
943 sanitizer.sanitize_args(&mut args).unwrap();
944 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 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}