geronimo_captcha/
samples.rs1use crate::challenge::GenerationOptions;
2use crate::error::{CaptchaError, Result};
3use crate::sprite::decode_base;
4
5use image::{ImageFormat, RgbaImage};
6use tracing::warn;
7
8const MAX_SAMPLES: usize = 4096;
9const MAX_CORPUS_BYTES: usize = 512 * 1024 * 1024;
10const RECOMMENDED_MIN_SAMPLES: usize = 32;
11
12const DEMO_SAMPLES: &[&[u8]] = &[
13 include_bytes!("../assets/sample1.jpg"),
14 include_bytes!("../assets/sample2.jpg"),
15 include_bytes!("../assets/sample3.jpg"),
16 include_bytes!("../assets/sample4.jpg"),
17 include_bytes!("../assets/sample5.jpg"),
18 include_bytes!("../assets/sample6.jpg"),
19 include_bytes!("../assets/sample7.jpg"),
20 include_bytes!("../assets/sample8.jpg"),
21];
22
23#[derive(Clone, Copy, Debug)]
27pub enum SampleFormat {
28 Jpeg,
29 Webp,
30}
31
32impl SampleFormat {
33 fn to_image_format(self) -> ImageFormat {
34 match self {
35 SampleFormat::Jpeg => ImageFormat::Jpeg,
36 SampleFormat::Webp => ImageFormat::WebP,
37 }
38 }
39}
40
41pub struct SampleSet {
52 images: Vec<Vec<u8>>,
53 format: SampleFormat,
54}
55
56impl SampleSet {
57 pub fn new(format: SampleFormat, images: Vec<Vec<u8>>) -> Result<Self> {
63 if images.is_empty() {
64 return Err(CaptchaError::InvalidInput(
65 "sample set must contain at least one image".into(),
66 ));
67 }
68
69 if images.len() > MAX_SAMPLES {
70 return Err(CaptchaError::InvalidInput(format!(
71 "sample set holds {} images, maximum is {MAX_SAMPLES}",
72 images.len()
73 )));
74 }
75
76 if let Some(i) = images.iter().position(Vec::is_empty) {
77 return Err(CaptchaError::InvalidInput(format!(
78 "sample image at index {i} is empty"
79 )));
80 }
81
82 if images.len() < RECOMMENDED_MIN_SAMPLES {
83 warn!(
84 samples = images.len(),
85 recommended = RECOMMENDED_MIN_SAMPLES,
86 "small sample corpus, a harvested corpus defeats the challenge"
87 );
88 }
89
90 Ok(Self { images, format })
91 }
92
93 pub fn demo_insecure() -> Self {
99 warn!("using the published demo sample set, challenges are trivially solvable");
100
101 Self {
102 images: DEMO_SAMPLES.iter().map(|b| b.to_vec()).collect(),
103 format: SampleFormat::Jpeg,
104 }
105 }
106
107 pub(crate) fn decode_all(&self, opts: &GenerationOptions) -> Result<Vec<RgbaImage>> {
108 let per_image = (opts.cell_size as usize)
109 .checked_mul(opts.cell_size as usize)
110 .and_then(|px| px.checked_mul(4))
111 .ok_or_else(|| CaptchaError::InvalidInput("cell_size overflows".into()))?;
112
113 let total = per_image
114 .checked_mul(self.images.len())
115 .ok_or_else(|| CaptchaError::InvalidInput("sample corpus overflows".into()))?;
116
117 if total > MAX_CORPUS_BYTES {
118 return Err(CaptchaError::InvalidInput(format!(
119 "decoded corpus needs {total} bytes, maximum is {MAX_CORPUS_BYTES}"
120 )));
121 }
122
123 let format = self.format.to_image_format();
124
125 self.images
126 .iter()
127 .map(|buf| decode_base(buf, format, opts))
128 .collect()
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::{DecodeLimits, SpriteFormat};
136
137 fn opts(cell_size: u32) -> GenerationOptions {
138 GenerationOptions {
139 cell_size,
140 sprite_format: SpriteFormat::default(),
141 rounds: 1,
142 limits: DecodeLimits::default(),
143 }
144 }
145
146 #[test]
147 fn new_rejects_degenerate_corpora() {
148 assert!(SampleSet::new(SampleFormat::Jpeg, Vec::new()).is_err());
149
150 let too_many = vec![vec![1u8]; MAX_SAMPLES + 1];
151 assert!(SampleSet::new(SampleFormat::Jpeg, too_many).is_err());
152
153 let with_empty = vec![vec![1u8], Vec::new(), vec![1u8]];
154 assert!(SampleSet::new(SampleFormat::Jpeg, with_empty).is_err());
155
156 assert!(SampleSet::new(SampleFormat::Jpeg, vec![vec![1u8]]).is_ok());
157 }
158
159 #[test]
160 fn demo_set_decodes() {
161 let set = SampleSet::demo_insecure();
162
163 let bases = set.decode_all(&opts(64)).expect("decode demo set");
164 assert_eq!(bases.len(), DEMO_SAMPLES.len());
165 assert!(bases.iter().all(|b| b.width() == 64 && b.height() == 64));
166 }
167
168 #[test]
169 fn decode_all_rejects_oversized_corpus() {
170 let images = vec![vec![1u8]; 200];
171 let set = SampleSet::new(SampleFormat::Jpeg, images).expect("valid set");
172
173 let err = set.decode_all(&opts(1024)).expect_err("corpus cap");
174 assert!(matches!(err, CaptchaError::InvalidInput(_)));
175 }
176
177 #[test]
178 fn format_is_pinned_not_guessed() {
179 let jpeg = include_bytes!("../assets/sample1.jpg").to_vec();
180 let set = SampleSet::new(SampleFormat::Webp, vec![jpeg]).expect("valid set");
181
182 let err = set
183 .decode_all(&opts(64))
184 .expect_err("jpeg must not decode as webp");
185 assert!(matches!(err, CaptchaError::Decode(_)));
186 }
187
188 #[test]
189 fn webp_corpus_decodes() {
190 let jpeg = SampleSet::demo_insecure()
191 .decode_all(&opts(64))
192 .expect("decode demo")
193 .remove(0);
194
195 let rgb = image::DynamicImage::ImageRgba8(jpeg).into_rgb8();
196 let (bytes, _) = crate::image::encode_image(
197 &rgb,
198 &SpriteFormat::Webp {
199 quality: 80,
200 lossless: false,
201 },
202 )
203 .expect("encode webp");
204
205 let set = SampleSet::new(SampleFormat::Webp, vec![bytes]).expect("valid set");
206 let bases = set.decode_all(&opts(64)).expect("decode webp corpus");
207
208 assert_eq!(bases.len(), 1);
209 assert_eq!(bases[0].width(), 64);
210 }
211}