# croniter-rs Differential Fuzzing Report

## TL;DR

We built a Rust reimplementation of Python [croniter](https://github.com/pallets-eco/croniter) and validated it through **2.3 million differential fuzz tests**, **180 structured compatibility tests**, and **5,200 real-world GitHub Actions cron expressions**. The result: **zero correctness bugs** in our Rust implementation. All 630 test discrepancies (0.027%) trace back to a single, previously unreported bug in Python croniter's `get_prev`, where a hardcoded `DAYS = (31, 28, ...)` tuple causes it to silently skip February 29 in leap years.

---

## 1. Methodology

### 1.1 Differential Testing

We compare Python croniter (v6.0.0) against croniter-rs on identical inputs and flag any divergence. For each cron expression and start time, we compare:

- **`get_next`** × 11 iterations
- **`get_prev`** × 11 iterations
- **`match()`** result
- **`expand()`** output

A test passes only when both implementations produce byte-identical results, or both reject the expression with the same error type.

### 1.2 Test Generation Strategy

| Layer | Method | Volume | Purpose |
|-------|--------|--------|---------|
| Structured | Equivalence class partitioning + boundary value analysis | 180 cases | Systematic coverage of all field types, operators, and edge cases |
| Fuzz | Randomized expression + start time generation | 2,300,000 cases | Broad exploration of the input space |
| Real-world | GitHub Actions cron expressions | 5,200 cases | Validate against production usage patterns |

**Fuzz generation** covers:
- All 5/6/7-field formats
- Wildcards, ranges (`1-15`), steps (`*/5`), lists (`1,3,5`), and combinations
- Special operators: `L` (last day), `#` (nth weekday), `H` (hash), `R` (random), `?`
- Month/weekday names (`JAN`, `MON`)
- `day_or` = true/false (OR vs AND logic for day + weekday)
- Start times spanning 2020–2028, all months, edge days (1, 28, 29, 30, 31)

### 1.3 Infrastructure

- 4 parallel fuzzing workers, ~2 hours wall time
- Checkpointed every 10 batches for crash recovery
- Memory-safe: ~1.5 GB/worker (previously OOM-killed at 65 workers / 30 GB)

---

## 2. Results

### 2.1 Summary

| Test Suite | Total | Passed | Failed | Pass Rate |
|-----------|-------|--------|--------|-----------|
| Structured compatibility | 180 | 180 | 0 | **100%** |
| Differential fuzz | 2,299,370 | 2,298,740 | 630 | **99.97%** |
| Real-world (GitHub Actions) | 5,200 | 5,146 | 0 | **100%**¹ |

¹ 54 expressions errored on both sides (impossible dates like `0 0 31 2 *`). Zero mismatches.

### 2.2 Fuzz Failure Breakdown

All 630 failures (166 unique expressions) share the same root cause:

| Property | Value |
|----------|-------|
| Direction | `get_prev` only |
| Day field | Contains 29, or a range spanning 29 (e.g. `29-31`) |
| Start year | Leap year (2024, 2028) |
| Start month | After February |
| `get_next` | ✓ Identical on same expressions |
| `match()` | ✓ Identical on same expressions |

---

## 3. Python croniter Bug: `get_prev` Skips Feb 29 in Leap Years

### 3.1 Status

- **Severity:** Silent wrong result (no exception raised)
- **Affected versions:** Confirmed on v6.0.0; likely present since introduction of `_get_prev_nearest_diff`
- **Related issues:** [#61](https://github.com/pallets-eco/croniter/issues/61), [#62](https://github.com/pallets-eco/croniter/issues/62) fixed a different manifestation (exception on `29 2 *`), but this variant remains unfixed
- **Not yet reported upstream** for the silent-skip variant described here

### 3.2 Reproduction

```python
from croniter import croniter
from datetime import datetime

# 2024 is a leap year — Feb 29 exists
c = croniter("0 0 29 * *", datetime(2024, 3, 16))
print(c.get_prev(datetime))
# Actual:   2024-01-29 00:00:00  ← WRONG
# Expected: 2024-02-29 00:00:00
```

### 3.3 Root Cause

`croniter._get_prev_nearest_diff` uses a hardcoded tuple to determine days-in-previous-month:

```python
DAYS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
#            ^^— February is always 28, ignoring leap years
```

When computing the backward step from March into February, the algorithm sees February as having 28 days. Since `day=29 > 28`, it concludes February cannot contain day 29 and skips the entire month.

The forward direction (`_get_next_nearest_diff`) has a different code path that was separately fixed in [#178](https://github.com/pallets-eco/croniter/issues/178), but the backward direction was not.

### 3.4 Trigger Conditions

All four must hold simultaneously:

1. **Leap year** — the target February actually has 29 days
2. **Day field includes 29+** — e.g. `29`, `29-31`, `28,29`
3. **`get_prev` direction** — backward search only
4. **Start date requires cross-month calculation** — the algorithm must compute a diff that crosses February

### 3.5 Inconsistent Behavior by Start Date

Expression `0 0 29 * *`, leap year 2024:

| Start | Python `get_prev` | Correct | Verdict |
|-------|-------------------|---------|---------|
| Mar 1 00:00 | Feb 29 | Feb 29 | ✓ `day -= 1` path, no diff calc |
| Mar 15 00:00 | **Jan 29** | Feb 29 | ✗ diff calc skips Feb |
| Mar 29 00:00 | Feb 29 | Feb 29 | ✓ direct day match |
| Apr 1 00:00 | **Mar 29** | Mar 29 | ✓ but Feb 29 lost from sequence |

### 3.6 Impact

- Affects any cron schedule with day ≥ 29 when iterating backward across a leap-year February
- The bug is **silent** — no exception, just a wrong datetime
- Downstream impact: missed schedule entries, incorrect "last run" calculations, audit log gaps

---

## 4. Feature Coverage

The following croniter features are exercised by the test suite:

| Feature | Structured | Fuzz | Real-world |
|---------|:----------:|:----:|:----------:|
| 5-field (standard cron) | ✓ | ✓ | ✓ |
| 6-field (with seconds) | ✓ | ✓ | — |
| 7-field (with year) | ✓ | ✓ | — |
| Wildcards, ranges, steps, lists | ✓ | ✓ | ✓ |
| `L` (last day of month) | ✓ | ✓ | ✓ |
| `#` (nth weekday) | ✓ | ✓ | — |
| `H` (hash-based) | ✓ | ✓ | — |
| `?` (any) | ✓ | — | — |
| Month/weekday names | ✓ | ✓ | ✓ |
| `day_or` (OR/AND logic) | ✓ | ✓ | — |
| `implement_cron_bug` flag | ✓ | — | — |
| Leap year boundaries | ✓ | ✓ | — |
| Error type compatibility | ✓ | ✓ | ✓ |

---

## 5. Code Coverage

| Module | Line Coverage | Notes |
|--------|:------------:|-------|
| `parser.rs` | **95.36%** (657/689) | Remaining: unreachable defensive branches |
| `schedule.rs` | **90.01%** (775/861) | Remaining: empty-list guards, `?`-operator None paths, proptest helpers |

All uncovered lines are defensive code that cannot be reached through valid cron expressions.

---

## 6. Conclusion

croniter-rs is a **correct, compatible, and thoroughly validated** Rust reimplementation of Python croniter. Across 2.3M+ test cases, every discrepancy traces to a single upstream Python bug — not to any deficiency in the Rust implementation.
