Skip to main content

ic_ontology/
query.rs

1//! Querying the registry.
2//!
3//! [`Query`] is a declarative filter an agent can build from a task description
4//! without knowing any algorithm names. It is a plain iterator adapter, so it
5//! allocates nothing and works in `no_std`.
6//!
7//! ```
8//! use ic_ontology::{Query, Purpose};
9//!
10//! // "I need authenticated encryption that is FIPS-approved and actually
11//! //  available in this build, at 256-bit strength."
12//! let hits: Vec<_> = Query::new()
13//!     .purpose(Purpose::Confidentiality)
14//!     .purpose(Purpose::Authentication)
15//!     .fips_approved_only()
16//!     .available_only()
17//!     .min_classical_bits(256)
18//!     .run()
19//!     .collect();
20//!
21//! assert!(hits.iter().any(|e| e.id == "aes-256-gcm"));
22//! ```
23
24use crate::registry::REGISTRY;
25use crate::types::{Class, Entry, ImplStatus, Purpose};
26
27/// The maximum number of purposes a single query can require.
28const MAX_PURPOSES: usize = 4;
29
30/// A declarative filter over the registry.
31#[derive(Debug, Clone, Copy, Default)]
32pub struct Query {
33    class: Option<Class>,
34    purposes: [Option<Purpose>; MAX_PURPOSES],
35    purpose_count: usize,
36    min_classical: u16,
37    min_quantum: u16,
38    fips_only: bool,
39    available_only: bool,
40    family: Option<&'static str>,
41}
42
43impl Query {
44    /// An empty query, matching every entry.
45    pub const fn new() -> Self {
46        Self {
47            class: None,
48            purposes: [None; MAX_PURPOSES],
49            purpose_count: 0,
50            min_classical: 0,
51            min_quantum: 0,
52            fips_only: false,
53            available_only: false,
54            family: None,
55        }
56    }
57
58    /// Restrict to one kind of cryptographic object.
59    pub const fn class(mut self, class: Class) -> Self {
60        self.class = Some(class);
61        self
62    }
63
64    /// Require the entry to serve this purpose. Repeating the call requires all
65    /// of them, which is how "authenticated encryption" is expressed as
66    /// confidentiality *and* authentication.
67    pub const fn purpose(mut self, purpose: Purpose) -> Self {
68        if self.purpose_count < MAX_PURPOSES {
69            self.purposes[self.purpose_count] = Some(purpose);
70            self.purpose_count += 1;
71        }
72        self
73    }
74
75    /// Require at least this much strength against a classical adversary.
76    pub const fn min_classical_bits(mut self, bits: u16) -> Self {
77        self.min_classical = bits;
78        self
79    }
80
81    /// Require at least this much strength against a quantum adversary.
82    ///
83    /// Setting this above zero excludes every discrete-log and factoring scheme,
84    /// which is exactly the filter a post-quantum migration needs.
85    pub const fn min_quantum_bits(mut self, bits: u16) -> Self {
86        self.min_quantum = bits;
87        self
88    }
89
90    /// Restrict to algorithms usable in the FIPS approved mode of operation.
91    pub const fn fips_approved_only(mut self) -> Self {
92        self.fips_only = true;
93        self
94    }
95
96    /// Restrict to algorithms actually implemented in this build.
97    pub const fn available_only(mut self) -> Self {
98        self.available_only = true;
99        self
100    }
101
102    /// Restrict to one family, e.g. `"SHA-2"`.
103    pub const fn family(mut self, family: &'static str) -> Self {
104        self.family = Some(family);
105        self
106    }
107
108    /// Whether a single entry satisfies the query.
109    pub fn matches(&self, e: &Entry) -> bool {
110        if let Some(c) = self.class {
111            if e.class != c {
112                return false;
113            }
114        }
115        for p in self.purposes.iter().flatten() {
116            if !e.serves(*p) {
117                return false;
118            }
119        }
120        if e.strength.classical < self.min_classical || e.strength.quantum < self.min_quantum {
121            return false;
122        }
123        if self.fips_only && !e.fips.permitted_in_approved_mode() {
124            return false;
125        }
126        if self.available_only && e.status != ImplStatus::Available {
127            return false;
128        }
129        if let Some(f) = self.family {
130            if e.family != f {
131                return false;
132            }
133        }
134        true
135    }
136
137    /// Run the query, yielding matching entries in registry order.
138    pub fn run(self) -> impl Iterator<Item = &'static Entry> {
139        REGISTRY.iter().filter(move |e| self.matches(e))
140    }
141
142    /// The number of entries this query matches.
143    pub fn count(self) -> usize {
144        self.run().count()
145    }
146}
147
148/// Look up an entry by identifier, name, or alias, case-insensitively.
149pub fn get(name: &str) -> Option<&'static Entry> {
150    REGISTRY.iter().find(|e| e.matches_name(name))
151}
152
153/// Every entry in the registry.
154pub fn all() -> &'static [Entry] {
155    REGISTRY
156}
157
158/// Entries related to `id` by any edge, in either direction.
159///
160/// Traversing both directions matters: `sha2-256` does not record that HMAC is
161/// built on it, but an agent asking "what depends on SHA-256?" still needs the
162/// answer.
163pub fn related(id: &str) -> impl Iterator<Item = &'static Entry> + '_ {
164    REGISTRY.iter().filter(move |e| {
165        if e.id == id {
166            return false;
167        }
168        let outgoing = get(id)
169            .map(|src| src.edges.iter().any(|edge| edge.target == e.id))
170            .unwrap_or(false);
171        let incoming = e.edges.iter().any(|edge| edge.target == id);
172        outgoing || incoming
173    })
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn lookup_handles_ids_names_and_aliases() {
182        assert_eq!(get("sha2-256").unwrap().id, "sha2-256");
183        assert_eq!(get("SHA-256").unwrap().id, "sha2-256");
184        assert_eq!(get("sha256").unwrap().id, "sha2-256");
185        assert_eq!(get("SHA256").unwrap().id, "sha2-256");
186        assert!(get("not-a-real-algorithm").is_none());
187    }
188
189    #[test]
190    fn authenticated_encryption_query_finds_the_aeads() {
191        let hits: Vec<_> = Query::new()
192            .purpose(Purpose::Confidentiality)
193            .purpose(Purpose::Authentication)
194            .available_only()
195            .run()
196            .map(|e| e.id)
197            .collect();
198        assert!(hits.contains(&"aes-256-gcm"));
199        assert!(hits.contains(&"chacha20-poly1305"));
200        // A raw block cipher offers confidentiality but not authentication.
201        assert!(!hits.contains(&"aes-256"));
202    }
203
204    #[test]
205    fn fips_filter_excludes_unapproved_algorithms() {
206        let hits: Vec<_> = Query::new()
207            .class(Class::Aead)
208            .fips_approved_only()
209            .run()
210            .map(|e| e.id)
211            .collect();
212        assert!(hits.contains(&"aes-256-gcm"));
213        assert!(!hits.contains(&"chacha20-poly1305"));
214    }
215
216    #[test]
217    fn availability_filter_excludes_what_is_not_implemented() {
218        // The KEM class used to be the example here, because ML-KEM-768 was the
219        // only entry in it and was `experimental`. It is vector-tested now, so
220        // the filter keeps it -- and that is the point of the filter, not a
221        // failure of it. ML-KEM-512 and ML-KEM-1024 joined it once their own
222        // ACVP vectors passed. HPKE is in the class too: its core is a KEM, and
223        // the class has no closer member for public-key encryption.
224        let kems: Vec<&str> = Query::new()
225            .class(Class::Kem)
226            .available_only()
227            .run()
228            .map(|e| e.id)
229            .collect();
230        assert_eq!(
231            kems,
232            [
233                "hpke-x25519-sha256",
234                "hpke-p384-sha384",
235                "ml-kem-768",
236                "ml-kem-512",
237                "ml-kem-1024"
238            ]
239        );
240
241        // Excluded algorithms are what the filter must still remove. They are
242        // in the registry so that asking for one gets a reasoned refusal rather
243        // than silence, and they must never be offered as usable.
244        for id in ["md5", "sha-1", "3des"] {
245            assert!(
246                crate::get(id).is_some(),
247                "{id} should still be listed, so a request for it is answered"
248            );
249            assert!(
250                !Query::new().available_only().run().any(|e| e.id == id),
251                "{id} is broken and must not appear as available"
252            );
253        }
254    }
255
256    #[test]
257    fn quantum_filter_removes_classical_asymmetric_schemes() {
258        let hits: Vec<_> = Query::new()
259            .min_quantum_bits(128)
260            .run()
261            .map(|e| e.id)
262            .collect();
263        assert!(!hits.contains(&"x25519"), "Shor breaks X25519");
264        assert!(!hits.contains(&"ed25519"));
265        assert!(
266            hits.contains(&"aes-256-gcm"),
267            "Grover leaves AES-256 at 128 bits"
268        );
269        assert!(hits.contains(&"ml-kem-768"));
270    }
271
272    #[test]
273    fn strength_filter_is_inclusive_at_the_boundary() {
274        let hits: Vec<_> = Query::new()
275            .class(Class::Aead)
276            .min_classical_bits(256)
277            .run()
278            .map(|e| e.id)
279            .collect();
280        assert!(hits.contains(&"aes-256-gcm"));
281        assert!(!hits.contains(&"aes-128-gcm"));
282    }
283
284    #[test]
285    fn family_filter_groups_related_entries() {
286        let n = Query::new().family("SHA-2").count();
287        assert_eq!(n, 6, "six published SHA-2 output variants");
288    }
289
290    #[test]
291    fn empty_query_matches_everything() {
292        assert_eq!(Query::new().count(), all().len());
293    }
294
295    #[test]
296    fn relations_traverse_both_directions() {
297        // HMAC-SHA-256 declares it is built on SHA-256...
298        let from_hmac: Vec<_> = related("hmac-sha2-256").map(|e| e.id).collect();
299        assert!(from_hmac.contains(&"sha2-256"));
300        // ...and the reverse lookup finds it even though SHA-256 has no edge.
301        let from_sha: Vec<_> = related("sha2-256").map(|e| e.id).collect();
302        assert!(from_sha.contains(&"hmac-sha2-256"));
303    }
304
305    #[test]
306    fn purpose_limit_does_not_silently_drop_filters() {
307        // Four is the cap; the fifth is ignored rather than panicking, and the
308        // first four still apply.
309        let q = Query::new()
310            .purpose(Purpose::Confidentiality)
311            .purpose(Purpose::Authentication)
312            .purpose(Purpose::Integrity)
313            .purpose(Purpose::KeyDerivation)
314            .purpose(Purpose::RandomGeneration);
315        assert_eq!(q.purpose_count, 4);
316        assert_eq!(q.count(), 0, "nothing serves all four of those purposes");
317    }
318}