tacet 0.4.2

Detect timing side channels in cryptographic code
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
//! Sample collection with randomized interleaved design.
//!
//! Implements a measurement strategy that alternates between Fixed and Random
//! inputs in a randomized order to minimize systematic biases from:
//! - CPU frequency scaling
//! - Cache warming/cooling
//! - Branch predictor state
//! - Other temporal effects

use crate::result::{BatchingInfo, UnmeasurableInfo};
use crate::types::Class;
use rand::seq::SliceRandom;

use super::timer::{black_box, rdtsc, Timer};

/// Minimum ticks per single call for reliable measurement.
/// Below this, we cannot distinguish timing differences from quantization noise.
pub const MIN_TICKS_SINGLE_CALL: f64 = 5.0;

/// Target ticks per batch for stable quantile-based inference.
/// Below ~50 ticks, the empirical distribution collapses to a sparse PMF.
pub const TARGET_TICKS_PER_BATCH: f64 = 50.0;

/// Maximum batch size to limit microarchitectural state accumulation.
/// Higher values cause false positives from cache/predictor effects.
/// With K ≤ 20, these artifacts are limited.
pub const MAX_BATCH_SIZE: u32 = 20;

/// Number of pilot samples for adaptive K selection.
pub const PILOT_SAMPLES: usize = 100;

/// A single timing measurement sample.
#[derive(Debug, Clone, Copy)]
pub struct Sample {
    /// The input class (Fixed or Random).
    pub class: Class,
    /// The measured execution time in cycles (batch total if batching enabled).
    pub cycles: u64,
}

impl Sample {
    /// Create a new sample.
    pub fn new(class: Class, cycles: u64) -> Self {
        Self { class, cycles }
    }
}

/// Collector for gathering timing measurements with interleaved design.
///
/// The collector alternates between measuring Fixed and Random inputs
/// in a randomized order to minimize systematic biases.
#[derive(Debug)]
pub struct Collector {
    /// The timer used for measurements.
    timer: Timer,
    /// Number of warmup iterations to run before measuring.
    warmup_iterations: usize,
    /// Maximum batch size (set to 1 to disable batching entirely).
    max_batch_size: u32,
    /// Target ticks per batch for adaptive K selection.
    target_ticks_per_batch: f64,
}

impl Collector {
    /// Create a new collector with the given warmup iterations.
    pub fn new(warmup_iterations: usize) -> Self {
        let timer = Timer::new();
        Self {
            timer,
            warmup_iterations,
            max_batch_size: MAX_BATCH_SIZE,
            target_ticks_per_batch: TARGET_TICKS_PER_BATCH,
        }
    }

    /// Create a collector with a pre-calibrated timer.
    pub fn with_timer(timer: Timer, warmup_iterations: usize) -> Self {
        Self {
            timer,
            warmup_iterations,
            max_batch_size: MAX_BATCH_SIZE,
            target_ticks_per_batch: TARGET_TICKS_PER_BATCH,
        }
    }

    /// Create a collector with explicit max batch size (set to 1 to disable batching).
    pub fn with_max_batch_size(
        timer: Timer,
        warmup_iterations: usize,
        max_batch_size: u32,
    ) -> Self {
        Self {
            timer,
            warmup_iterations,
            max_batch_size: max_batch_size.max(1),
            target_ticks_per_batch: TARGET_TICKS_PER_BATCH,
        }
    }

    /// Get a reference to the internal timer.
    pub fn timer(&self) -> &Timer {
        &self.timer
    }

