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