Skip to content

Commit fa0aa80

Browse files
authored
feat: add Gronsfeld cipher implementation (#1045)
1 parent 7789289 commit fa0aa80

2 files changed

Lines changed: 196 additions & 0 deletions

File tree

src/ciphers/gronsfeld.rs

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
//! Gronsfeld Cipher
2+
//!
3+
//! # Algorithm
4+
//!
5+
//! A variant of the Vigenère cipher where the key is a sequence of digits (0–9).
6+
//! Each ASCII alphabetic character in the plaintext is shifted forward (encrypt) or
7+
//! backward (decrypt) by the value of the corresponding key digit, cycling
8+
//! through the key. Non-alphabetic characters are passed through unchanged.
9+
10+
const ALPHABET_LEN: u8 = 26;
11+
12+
const ERR_EMPTY_KEY: &str = "Key must not be empty";
13+
const ERR_INVALID_KEY: &str = "Key must contain only digits (0-9)";
14+
15+
fn validate_key(key: &str) -> Result<Vec<u8>, &'static str> {
16+
if key.is_empty() {
17+
return Err(ERR_EMPTY_KEY);
18+
}
19+
20+
key.bytes()
21+
.map(|b| match b {
22+
b'0'..=b'9' => Ok(b - b'0'),
23+
_ => Err(ERR_INVALID_KEY),
24+
})
25+
.collect()
26+
}
27+
28+
fn shift_char(c: char, shift: u8, forward: bool) -> char {
29+
let base = if c.is_ascii_lowercase() { b'a' } else { b'A' };
30+
let pos = c as u8 - base;
31+
let shifted = if forward {
32+
(pos + shift) % ALPHABET_LEN
33+
} else {
34+
(pos + ALPHABET_LEN - shift % ALPHABET_LEN) % ALPHABET_LEN
35+
};
36+
(base + shifted) as char
37+
}
38+
39+
fn process(text: &str, key: &[u8], forward: bool) -> String {
40+
let key_len = key.len();
41+
let mut key_index = 0;
42+
text.chars()
43+
.map(|c| {
44+
if c.is_ascii_alphabetic() {
45+
let result = shift_char(c, key[key_index % key_len], forward);
46+
key_index += 1;
47+
result
48+
} else {
49+
c
50+
}
51+
})
52+
.collect()
53+
}
54+
55+
/// Encrypts `text` using the Gronsfeld cipher with the given digit `key`.
56+
pub fn gronsfeld_encrypt(text: &str, key: &str) -> Result<String, &'static str> {
57+
let digits = validate_key(key)?;
58+
Ok(process(text, &digits, true))
59+
}
60+
61+
/// Decrypts `text` using the Gronsfeld cipher with the given digit `key`.
62+
pub fn gronsfeld_decrypt(text: &str, key: &str) -> Result<String, &'static str> {
63+
let digits = validate_key(key)?;
64+
Ok(process(text, &digits, false))
65+
}
66+
67+
#[cfg(test)]
68+
mod tests {
69+
use super::*;
70+
71+
// --- validate_key ---
72+
73+
#[test]
74+
fn empty_key_returns_error() {
75+
assert_eq!(gronsfeld_encrypt("hello", ""), Err(ERR_EMPTY_KEY));
76+
assert_eq!(gronsfeld_decrypt("hello", ""), Err(ERR_EMPTY_KEY));
77+
}
78+
79+
#[test]
80+
fn non_digit_key_returns_error() {
81+
assert_eq!(gronsfeld_encrypt("hello", "12a3"), Err(ERR_INVALID_KEY));
82+
assert_eq!(gronsfeld_decrypt("hello", "abc"), Err(ERR_INVALID_KEY));
83+
}
84+
85+
// --- encrypt ---
86+
87+
#[test]
88+
fn encrypt_empty_text() {
89+
assert_eq!(gronsfeld_encrypt("", "123").unwrap(), "");
90+
}
91+
92+
#[test]
93+
fn encrypt_basic() {
94+
assert_eq!(gronsfeld_encrypt("abc", "123").unwrap(), "bdf");
95+
}
96+
97+
#[test]
98+
fn encrypt_preserves_case() {
99+
assert_eq!(gronsfeld_encrypt("ABC", "123").unwrap(), "BDF");
100+
}
101+
102+
#[test]
103+
fn encrypt_mixed_case() {
104+
assert_eq!(gronsfeld_encrypt("aAbB", "12").unwrap(), "bCcD");
105+
}
106+
107+
#[test]
108+
fn encrypt_passthrough_non_alpha() {
109+
assert_eq!(gronsfeld_encrypt("a b,c!", "123").unwrap(), "b d,f!");
110+
}
111+
112+
#[test]
113+
fn encrypt_key_wraps() {
114+
assert_eq!(gronsfeld_encrypt("abcd", "12").unwrap(), "bddf");
115+
}
116+
117+
#[test]
118+
fn encrypt_zero_shift() {
119+
assert_eq!(gronsfeld_encrypt("hello", "0").unwrap(), "hello");
120+
}
121+
122+
#[test]
123+
fn encrypt_wraps_around_alphabet() {
124+
assert_eq!(gronsfeld_encrypt("z", "1").unwrap(), "a");
125+
assert_eq!(gronsfeld_encrypt("Z", "9").unwrap(), "I");
126+
}
127+
128+
#[test]
129+
fn encrypt_single_digit_key() {
130+
assert_eq!(
131+
gronsfeld_encrypt("Hello, World!", "5").unwrap(),
132+
"Mjqqt, Btwqi!"
133+
);
134+
}
135+
136+
// --- decrypt ---
137+
138+
#[test]
139+
fn decrypt_empty_text() {
140+
assert_eq!(gronsfeld_decrypt("", "123").unwrap(), "");
141+
}
142+
143+
#[test]
144+
fn decrypt_basic() {
145+
assert_eq!(gronsfeld_decrypt("bdf", "123").unwrap(), "abc");
146+
}
147+
148+
#[test]
149+
fn decrypt_preserves_case() {
150+
assert_eq!(gronsfeld_decrypt("BDF", "123").unwrap(), "ABC");
151+
}
152+
153+
#[test]
154+
fn decrypt_passthrough_non_alpha() {
155+
assert_eq!(gronsfeld_decrypt("b d,f!", "123").unwrap(), "a b,c!");
156+
}
157+
158+
#[test]
159+
fn decrypt_zero_shift() {
160+
assert_eq!(gronsfeld_decrypt("hello", "0").unwrap(), "hello");
161+
}
162+
163+
#[test]
164+
fn decrypt_wraps_around_alphabet() {
165+
// 'a' - 1 = 'z'
166+
assert_eq!(gronsfeld_decrypt("a", "1").unwrap(), "z");
167+
}
168+
169+
// --- round-trip ---
170+
171+
#[test]
172+
fn roundtrip_basic() {
173+
let plain = "Hello, World!";
174+
let key = "31415";
175+
let encrypted = gronsfeld_encrypt(plain, key).unwrap();
176+
assert_eq!(gronsfeld_decrypt(&encrypted, key).unwrap(), plain);
177+
}
178+
179+
#[test]
180+
fn roundtrip_long_text() {
181+
let plain = "The quick brown fox jumps over the lazy dog.";
182+
let key = "9876543210";
183+
let encrypted = gronsfeld_encrypt(plain, key).unwrap();
184+
assert_eq!(gronsfeld_decrypt(&encrypted, key).unwrap(), plain);
185+
}
186+
187+
#[test]
188+
fn roundtrip_with_unicode_passthrough() {
189+
let plain = "Rust ⏳ 2024";
190+
let key = "42";
191+
let encrypted = gronsfeld_encrypt(plain, key).unwrap();
192+
assert_eq!(gronsfeld_decrypt(&encrypted, key).unwrap(), plain);
193+
}
194+
}

src/ciphers/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod base85;
99
mod caesar;
1010
mod chacha;
1111
mod diffie_hellman;
12+
mod gronsfeld;
1213
mod hill_cipher;
1314
mod kernighan;
1415
mod morse_code;
@@ -36,6 +37,7 @@ pub use self::base85::{base85_decode, base85_encode};
3637
pub use self::caesar::caesar;
3738
pub use self::chacha::chacha20;
3839
pub use self::diffie_hellman::DiffieHellman;
40+
pub use self::gronsfeld::{gronsfeld_decrypt, gronsfeld_encrypt};
3941
pub use self::hill_cipher::HillCipher;
4042
pub use self::kernighan::kernighan;
4143
pub use self::morse_code::{decode, encode};

0 commit comments

Comments
 (0)