    /// Run pilot phase to measure operation duration and select K.
    ///
    /// During warmup, measures ~100 individual operations to determine
    /// median operation time, then selects K to achieve target tick density.
    ///
    /// # Returns
    ///
    /// BatchingInfo including K, ticks_per_batch, rationale, and unmeasurable status.
    fn pilot_and_warmup<F, R, T>(&self, mut fixed: F, mut random: R) -> BatchingInfo
    where
        F: FnMut() -> T,
        R: FnMut() -> T,
    {
        let pilot_count = PILOT_SAMPLES.min(self.warmup_iterations);
        let warmup_only = self.warmup_iterations.saturating_sub(pilot_count);

        // Run initial warmup (without measurement)
        for _ in 0..warmup_only {
            black_box(fixed());
            black_box(random());
        }

        // Pilot phase: measure individual operations
        let mut pilot_cycles = Vec::with_capacity(pilot_count * 2);

        for _ in 0..pilot_count {
            let start = rdtsc();
            black_box(fixed());
            let end = rdtsc();
            pilot_cycles.push(end.saturating_sub(start));

            let start = rdtsc();
            black_box(random());
            let end = rdtsc();
            pilot_cycles.push(end.saturating_sub(start));
        }

        // Compute median operation time
        pilot_cycles.sort_unstable();
        let median_cycles = pilot_cycles[pilot_cycles.len() / 2];
        let median_ns = self.timer.cycles_to_ns(median_cycles);
        let resolution_ns = self.timer.resolution_ns();

        // Calculate ticks per call (how many timer ticks per single operation)
        let ticks_per_call = median_ns / resolution_ns;
        let _threshold_ns = resolution_ns * TARGET_TICKS_PER_BATCH / MAX_BATCH_SIZE as f64;

        // Select K to achieve target tick density
        let (k, enabled, unmeasurable, rationale) = if ticks_per_call >= self.target_ticks_per_batch
        {
            // No batching needed - individual measurements have enough resolution
            (
                1,
                false,
                None,
                format!(
                    "no batching needed ({:.1} ticks/call >= {:.0} target)",
                    ticks_per_call, self.target_ticks_per_batch
                ),
            )
        } else {
            // Need batching to achieve target tick density
            let k_raw = (self.target_ticks_per_batch / ticks_per_call).ceil() as u32;
            let k_attempt = k_raw.clamp(1, self.max_batch_size);
            let actual_ticks = ticks_per_call * k_attempt as f64;

            // Check if even with max batching we're still below measurability threshold
            // Spec §4.5-4.6: If K*Top < 50*Tres even at K=20, it's Unmeasurable.
            if actual_ticks < self.target_ticks_per_batch {
                #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
                let suggestion =
                    ". On macOS, run with sudo to enable kperf cycle counting (~1ns resolution)";
                #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
                let suggestion = ". Run with sudo and --features perf for cycle-accurate timing";
                #[cfg(not(target_arch = "aarch64"))]
                let suggestion = "";

                let rationale = format!(
                    "UNMEASURABLE: {:.1} ticks/batch < {:.0} minimum even at K={} (op ~{:.2}ns, threshold ~{:.2}ns){}",
                    actual_ticks,
                    self.target_ticks_per_batch,
                    k_attempt,
                    median_ns,
                    resolution_ns * self.target_ticks_per_batch / k_attempt as f64,
                    suggestion
                );

                (
                    1, // Revert to k=1 for reporting unmeasurable
                    false,
                    Some(UnmeasurableInfo {
                        operation_ns: median_ns,
                        threshold_ns: resolution_ns * self.target_ticks_per_batch
                            / k_attempt as f64,
                        ticks_per_call,
                    }),
                    rationale,
                )
            } else {
                let partial = actual_ticks < self.target_ticks_per_batch;
                let rationale = if partial {
                    format!(
                        "K={} ({:.1} ticks/batch < {:.0} target, capped at MAX_BATCH_SIZE={})",
                        k_attempt, actual_ticks, self.target_ticks_per_batch, self.max_batch_size
                    )
                } else {
                    format!(
                        "K={} ({:.1} ticks/batch, {:.2} ticks/call, timer res {:.1}ns)",
                        k_attempt, actual_ticks, ticks_per_call, resolution_ns
                    )
                };
                (k_attempt, k_attempt > 1, None, rationale)
            }
        };

        BatchingInfo {
            enabled,
            k,
            ticks_per_batch: ticks_per_call * k as f64,
            rationale,
            unmeasurable,
        }
    }

