Skip to main content

hedl_core/
limits.rs

1// Dweve HEDL - Hierarchical Entity Data Language
2//
3// Copyright (c) 2025 Dweve IP B.V. and individual contributors.
4//
5// SPDX-License-Identifier: Apache-2.0
6//
7// Licensed under the Apache License, Version 2.0 (the "License");
8// you may not use this file except in compliance with the License.
9// You may obtain a copy of the License in the LICENSE file at the
10// root of this repository or at: http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! Security limits for HEDL parsing.
19
20use crate::error::{HedlError, HedlResult};
21
22#[cfg(not(target_arch = "wasm32"))]
23use std::time::{Duration, Instant};
24
25#[cfg(target_arch = "wasm32")]
26use std::time::Duration;
27
28/// Configurable limits for parser security.
29///
30/// These limits protect against denial-of-service attacks and memory exhaustion
31/// by bounding the resources consumed during parsing.
32#[derive(Debug, Clone)]
33pub struct Limits {
34    /// Maximum file size in bytes (default: 1GB).
35    pub max_file_size: usize,
36    /// Maximum line length in bytes (default: 1MB).
37    pub max_line_length: usize,
38    /// Maximum indent depth (default: 50).
39    pub max_indent_depth: usize,
40    /// Maximum number of nodes (default: 10M).
41    pub max_nodes: usize,
42    /// Maximum number of aliases (default: 10k).
43    pub max_aliases: usize,
44    /// Maximum columns per schema (default: 100).
45    pub max_columns: usize,
46    /// Maximum NEST hierarchy depth (default: 100).
47    pub max_nest_depth: usize,
48    /// Maximum block string size in bytes (default: 10MB).
49    pub max_block_string_size: usize,
50    /// Maximum number of keys in a single object/map (default: 10k).
51    pub max_object_keys: usize,
52    /// Maximum total number of keys across all objects (default: 10M).
53    ///
54    /// This prevents DoS attacks where an attacker creates many small objects,
55    /// each under the max_object_keys limit, but collectively consuming excessive
56    /// memory. Provides defense-in-depth against memory exhaustion attacks.
57    ///
58    /// Default is 10,000,000 keys, which allows for large documents while still
59    /// providing protection against memory exhaustion. For very large datasets,
60    /// this can be increased via `ParseOptions`.
61    pub max_total_keys: usize,
62    /// Maximum total number of IDs across all types (default: 10M).
63    ///
64    /// This prevents DoS attacks where an attacker registers many IDs across
65    /// multiple types, each type under reasonable limits, but collectively
66    /// consuming excessive memory in the TypeRegistry indices.
67    ///
68    /// Default is 10,000,000 IDs, matching max_total_keys for consistency.
69    /// The TypeRegistry maintains two indices (forward and inverted), so each
70    /// ID registration consumes memory in both data structures.
71    pub max_total_ids: usize,
72    /// Maximum parsing duration (default: 30 seconds).
73    ///
74    /// Prevents denial-of-service attacks where a malicious document causes the
75    /// parser to hang indefinitely. The parser checks elapsed time periodically
76    /// and returns a `Timeout` error if parsing exceeds this duration.
77    ///
78    /// Set to `None` to disable timeout checking (not recommended for untrusted input).
79    pub timeout: Option<Duration>,
80}
81
82impl Default for Limits {
83    fn default() -> Self {
84        Self {
85            max_file_size: 1024 * 1024 * 1024, // 1GB
86            max_line_length: 1024 * 1024,      // 1MB
87            max_indent_depth: 50,
88            max_nodes: 10_000_000,
89            max_aliases: 10_000,
90            max_columns: 100,
91            max_nest_depth: 100,
92            max_block_string_size: 10 * 1024 * 1024, // 10MB
93            max_object_keys: 10_000,
94            max_total_keys: 10_000_000,             // 10M
95            max_total_ids: 10_000_000,              // 10M
96            timeout: Some(Duration::from_secs(30)), // 30 seconds
97        }
98    }
99}
100
101impl Limits {
102    /// Create limits with no restrictions (for testing).
103    pub fn unlimited() -> Self {
104        Self {
105            max_file_size: usize::MAX,
106            max_line_length: usize::MAX,
107            max_indent_depth: usize::MAX,
108            max_nodes: usize::MAX,
109            max_aliases: usize::MAX,
110            max_columns: usize::MAX,
111            max_nest_depth: usize::MAX,
112            max_block_string_size: usize::MAX,
113            max_object_keys: usize::MAX,
114            max_total_keys: usize::MAX,
115            max_total_ids: usize::MAX,
116            timeout: None,
117        }
118    }
119}
120
121/// Timeout context for tracking parsing time and enforcing timeout limits.
122///
123/// This structure tracks the start time of a parsing operation and provides
124/// a method to check whether the configured timeout has been exceeded.
125///
126/// On WASM targets, timeout checking is disabled since `std::time::Instant`
127/// is not available.
128#[derive(Debug, Clone, Copy)]
129pub struct TimeoutContext {
130    #[cfg(not(target_arch = "wasm32"))]
131    start: Instant,
132    #[cfg(not(target_arch = "wasm32"))]
133    timeout: Option<Duration>,
134}
135
136impl TimeoutContext {
137    /// Create a new timeout context with the given timeout duration.
138    #[cfg(not(target_arch = "wasm32"))]
139    pub fn new(timeout: Option<Duration>) -> Self {
140        Self {
141            start: Instant::now(),
142            timeout,
143        }
144    }
145
146    /// Create a new timeout context (no-op on WASM).
147    #[cfg(target_arch = "wasm32")]
148    pub fn new(_timeout: Option<Duration>) -> Self {
149        Self {}
150    }
151
152    /// Check if timeout has been exceeded. Returns an error if timeout exceeded.
153    ///
154    /// # Arguments
155    ///
156    /// * `line_num` - Line number for error reporting
157    ///
158    /// # Errors
159    ///
160    /// Returns a security error if the elapsed time exceeds the configured timeout.
161    /// On WASM targets, this always returns Ok.
162    #[cfg(not(target_arch = "wasm32"))]
163    pub fn check_timeout(&self, line_num: usize) -> HedlResult<()> {
164        if let Some(timeout) = self.timeout {
165            let elapsed = self.start.elapsed();
166            if elapsed > timeout {
167                return Err(HedlError::security(
168                    format!(
169                        "parsing timeout exceeded: {}ms > {}ms",
170                        elapsed.as_millis(),
171                        timeout.as_millis()
172                    ),
173                    line_num,
174                ));
175            }
176        }
177        Ok(())
178    }
179
180    /// Check if timeout has been exceeded (no-op on WASM).
181    #[cfg(target_arch = "wasm32")]
182    #[inline(always)]
183    pub fn check_timeout(&self, _line_num: usize) -> HedlResult<()> {
184        Ok(())
185    }
186}
187
188/// Default interval for periodic timeout checks (every 10,000 iterations).
189///
190/// This value balances timeout detection responsiveness with performance overhead:
191/// - At typical parsing speeds (~100k lines/sec), checks occur every ~100ms
192/// - Calling `Instant::elapsed()` every 10k iterations adds <0.01% overhead
193/// - Timeout detection latency is ~1ms worst-case
194pub const DEFAULT_TIMEOUT_CHECK_INTERVAL: usize = 10_000;
195
196/// Iterator adapter that performs periodic timeout checks.
197///
198/// This adapter wraps an iterator and checks for timeout every N iterations,
199/// balancing responsiveness with performance. The check interval is configurable
200/// but defaults to 10,000 iterations for optimal performance.
201///
202/// # Performance
203///
204/// Calling `Instant::elapsed()` on every iteration adds measurable overhead.
205/// The default 10,000 iteration interval provides:
206/// - Minimal performance impact (<0.01% overhead)
207/// - Reasonable timeout detection latency (~1ms at typical parsing speeds)
208/// - Balance between responsiveness and efficiency
209///
210/// # Examples
211///
212/// ```ignore
213/// // Internal API - limits module is private
214/// use hedl_core::limits::{TimeoutContext, TimeoutCheckExt};
215/// use std::time::Duration;
216///
217/// let timeout_ctx = TimeoutContext::new(Some(Duration::from_secs(30)));
218/// let lines = vec![(1, "line1"), (2, "line2"), (3, "line3")];
219///
220/// for result in lines.iter().copied().with_timeout_check(&timeout_ctx) {
221///     let (line_num, line) = result.unwrap();
222///     // Process line - timeout checked automatically every 10,000 iterations
223/// }
224/// ```
225pub struct TimeoutCheckIterator<'a, I>
226where
227    I: Iterator,
228{
229    inner: I,
230    timeout_ctx: &'a TimeoutContext,
231    check_interval: usize,
232    iteration_count: usize,
233}
234
235impl<'a, I> TimeoutCheckIterator<'a, I>
236where
237    I: Iterator,
238{
239    /// Create a new timeout-checking iterator with the default check interval.
240    pub fn new(inner: I, timeout_ctx: &'a TimeoutContext) -> Self {
241        Self::with_interval(inner, timeout_ctx, DEFAULT_TIMEOUT_CHECK_INTERVAL)
242    }
243
244    /// Create a new timeout-checking iterator with a custom check interval.
245    ///
246    /// # Arguments
247    ///
248    /// * `inner` - The underlying iterator to wrap
249    /// * `timeout_ctx` - The timeout context to check against
250    /// * `check_interval` - Number of iterations between timeout checks
251    pub fn with_interval(inner: I, timeout_ctx: &'a TimeoutContext, check_interval: usize) -> Self {
252        Self {
253            inner,
254            timeout_ctx,
255            check_interval,
256            iteration_count: 0,
257        }
258    }
259}
260
261impl<'a, I> Iterator for TimeoutCheckIterator<'a, I>
262where
263    I: Iterator<Item = (usize, &'a str)>,
264{
265    type Item = Result<(usize, &'a str), HedlError>;
266
267    fn next(&mut self) -> Option<Self::Item> {
268        // Get next item from inner iterator
269        let item = self.inner.next()?;
270        let (line_num, _line) = item;
271
272        // Periodic timeout check
273        self.iteration_count += 1;
274        if self.iteration_count % self.check_interval == 0 {
275            if let Err(e) = self.timeout_ctx.check_timeout(line_num) {
276                return Some(Err(e));
277            }
278        }
279
280        Some(Ok(item))
281    }
282
283    fn size_hint(&self) -> (usize, Option<usize>) {
284        self.inner.size_hint()
285    }
286}
287
288/// Extension trait for adding timeout checking to iterators.
289///
290/// This trait provides a convenient method to wrap any iterator with
291/// periodic timeout checks.
292pub trait TimeoutCheckExt<'a>: Iterator<Item = (usize, &'a str)> + Sized {
293    /// Add periodic timeout checking to this iterator.
294    ///
295    /// The iterator will check for timeout every 10,000 iterations by default.
296    ///
297    /// # Examples
298    ///
299    /// ```ignore
300    /// // Internal API - limits module is private
301    /// use hedl_core::limits::{TimeoutContext, TimeoutCheckExt};
302    /// use std::time::Duration;
303    ///
304    /// let timeout_ctx = TimeoutContext::new(Some(Duration::from_secs(30)));
305    /// let lines = vec![(1, "line1"), (2, "line2")];
306    ///
307    /// for result in lines.iter().copied().with_timeout_check(&timeout_ctx) {
308    ///     let (line_num, line) = result.unwrap();
309    ///     // Process line
310    /// }
311    /// ```
312    fn with_timeout_check(self, timeout_ctx: &'a TimeoutContext) -> TimeoutCheckIterator<'a, Self> {
313        TimeoutCheckIterator::new(self, timeout_ctx)
314    }
315}
316
317// Blanket implementation for all iterators with the right item type
318impl<'a, I> TimeoutCheckExt<'a> for I where I: Iterator<Item = (usize, &'a str)> {}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    // ==================== Default limits tests ====================
325
326    #[test]
327    fn test_default_max_file_size() {
328        let limits = Limits::default();
329        assert_eq!(limits.max_file_size, 1024 * 1024 * 1024); // 1GB
330    }
331
332    #[test]
333    fn test_default_max_line_length() {
334        let limits = Limits::default();
335        assert_eq!(limits.max_line_length, 1024 * 1024); // 1MB
336    }
337
338    #[test]
339    fn test_default_max_indent_depth() {
340        let limits = Limits::default();
341        assert_eq!(limits.max_indent_depth, 50);
342    }
343
344    #[test]
345    fn test_default_max_nodes() {
346        let limits = Limits::default();
347        assert_eq!(limits.max_nodes, 10_000_000); // 10M
348    }
349
350    #[test]
351    fn test_default_max_aliases() {
352        let limits = Limits::default();
353        assert_eq!(limits.max_aliases, 10_000); // 10k
354    }
355
356    #[test]
357    fn test_default_max_columns() {
358        let limits = Limits::default();
359        assert_eq!(limits.max_columns, 100);
360    }
361
362    // ==================== Unlimited limits tests ====================
363
364    #[test]
365    fn test_unlimited_max_file_size() {
366        let limits = Limits::unlimited();
367        assert_eq!(limits.max_file_size, usize::MAX);
368    }
369
370    #[test]
371    fn test_unlimited_max_line_length() {
372        let limits = Limits::unlimited();
373        assert_eq!(limits.max_line_length, usize::MAX);
374    }
375
376    #[test]
377    fn test_unlimited_max_indent_depth() {
378        let limits = Limits::unlimited();
379        assert_eq!(limits.max_indent_depth, usize::MAX);
380    }
381
382    #[test]
383    fn test_unlimited_max_nodes() {
384        let limits = Limits::unlimited();
385        assert_eq!(limits.max_nodes, usize::MAX);
386    }
387
388    #[test]
389    fn test_unlimited_max_aliases() {
390        let limits = Limits::unlimited();
391        assert_eq!(limits.max_aliases, usize::MAX);
392    }
393
394    #[test]
395    fn test_unlimited_max_columns() {
396        let limits = Limits::unlimited();
397        assert_eq!(limits.max_columns, usize::MAX);
398    }
399
400    // ==================== Clone and Debug tests ====================
401
402    #[test]
403    fn test_limits_clone() {
404        let original = Limits::default();
405        let cloned = original.clone();
406        assert_eq!(original.max_file_size, cloned.max_file_size);
407        assert_eq!(original.max_line_length, cloned.max_line_length);
408        assert_eq!(original.max_indent_depth, cloned.max_indent_depth);
409        assert_eq!(original.max_nodes, cloned.max_nodes);
410        assert_eq!(original.max_aliases, cloned.max_aliases);
411        assert_eq!(original.max_columns, cloned.max_columns);
412    }
413
414    #[test]
415    fn test_limits_debug() {
416        let limits = Limits::default();
417        let debug = format!("{:?}", limits);
418        assert!(debug.contains("max_file_size"));
419        assert!(debug.contains("max_line_length"));
420        assert!(debug.contains("max_indent_depth"));
421        assert!(debug.contains("max_nodes"));
422        assert!(debug.contains("max_aliases"));
423        assert!(debug.contains("max_columns"));
424    }
425
426    // ==================== Custom limits tests ====================
427
428    #[test]
429    fn test_custom_limits() {
430        let limits = Limits {
431            max_file_size: 100,
432            max_line_length: 200,
433            max_indent_depth: 5,
434            max_nodes: 1000,
435            max_aliases: 50,
436            max_columns: 10,
437            max_nest_depth: 20,
438            max_block_string_size: 5000,
439            max_object_keys: 100,
440            max_total_keys: 500,
441            max_total_ids: 1000,
442            timeout: Some(Duration::from_secs(5)),
443        };
444        assert_eq!(limits.max_file_size, 100);
445        assert_eq!(limits.max_line_length, 200);
446        assert_eq!(limits.max_indent_depth, 5);
447        assert_eq!(limits.max_nodes, 1000);
448        assert_eq!(limits.max_aliases, 50);
449        assert_eq!(limits.max_columns, 10);
450        assert_eq!(limits.max_nest_depth, 20);
451        assert_eq!(limits.max_block_string_size, 5000);
452        assert_eq!(limits.max_object_keys, 100);
453        assert_eq!(limits.max_total_keys, 500);
454        assert_eq!(limits.max_total_ids, 1000);
455        assert_eq!(limits.timeout, Some(Duration::from_secs(5)));
456    }
457
458    #[test]
459    fn test_limits_zero_values() {
460        let limits = Limits {
461            max_file_size: 0,
462            max_line_length: 0,
463            max_indent_depth: 0,
464            max_nodes: 0,
465            max_aliases: 0,
466            max_columns: 0,
467            max_nest_depth: 0,
468            max_block_string_size: 0,
469            max_object_keys: 0,
470            max_total_keys: 0,
471            max_total_ids: 0,
472            timeout: Some(Duration::from_secs(0)),
473        };
474        assert_eq!(limits.max_file_size, 0);
475        assert_eq!(limits.max_columns, 0);
476        assert_eq!(limits.max_nest_depth, 0);
477        assert_eq!(limits.max_block_string_size, 0);
478        assert_eq!(limits.max_object_keys, 0);
479        assert_eq!(limits.max_total_keys, 0);
480    }
481
482    // ==================== New limits tests ====================
483
484    #[test]
485    fn test_default_max_nest_depth() {
486        let limits = Limits::default();
487        assert_eq!(limits.max_nest_depth, 100);
488    }
489
490    #[test]
491    fn test_default_max_block_string_size() {
492        let limits = Limits::default();
493        assert_eq!(limits.max_block_string_size, 10 * 1024 * 1024); // 10MB
494    }
495
496    #[test]
497    fn test_unlimited_max_nest_depth() {
498        let limits = Limits::unlimited();
499        assert_eq!(limits.max_nest_depth, usize::MAX);
500    }
501
502    #[test]
503    fn test_unlimited_max_block_string_size() {
504        let limits = Limits::unlimited();
505        assert_eq!(limits.max_block_string_size, usize::MAX);
506    }
507
508    #[test]
509    fn test_default_max_total_keys() {
510        let limits = Limits::default();
511        assert_eq!(limits.max_total_keys, 10_000_000);
512    }
513
514    #[test]
515    fn test_unlimited_max_total_keys() {
516        let limits = Limits::unlimited();
517        assert_eq!(limits.max_total_keys, usize::MAX);
518    }
519
520    #[test]
521    fn test_max_total_keys_greater_than_max_object_keys() {
522        let limits = Limits::default();
523        assert!(
524            limits.max_total_keys > limits.max_object_keys,
525            "max_total_keys ({}) should be greater than max_object_keys ({})",
526            limits.max_total_keys,
527            limits.max_object_keys
528        );
529    }
530
531    // ==================== max_total_ids tests ====================
532
533    #[test]
534    fn test_default_max_total_ids() {
535        let limits = Limits::default();
536        assert_eq!(limits.max_total_ids, 10_000_000);
537    }
538
539    #[test]
540    fn test_unlimited_max_total_ids() {
541        let limits = Limits::unlimited();
542        assert_eq!(limits.max_total_ids, usize::MAX);
543    }
544
545    #[test]
546    fn test_max_total_ids_matches_max_total_keys() {
547        let limits = Limits::default();
548        assert_eq!(
549            limits.max_total_ids, limits.max_total_keys,
550            "max_total_ids ({}) should match max_total_keys ({}) for consistency",
551            limits.max_total_ids, limits.max_total_keys
552        );
553    }
554
555    // ==================== Timeout tests ====================
556
557    #[test]
558    fn test_default_timeout() {
559        let limits = Limits::default();
560        assert_eq!(limits.timeout, Some(Duration::from_secs(30)));
561    }
562
563    #[test]
564    fn test_unlimited_no_timeout() {
565        let limits = Limits::unlimited();
566        assert_eq!(limits.timeout, None);
567    }
568
569    #[test]
570    fn test_custom_timeout() {
571        let limits = Limits {
572            timeout: Some(Duration::from_secs(60)),
573            ..Limits::default()
574        };
575        assert_eq!(limits.timeout, Some(Duration::from_secs(60)));
576    }
577
578    #[test]
579    fn test_disabled_timeout() {
580        let limits = Limits {
581            timeout: None,
582            ..Limits::default()
583        };
584        assert_eq!(limits.timeout, None);
585    }
586
587    // ==================== TimeoutContext tests ====================
588
589    #[test]
590    fn test_timeout_context_no_timeout() {
591        let ctx = TimeoutContext::new(None);
592        // Should never timeout when timeout is None
593        assert!(ctx.check_timeout(1).is_ok());
594        assert!(ctx.check_timeout(1000).is_ok());
595    }
596
597    #[test]
598    fn test_timeout_context_with_generous_timeout() {
599        let ctx = TimeoutContext::new(Some(Duration::from_secs(10)));
600        // Should not timeout immediately
601        assert!(ctx.check_timeout(1).is_ok());
602    }
603
604    #[test]
605    fn test_timeout_context_with_zero_timeout() {
606        // Zero timeout should immediately trigger
607        let ctx = TimeoutContext::new(Some(Duration::from_micros(1)));
608        // Sleep a tiny bit to ensure elapsed time > 1 microsecond
609        std::thread::sleep(Duration::from_micros(10));
610        // Should timeout
611        let result = ctx.check_timeout(42);
612        assert!(result.is_err());
613        if let Err(e) = result {
614            let msg = e.to_string();
615            assert!(msg.contains("timeout exceeded") || msg.contains("Timeout"));
616        }
617    }
618
619    #[test]
620    fn test_timeout_context_error_message() {
621        let ctx = TimeoutContext::new(Some(Duration::from_nanos(1)));
622        std::thread::sleep(Duration::from_millis(1));
623        let result = ctx.check_timeout(123);
624        assert!(result.is_err());
625        if let Err(e) = result {
626            let msg = e.to_string();
627            assert!(msg.contains("123")); // Should include line number
628        }
629    }
630
631    // ==================== TimeoutCheckIterator tests ====================
632
633    #[test]
634    fn test_timeout_iterator_basic() {
635        let lines = [(1, "line1"), (2, "line2"), (3, "line3")];
636        let timeout_ctx = TimeoutContext::new(Some(Duration::from_secs(60)));
637
638        let mut count = 0;
639        for result in lines.iter().copied().with_timeout_check(&timeout_ctx) {
640            let (_line_num, _line) = result.unwrap();
641            count += 1;
642        }
643        assert_eq!(count, 3);
644    }
645
646    #[test]
647    fn test_timeout_iterator_no_timeout() {
648        let lines = vec![(1, "a"); 1000];
649        let timeout_ctx = TimeoutContext::new(Some(Duration::from_secs(60)));
650
651        let count = lines
652            .iter()
653            .copied()
654            .with_timeout_check(&timeout_ctx)
655            .filter_map(Result::ok)
656            .count();
657        assert_eq!(count, 1000);
658    }
659
660    #[test]
661    fn test_timeout_iterator_triggers_timeout() {
662        // Create lines that will take long to process
663        let lines: Vec<(usize, &str)> = (1..=100_000).map(|i| (i, "line")).collect();
664
665        // Very short timeout (1 microsecond)
666        let timeout_ctx = TimeoutContext::new(Some(Duration::from_micros(1)));
667
668        // Should eventually hit timeout (checked every 10k iterations)
669        let mut hit_timeout = false;
670        for result in lines.iter().copied().with_timeout_check(&timeout_ctx) {
671            if result.is_err() {
672                hit_timeout = true;
673                break;
674            }
675        }
676
677        // May or may not timeout depending on machine speed, but should not panic
678        // This test mainly verifies the mechanism works without errors
679        // Use underscore prefix to indicate intentional unused value check
680        let _ = hit_timeout; // Exercises code path, value not relevant
681    }
682
683    #[test]
684    fn test_timeout_iterator_custom_interval() {
685        let lines = vec![(1, "a"); 100];
686        let timeout_ctx = TimeoutContext::new(Some(Duration::from_secs(60)));
687
688        // Use very small interval (check every iteration)
689        let count = TimeoutCheckIterator::with_interval(lines.iter().copied(), &timeout_ctx, 1)
690            .filter_map(Result::ok)
691            .count();
692        assert_eq!(count, 100);
693    }
694
695    #[test]
696    fn test_timeout_iterator_size_hint() {
697        let lines = [(1, "a"), (2, "b"), (3, "c")];
698        let timeout_ctx = TimeoutContext::new(Some(Duration::from_secs(60)));
699
700        let iter = lines.iter().copied().with_timeout_check(&timeout_ctx);
701        let (lower, upper) = iter.size_hint();
702        assert_eq!(lower, 3);
703        assert_eq!(upper, Some(3));
704    }
705
706    #[test]
707    fn test_timeout_iterator_empty() {
708        let lines: Vec<(usize, &str)> = vec![];
709        let timeout_ctx = TimeoutContext::new(Some(Duration::from_secs(60)));
710
711        let count = lines
712            .iter()
713            .copied()
714            .with_timeout_check(&timeout_ctx)
715            .filter_map(Result::ok)
716            .count();
717        assert_eq!(count, 0);
718    }
719
720    #[test]
721    fn test_timeout_iterator_single_item() {
722        let lines = [(1, "line")];
723        let timeout_ctx = TimeoutContext::new(Some(Duration::from_secs(60)));
724
725        let items: Vec<_> = lines
726            .iter()
727            .copied()
728            .with_timeout_check(&timeout_ctx)
729            .collect();
730        assert_eq!(items.len(), 1);
731        assert!(items[0].is_ok());
732    }
733
734    #[test]
735    fn test_timeout_iterator_no_timeout_configured() {
736        let lines = vec![(1, "a"); 1000];
737        let timeout_ctx = TimeoutContext::new(None);
738
739        let count = lines
740            .iter()
741            .copied()
742            .with_timeout_check(&timeout_ctx)
743            .filter_map(Result::ok)
744            .count();
745        assert_eq!(count, 1000);
746    }
747
748    #[test]
749    fn test_default_timeout_check_interval() {
750        assert_eq!(DEFAULT_TIMEOUT_CHECK_INTERVAL, 10_000);
751    }
752
753    // ==================== Integration tests ====================
754
755    #[test]
756    fn test_timeout_check_interval_performance_characteristic() {
757        // Verify that check interval is large enough to minimize overhead
758        // but small enough for reasonable timeout detection
759        let interval = DEFAULT_TIMEOUT_CHECK_INTERVAL;
760
761        // Should be >= 1000 for performance (avoid excessive checks)
762        assert!(
763            interval >= 1000,
764            "Check interval too small, may impact performance"
765        );
766
767        // Should be <= 100_000 for responsiveness (detect timeout reasonably quickly)
768        assert!(
769            interval <= 100_000,
770            "Check interval too large, slow timeout detection"
771        );
772    }
773}