ic_vectors/lib.rs
1//! Loading test vectors that are not in the repository.
2//!
3//! An algorithm is registered `experimental` rather than `available` when it is
4//! implemented and nothing has checked it against values produced by something
5//! other than itself. That is not a code problem. It is a *files* problem, and
6//! this crate exists so that closing it needs no code at all: drop a vector
7//! file in the right place and the tests start checking against it.
8//!
9//! It has worked for every algorithm that needed it. ML-KEM-768 and ML-DSA-65
10//! were closed by NIST's ACVP files and AES-GCM-SIV by RFC 8452 appendix C, and
11//! nothing in the registry is `experimental` now. What remains is the
12//! mechanism, for whatever is added next. Without one they skip,
13//! loudly enough to be visible and quietly enough not to fail a build that was
14//! never promised the file.
15//!
16//! # Where the files go
17//!
18//! `testvectors/<name>.json`, relative to the workspace root, or wherever
19//! `IC_TEST_VECTORS` points. The format is deliberately not raw ACVP:
20//!
21//! ```json
22//! {
23//! "algorithm": "aes-kw",
24//! "source": "RFC 3394 section 4.1",
25//! "cases": [
26//! { "key": "000102...", "pt": "001122...", "ct": "1fa68b..." }
27//! ]
28//! }
29//! ```
30//!
31//! Every field is a hex string, and which fields a case needs is up to the test
32//! reading it. ACVP's own files nest differently per algorithm and carry a
33//! great deal that is irrelevant here, so converting is a few lines of `jq`
34//! rather than a parser this crate has to keep up with. `testvectors/README.md`
35//! records the mapping for each algorithm that wants one.
36//!
37//! # Why the skip is loud
38//!
39//! A test that silently passes when its input is missing is worse than no test:
40//! it reports success for work nobody did. [`VectorFile::load`] returns `None`
41//! and the callers print what they were looking for, so an absent file shows up
42//! in the output rather than in nothing at all.
43
44#![forbid(unsafe_code)]
45#![deny(missing_docs)]
46#![warn(clippy::all)]
47
48use ic_json::Json;
49use std::collections::BTreeMap;
50use std::path::PathBuf;
51
52/// A loaded vector file.
53pub struct VectorFile {
54 /// What the file says it is for.
55 pub algorithm: String,
56 /// Where the vectors came from, for the record.
57 pub source: String,
58 /// The cases, each a map of field name to raw hex string.
59 pub cases: Vec<BTreeMap<String, String>>,
60 /// Where the file was found.
61 pub path: PathBuf,
62}
63
64/// The directory vector files are read from.
65///
66/// `IC_TEST_VECTORS` if set, otherwise `testvectors/` beside the workspace
67/// manifest. Tests run with the crate directory as the working directory, so
68/// the fallback walks up until it finds the workspace root.
69pub fn vectors_dir() -> PathBuf {
70 if let Ok(dir) = std::env::var("IC_TEST_VECTORS") {
71 return PathBuf::from(dir);
72 }
73 // CARGO_MANIFEST_DIR is the crate being tested; the workspace root is one
74 // or two levels up depending on layout, so look for the marker.
75 let mut here = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default());
76 for _ in 0..4 {
77 let candidate = here.join("testvectors");
78 if candidate.is_dir() {
79 return candidate;
80 }
81 if !here.pop() {
82 break;
83 }
84 }
85 PathBuf::from("testvectors")
86}
87
88impl VectorFile {
89 /// Load `testvectors/<name>.json`, or `None` if it is not there.
90 ///
91 /// A malformed file is an error rather than a miss: someone went to the
92 /// trouble of providing it, and silently ignoring it would waste their
93 /// effort in the most confusing way available.
94 pub fn load(name: &str) -> Option<VectorFile> {
95 Self::load_from(&vectors_dir(), name)
96 }
97
98 /// [`load`](Self::load), reading from a directory the caller names.
99 ///
100 /// Exists so the tests can point at a directory of their own rather than
101 /// setting `IC_TEST_VECTORS` -- cargo runs a crate's tests as threads in
102 /// one process, so mutating the environment races every sibling that reads
103 /// it.
104 pub fn load_from(dir: &std::path::Path, name: &str) -> Option<VectorFile> {
105 let path = dir.join(format!("{name}.json"));
106 let text = std::fs::read_to_string(&path).ok()?;
107 let parsed = ic_json::parse(&text)
108 .unwrap_or_else(|e| panic!("{} is present but not valid JSON: {e}", path.display()));
109
110 // Both are required of a file that exists. `source` used to fall back to
111 // the string "unrecorded", so a file with no provenance loaded and its
112 // values went on to validate an implementation, with the report
113 // printing "unrecorded" where the citation belongs.
114 //
115 // The point of this crate is that an algorithm stops being
116 // `experimental` when someone drops a file in here. `experimental`
117 // means nothing has checked it against values produced by something
118 // other than itself, and a file of unknown origin does not close that
119 // -- it hides it. A missing citation belongs with the other ways a
120 // present file can be malformed, all of which already panic.
121 let algorithm = parsed
122 .get("algorithm")
123 .and_then(|v| v.as_str())
124 .filter(|s| !s.trim().is_empty())
125 .unwrap_or_else(|| panic!("{} does not say which algorithm it is for", path.display()))
126 .to_string();
127 let source = parsed
128 .get("source")
129 .and_then(|v| v.as_str())
130 .filter(|s| !s.trim().is_empty())
131 .unwrap_or_else(|| {
132 panic!(
133 "{} does not cite where its values came from. A vector whose \
134 provenance is unknown cannot establish that an implementation \
135 interoperates, which is the only reason to have one.",
136 path.display()
137 )
138 })
139 .to_string();
140
141 let raw = match parsed.get("cases") {
142 Some(Json::Array(items)) => items.clone(),
143 _ => panic!("{} has no \"cases\" array", path.display()),
144 };
145
146 let mut cases = Vec::new();
147 for (index, item) in raw.iter().enumerate() {
148 let Json::Object(fields) = item else {
149 panic!("{}: case {index} is not an object", path.display());
150 };
151 let mut case = BTreeMap::new();
152 for (key, value) in fields {
153 if let Some(text) = value.as_str() {
154 case.insert(key.clone(), text.to_string());
155 }
156 }
157 cases.push(case);
158 }
159
160 Some(VectorFile {
161 algorithm,
162 source,
163 cases,
164 path,
165 })
166 }
167
168 /// Load, or print why nothing was checked and return `None`.
169 ///
170 /// The message is the point. A skipped vector test should be visible in the
171 /// output of `cargo test -- --nocapture`, not inferred from its absence.
172 pub fn load_or_report(name: &str) -> Option<VectorFile> {
173 match Self::load(name) {
174 Some(file) => {
175 println!(
176 "vectors: {} cases for {} from {} ({})",
177 file.cases.len(),
178 file.algorithm,
179 file.source,
180 file.path.display()
181 );
182 Some(file)
183 }
184 None => {
185 println!(
186 "vectors: SKIPPED {name} -- no file at {}. \
187 See testvectors/README.md.",
188 vectors_dir().join(format!("{name}.json")).display()
189 );
190 None
191 }
192 }
193 }
194}
195
196/// Decode a hex field from a case, panicking with the field name on failure.
197///
198/// Tests are the caller, so a bad field is a broken input file and should stop
199/// the run with something readable rather than return an error nobody handles.
200pub fn hex_field(case: &BTreeMap<String, String>, name: &str) -> Vec<u8> {
201 let text = case
202 .get(name)
203 .unwrap_or_else(|| panic!("a case is missing the field {name:?}"));
204 unhex(text).unwrap_or_else(|| panic!("field {name:?} is not valid hex: {text:?}"))
205}
206
207/// An optional hex field.
208pub fn optional_hex_field(case: &BTreeMap<String, String>, name: &str) -> Option<Vec<u8>> {
209 case.get(name).map(|text| {
210 unhex(text).unwrap_or_else(|| panic!("field {name:?} is not valid hex: {text:?}"))
211 })
212}
213
214/// Decode a hex string, tolerating whitespace and either case.
215fn unhex(text: &str) -> Option<Vec<u8>> {
216 let cleaned: Vec<u8> = text.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
217 if cleaned.len() % 2 != 0 {
218 return None;
219 }
220 let mut out = Vec::with_capacity(cleaned.len() / 2);
221 for pair in cleaned.chunks(2) {
222 let hi = (pair[0] as char).to_digit(16)?;
223 let lo = (pair[1] as char).to_digit(16)?;
224 out.push((hi * 16 + lo) as u8);
225 }
226 Some(out)
227}
228
229/// Render bytes as lowercase hex, for comparison messages.
230pub fn hex(bytes: &[u8]) -> String {
231 bytes.iter().map(|b| format!("{b:02x}")).collect()
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn hex_decoding_is_forgiving_about_layout_and_strict_about_content() {
240 assert_eq!(unhex("00ff").unwrap(), vec![0x00, 0xff]);
241 assert_eq!(unhex("00FF").unwrap(), vec![0x00, 0xff]);
242 assert_eq!(unhex("00 ff\n").unwrap(), vec![0x00, 0xff]);
243 assert_eq!(unhex("").unwrap(), Vec::<u8>::new());
244 assert!(unhex("0").is_none(), "odd length");
245 assert!(unhex("zz").is_none(), "not hex");
246 }
247
248 #[test]
249 fn hex_round_trips() {
250 let bytes = [0x00u8, 0x0f, 0xa5, 0xff];
251 assert_eq!(unhex(&hex(&bytes)).unwrap(), bytes);
252 }
253
254 /// A missing file is a miss, not a failure. This is the behaviour the whole
255 /// design rests on, so it is asserted rather than assumed.
256 /// A file that is present must cite its source, and one that does not must
257 /// fail loudly rather than load.
258 ///
259 /// `source` used to fall back to the string "unrecorded", so a file with no
260 /// provenance loaded and its values went on to validate an implementation.
261 /// That matters here more than it would elsewhere: this crate is how an
262 /// algorithm stops being `experimental` -- as ML-KEM-768, ML-DSA-65 and
263 /// AES-GCM-SIV did -- and `experimental` means exactly that nothing has
264 /// checked it against values produced by something other than itself. A file of unknown
265 /// origin does not close that gap, it conceals it.
266 ///
267 /// Absent stays `None`: skipping loudly is the documented behaviour and is
268 /// a different situation from a file that is there and incomplete.
269 #[test]
270 fn a_vector_file_without_provenance_is_refused() {
271 // Under the workspace's own target directory, not the system temp
272 // directory. cargo already requires target/ to be writable, and
273 // .gitignore already covers it.
274 //
275 // This test has been flaky three times, each from depending on
276 // something outside itself: a process-wide environment variable that
277 // raced its sibling tests, a name built from the process id that
278 // collided with leftovers after Windows recycled it, and a subdirectory
279 // of %LOCALAPPDATA%\Temp that the user could create and then not write
280 // to, because that directory grants the user no inheritable rights on
281 // this machine. Nothing ambient is left.
282 let unique = std::time::SystemTime::now()
283 .duration_since(std::time::UNIX_EPOCH)
284 .map(|d| d.as_nanos())
285 .unwrap_or(0);
286 // `vectors_dir()` already locates the workspace root by looking for
287 // the `testvectors` marker, so its parent is the root.
288 let root = vectors_dir()
289 .parent()
290 .map(PathBuf::from)
291 .unwrap_or_else(|| PathBuf::from("."));
292 let dir = root
293 .join("target")
294 .join("ic-vectors-tests")
295 .join(format!("{}-{unique}", std::process::id()));
296
297 let _ = std::fs::remove_dir_all(&dir);
298 std::fs::create_dir_all(&dir)
299 .unwrap_or_else(|e| panic!("could not create {}: {e}", dir.display()));
300
301 let write = |name: &str, body: &str| {
302 let path = dir.join(format!("{name}.json"));
303 std::fs::write(&path, body)
304 .unwrap_or_else(|e| panic!("could not write {}: {e}", path.display()));
305 };
306
307 // Present and properly cited: loads, and carries the citation through.
308 write(
309 "cited",
310 r#"{"algorithm":"aes-kw","source":"RFC 3394 section 4.1",
311 "cases":[{"key":"00","pt":"11","ct":"22"}]}"#,
312 );
313 let file = VectorFile::load_from(&dir, "cited").expect("a cited file must load");
314 assert_eq!(file.source, "RFC 3394 section 4.1");
315 assert_eq!(file.algorithm, "aes-kw");
316 assert_eq!(file.cases.len(), 1);
317
318 // Absent: none, and no panic. This is the skip path.
319 assert!(VectorFile::load_from(&dir, "no-such-file").is_none());
320
321 // Present with no source, and present with an empty one. Both are a
322 // file someone meant to be used, without the one thing that makes it
323 // usable.
324 for (name, body) in [
325 (
326 "uncited",
327 r#"{"algorithm":"aes-kw","cases":[{"key":"00"}]}"#,
328 ),
329 (
330 "blank-source",
331 r#"{"algorithm":"aes-kw","source":" ","cases":[{"key":"00"}]}"#,
332 ),
333 ("unnamed", r#"{"source":"RFC 3394","cases":[{"key":"00"}]}"#),
334 ] {
335 write(name, body);
336 let outcome = std::panic::catch_unwind(|| VectorFile::load_from(&dir, name));
337 assert!(
338 outcome.is_err(),
339 "{name} loaded despite not saying where its values came from"
340 );
341 }
342
343 let _ = std::fs::remove_dir_all(&dir);
344 }
345
346 #[test]
347 fn an_absent_file_is_none() {
348 assert!(VectorFile::load("a-name-no-file-will-ever-have").is_none());
349 }
350
351 /// The bundled RFC 3394 file must load, which is what proves the plumbing
352 /// works rather than merely compiling.
353 #[test]
354 fn the_bundled_key_wrap_vectors_load() {
355 let file =
356 VectorFile::load("aes-kw").expect("testvectors/aes-kw.json is in the repository");
357 assert_eq!(file.algorithm, "aes-kw");
358 assert!(
359 file.source.contains("3394"),
360 "the source should be recorded"
361 );
362 assert_eq!(file.cases.len(), 6, "RFC 3394 publishes six");
363 for case in &file.cases {
364 assert!(!hex_field(case, "key").is_empty());
365 assert!(!hex_field(case, "pt").is_empty());
366 assert!(!hex_field(case, "ct").is_empty());
367 }
368 }
369}