    /// Collect timing samples using randomized interleaved design.
    ///
    /// This method:
    /// 1. Runs pilot phase to measure operation duration and select K
    /// 2. Runs remaining warmup iterations
    /// 3. Creates a randomized schedule alternating Fixed/Random
    /// 4. Measures each execution and records the timing
    ///
    /// # Adaptive Batching
    ///
    /// When timer resolution is coarse relative to operation duration, batching
    /// multiple iterations recovers effective resolution by measuring aggregate time.
    ///
    /// **Target condition:** `batch_ticks = K × operation_time / timer_resolution > 50`
    ///
    /// Below ~50 ticks, the empirical distribution collapses to a sparse PMF,
    /// making quantile-based inference unstable.
    ///
    /// # What Batching Tests
    ///
    /// Batching changes the estimand: you're testing **amortized cost over K executions**
    /// rather than individual operation timing. This is a valid threat model - real
    /// attackers often average repeated measurements.
    ///
    /// When batching is enabled:
    /// - Samples contain **batch totals** (not divided by K)
    /// - Effect sizes should be divided by K when reporting per-call differences
    ///
    /// # Arguments
    ///
    /// * `samples_per_class` - Number of samples to collect for each class
    /// * `fixed` - Closure that executes with the fixed input
    /// * `random` - Closure that executes with random inputs
    ///
    /// # Returns
    ///
    /// A tuple of (samples, batching_info).
    pub fn collect_with_info<F, R, T>(
        &self,
        samples_per_class: usize,
        mut fixed: F,
        mut random: R,
    ) -> (Vec<Sample>, BatchingInfo)
    where
        F: FnMut() -> T,
        R: FnMut() -> T,
    {
        // Run pilot and warmup, get batching configuration
        let batching_info = self.pilot_and_warmup(&mut fixed, &mut random);
        let k = batching_info.k;

        // Create measurement schedule
        let schedule = self.create_schedule(samples_per_class);

        // Collect measurements
        let mut samples = Vec::with_capacity(samples_per_class * 2);

        if k == 1 {
            // Single-iteration measurement
            for class in schedule {
                let result = match class {
                    Class::Baseline => self.timer.measure_cycles(&mut fixed),
                    Class::Sample => self.timer.measure_cycles(&mut random),
                };

                // Skip invalid measurements - they corrupt statistical analysis
                if let Ok(cycles) = result {
                    samples.push(Sample::new(class, cycles));
                }
            }
        } else {
            // Batched measurement - return batch totals (not divided)
            for class in schedule {
                let result = match class {
                    Class::Baseline => self.measure_batch_total(&mut fixed, k),
                    Class::Sample => self.measure_batch_total(&mut random, k),
                };

                // Skip invalid measurements - they corrupt statistical analysis
                if let Ok(cycles) = result {
                    samples.push(Sample::new(class, cycles));
                }
            }
        }

        (samples, batching_info)
    }

    /// Collect timing samples (convenience method without batching info).
    ///
    /// For backward compatibility. Use `collect_with_info` to get batching details.
    pub fn collect<F, R, T>(&self, samples_per_class: usize, fixed: F, random: R) -> Vec<Sample>
    where
        F: FnMut() -> T,
        R: FnMut() -> T,
    {
        let (samples, _) = self.collect_with_info(samples_per_class, fixed, random);
        samples
    }

    /// Measure a batch of K iterations and return the total cycles (not divided).
    #[inline]
    fn measure_batch_total<F, T>(&self, f: &mut F, k: u32) -> super::error::MeasurementResult
    where
        F: FnMut() -> T,
    {
        let start = rdtsc();
        for _ in 0..k {
            black_box(f());
        }
        let end = rdtsc();
        Ok(end.saturating_sub(start))
    }

    /// Create a randomized interleaved measurement schedule.
    ///
    /// The schedule ensures equal numbers of Fixed and Random measurements
    /// while randomizing the order to prevent systematic biases.
    fn create_schedule(&self, samples_per_class: usize) -> Vec<Class> {
        let mut rng = rand::rng();

        // Create balanced schedule
        let mut schedule: Vec<Class> = Vec::with_capacity(samples_per_class * 2);
        schedule.extend(std::iter::repeat_n(Class::Baseline, samples_per_class));
        schedule.extend(std::iter::repeat_n(Class::Sample, samples_per_class));

        // Shuffle for randomized interleaving
        schedule.shuffle(&mut rng);

        schedule
    }

