Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/mutation-testing.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Mutation Testing

# Minimal permissions for security
permissions:
contents: read

on:
# Run weekly on Sundays at 2 AM UTC
schedule:
- cron: "0 2 * * 0"
# Allow manual triggering
workflow_dispatch:
inputs:
package:
description: "Package to test (select 'all' for all packages)"
required: false
default: "all"
type: choice
options:
- all
- ml-kem
- module-lattice
- dhkem
- frodo-kem
- x-wing

env:
CARGO_INCREMENTAL: 0
CARGO_TERM_COLOR: always

# Only run one mutation test at a time
concurrency:
group: mutation-testing
cancel-in-progress: false

jobs:
mutants:
name: ${{ matrix.package }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package:
- ml-kem
- module-lattice
- dhkem
- frodo-kem
- x-wing
# Only filter if a specific package was requested via workflow_dispatch
# For push/pull_request events, inputs.package is undefined, so default to 'all'
exclude:
- package: ${{ (github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'ml-kem' && 'ml-kem' || 'NONE' }}
- package: ${{ (github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'module-lattice' && 'module-lattice' || 'NONE' }}
- package: ${{ (github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'dhkem' && 'dhkem' || 'NONE' }}
- package: ${{ (github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'frodo-kem' && 'frodo-kem' || 'NONE' }}
- package: ${{ (github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'x-wing' && 'x-wing' || 'NONE' }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
submodules: recursive

- uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable
with:
toolchain: stable

- uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
with:
prefix-key: mutants-${{ matrix.package }}

- name: Install cargo-mutants
run: cargo install cargo-mutants@26.1.2 --locked

- name: Run mutation testing
run: |
cargo mutants --package "${{ matrix.package }}" --all-features -j 2 --timeout 300 2>&1 | tee mutants-output.txt
# Extract summary for job summary
{
echo "## Mutation Testing Results: ${{ matrix.package }}"
echo ""
echo '```'
tail -5 mutants-output.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

- name: Upload mutation results
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: mutants-${{ matrix.package }}
path: |
mutants.out/
mutants-output.txt
retention-days: 30
5 changes: 5 additions & 0 deletions ml-kem/src/algebra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,11 @@ mod test {
assert_eq!((&v1 * &v2), const_ntt(6));
assert_eq!((&v1 * &v3), const_ntt(9));
assert_eq!((&v2 * &v3), const_ntt(18));

// Verify inequality (catches PartialEq mutation that returns true unconditionally)
assert_ne!(v1, v2);
assert_ne!(v1, v3);
assert_ne!(v2, v3);
}

#[test]
Expand Down
25 changes: 25 additions & 0 deletions ml-kem/src/kem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,4 +440,29 @@ mod test {
seed_test::<MlKem768Params>();
seed_test::<MlKem1024Params>();
}

fn key_inequality_test<P>()
where
P: KemParams,
{
let mut rng = UnwrapErr(SysRng);

// Generate two different keys
let dk1 = DecapsulationKey::<P>::generate_from_rng(&mut rng);
let dk2 = DecapsulationKey::<P>::generate_from_rng(&mut rng);

let ek1 = dk1.encapsulation_key();
let ek2 = dk2.encapsulation_key();

// Verify inequality (catches PartialEq mutation that returns true unconditionally)
assert_ne!(dk1, dk2);
assert_ne!(ek1, ek2);
}

#[test]
fn key_inequality() {
key_inequality_test::<MlKem512Params>();
key_inequality_test::<MlKem768Params>();
key_inequality_test::<MlKem1024Params>();
}
}
22 changes: 22 additions & 0 deletions ml-kem/src/pke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,4 +264,26 @@ mod test {
let invalid_key = [0xFF; 1184];
assert!(EncryptionKey::<MlKem768Params>::from_bytes(&invalid_key.into()).is_err());
}

fn key_inequality_test<P>()
where
P: PkeParams,
{
let mut rng = UnwrapErr(SysRng);
let d1 = B32::generate_from_rng(&mut rng);
let d2 = B32::generate_from_rng(&mut rng);

let (dk1, _) = DecryptionKey::<P>::generate(&d1);
let (dk2, _) = DecryptionKey::<P>::generate(&d2);

// Verify inequality (catches PartialEq mutation that returns true unconditionally)
assert_ne!(dk1, dk2);
}

#[test]
fn key_inequality() {
key_inequality_test::<MlKem512Params>();
key_inequality_test::<MlKem768Params>();
key_inequality_test::<MlKem1024Params>();
}
}
Loading