Skip to main content

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}