    /// Collect samples and separate by class, with batching info.
    ///
    /// # Returns
    ///
    /// A tuple of (fixed_samples, random_samples, batching_info).
    pub fn collect_separated<F, R, T>(
        &self,
        samples_per_class: usize,
        fixed: F,
        random: R,
    ) -> (Vec<u64>, Vec<u64>, BatchingInfo)
    where
        F: FnMut() -> T,
        R: FnMut() -> T,
    {
        let (samples, batching_info) = self.collect_with_info(samples_per_class, fixed, random);

        let mut fixed_samples = Vec::with_capacity(samples_per_class);
        let mut random_samples = Vec::with_capacity(samples_per_class);

        for sample in samples {
            match sample.class {
                Class::Baseline => fixed_samples.push(sample.cycles),
                Class::Sample => random_samples.push(sample.cycles),
            }
        }

        (fixed_samples, random_samples, batching_info)
    }
}

impl Default for Collector {
    fn default() -> Self {
        Self::new(1000)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_sample_creation() {
        let sample = Sample::new(Class::Baseline, 1000);
        assert_eq!(sample.class, Class::Baseline);
        assert_eq!(sample.cycles, 1000);
    }

    #[test]
    fn test_schedule_balanced() {
        let collector = Collector::new(0);
        let schedule = collector.create_schedule(100);

        let baseline_count = schedule.iter().filter(|c| **c == Class::Baseline).count();
        let sample_count = schedule.iter().filter(|c| **c == Class::Sample).count();

        assert_eq!(baseline_count, 100);
        assert_eq!(sample_count, 100);
    }

    #[test]
    fn test_collector_basic() {
        let collector = Collector::new(10);

        let counter = std::sync::atomic::AtomicU64::new(0);
        let (fixed, random, _batching) = collector.collect_separated(
            100,
            || counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
            || counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
        );

        assert_eq!(fixed.len(), 100);
        assert_eq!(random.len(), 100);
    }

    #[test]
    fn test_unmeasurable_detection() {
        // Test that extremely fast operations are detected as unmeasurable
        let collector = Collector::new(10);

        // A trivial operation that completes in near-zero time
        let (_, batching) = collector.collect_with_info(
            100,
            || 42u8, // Trivial constant return
            || 42u8,
        );

        // On ARM (41ns resolution), this should be unmeasurable
        // On x86 (0.3ns resolution), it might still measure
        #[cfg(target_arch = "aarch64")]
        {
            // ARM timer has coarse resolution, trivial ops should be unmeasurable
            if batching.ticks_per_batch < MIN_TICKS_SINGLE_CALL {
                assert!(
                    batching.unmeasurable.is_some(),
                    "Expected unmeasurable for trivial op on ARM, got: {:?}",
                    batching
                );
                let info = batching.unmeasurable.as_ref().unwrap();
                assert!(info.ticks_per_call < MIN_TICKS_SINGLE_CALL);
                assert!(batching.rationale.contains("UNMEASURABLE"));
            }
        }

        // On any architecture, if unmeasurable is set, fields should be consistent
        if let Some(ref info) = batching.unmeasurable {
            assert!(info.ticks_per_call < MIN_TICKS_SINGLE_CALL);
            assert!(info.threshold_ns > 0.0);
            assert!(info.operation_ns >= 0.0);
            assert!(!batching.enabled);
            assert_eq!(batching.k, 1);
        }
    }

    #[test]
    fn test_batching_k_selection() {
        // Test K selection logic with a controlled slow operation
        let collector = Collector::new(10);

        // Create an operation slow enough to be measurable but potentially need batching
        let (_, batching) = collector.collect_with_info(
            100,
            || {
                // Busy work to create measurable timing
                let mut x = 0u64;
                for i in 0..1000 {
                    x = x.wrapping_add(black_box(i));
                }
                black_box(x)
            },
            || {
                let mut x = 0u64;
                for i in 0..1000 {
                    x = x.wrapping_add(black_box(i));
                }
                black_box(x)
            },
        );

        // If measurable, verify K selection logic
        if batching.unmeasurable.is_none() {
            // K should be at least 1
            assert!(batching.k >= 1, "K should be at least 1");

            // If batching enabled, K > 1
            if batching.enabled {
                assert!(batching.k > 1, "Batching enabled but K <= 1");
            }

            // K should never exceed MAX_BATCH_SIZE
            assert!(
                batching.k <= MAX_BATCH_SIZE,
                "K {} exceeds MAX_BATCH_SIZE {}",
                batching.k,
                MAX_BATCH_SIZE
            );

            // ticks_per_batch should be reasonable
            assert!(batching.ticks_per_batch > 0.0);
        }
    }

    #[test]
    fn test_max_batch_size_cap() {
        // Create a collector with very low max_batch_size
        let timer = Timer::new();
        let collector = Collector::with_max_batch_size(timer, 10, 5);

        let (_, batching) =
            collector.collect_with_info(100, || black_box(42u8), || black_box(42u8));

        // K should never exceed the configured max_batch_size
        assert!(
            batching.k <= 5,
            "K {} exceeded configured max_batch_size 5",
            batching.k
        );
    }

    #[test]
    fn test_batching_disabled_for_slow_ops() {
        // Test that slow operations don't get batched
        let collector = Collector::new(10);

        let (_, batching) = collector.collect_with_info(
            50,
            || {
                // Slow operation: enough work to exceed TARGET_TICKS_PER_BATCH
                let mut x = 0u64;
                for i in 0..100_000 {
                    x = x.wrapping_add(black_box(i));
                }
                std::hint::black_box(x)
            },
            || {
                let mut x = 0u64;
                for i in 0..100_000 {
                    x = x.wrapping_add(black_box(i));
                }
                std::hint::black_box(x)
            },
        );

        // A slow enough operation should not need batching
        if batching.unmeasurable.is_none() && batching.ticks_per_batch >= TARGET_TICKS_PER_BATCH {
            assert!(
                !batching.enabled || batching.k == 1,
                "Slow op should not need batching: {:?}",
                batching
            );
        }
    }

    #[test]
    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
    fn test_kperf_suggestion_on_macos_arm64() {
        // On macOS ARM64, unmeasurable operations should suggest kperf
        let collector = Collector::new(10);

        let (_, batching) = collector.collect_with_info(
            100,
            || 42u8, // Trivial op
            || 42u8,
        );

        // If unmeasurable on macOS ARM64, should mention kperf
        if batching.unmeasurable.is_some() {
            assert!(
                batching.rationale.contains("kperf") || batching.rationale.contains("macOS"),
                "macOS ARM64 unmeasurable should mention kperf, got: {}",
                batching.rationale
            );
        }
    }

    #[test]
    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
    fn test_suggestion_on_linux_arm64() {
        // On Linux ARM64, unmeasurable operations should suggest --features perf
        let collector = Collector::new(10);

        let (_, batching) = collector.collect_with_info(
            100,
            || 42u8, // Trivial op
            || 42u8,
        );

        // If unmeasurable on Linux ARM64, should mention perf feature
        if batching.unmeasurable.is_some() {
            assert!(
                batching.rationale.contains("--features perf"),
                "Linux ARM64 unmeasurable should mention --features perf, got: {}",
                batching.rationale
            );
        }
    }

    #[test]
    fn test_batching_info_consistency() {
        // Verify BatchingInfo fields are internally consistent
        let collector = Collector::new(10);

        let (_, batching) = collector.collect_with_info(
            100,
            || {
                let mut x = 0u64;
                for i in 0..500 {
                    x = x.wrapping_add(black_box(i));
                }
                black_box(x)
            },
            || {
                let mut x = 0u64;
                for i in 0..500 {
                    x = x.wrapping_add(black_box(i));
                }
                black_box(x)
            },
        );

        // enabled should match k > 1
        assert_eq!(
            batching.enabled,
            batching.k > 1,
            "enabled={} should match k > 1 (k={})",
            batching.enabled,
            batching.k
        );

        // rationale should not be empty
        assert!(
            !batching.rationale.is_empty(),
            "rationale should not be empty"
        );

        // If unmeasurable, k should be 1 and enabled should be false
        if batching.unmeasurable.is_some() {
            assert_eq!(batching.k, 1, "unmeasurable should have k=1");
            assert!(!batching.enabled, "unmeasurable should not be enabled");
        }
    }
}