Skip to main content

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}