veredictum 0.1.4

The independent conformance instrument for openEHR clinical data repositories: a machine-readable catalogue of spec-cited test cases, executed against any running CDR, judged by pure-function verdicts
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
// SPDX-FileCopyrightText: Veredictum contributors
// SPDX-License-Identifier: Apache-2.0

//! Targeting and credentials: a base URL plus one credential is the whole
//! ceremony a bench run asks for.
//!
//! Secrets never ride argv. `--auth basic` takes its password from
//! [`PASSWORD_ENV`] and `--auth bearer` its token from [`TOKEN_ENV`], so a
//! credential is never visible to every process on the host through the
//! command line.

use std::fmt;
use std::io::Read as _;
use std::time::Duration;

use base64::Engine as _;

use crate::bench::BenchError;

/// Where `--auth basic` reads its password from.
pub const PASSWORD_ENV: &str = "VEREDICTUM_BENCH_PASSWORD";

/// Where `--auth bearer` reads its token from.
pub const TOKEN_ENV: &str = "VEREDICTUM_BENCH_TOKEN";

/// Per-request timeout. A response slower than this is a timeout arrival,
/// counted in its own error class rather than waited on forever.
pub const CLIENT_TIMEOUT: Duration = Duration::from_secs(30);

/// The closed `--auth` vocabulary.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthKind {
    /// No `Authorization` header.
    None,
    /// HTTP Basic, with the user from `--user` and the password from
    /// [`PASSWORD_ENV`].
    Basic,
    /// A bearer token from [`TOKEN_ENV`].
    Bearer,
}

impl AuthKind {
    /// Every mode, in the order `--auth` documents them.
    pub const ALL: &[AuthKind] = &[AuthKind::None, AuthKind::Basic, AuthKind::Bearer];

    /// The token as written on the command line.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            AuthKind::None => "none",
            AuthKind::Basic => "basic",
            AuthKind::Bearer => "bearer",
        }
    }

    /// Reads one token from the closed vocabulary.
    ///
    /// # Errors
    /// [`BenchError::UnknownToken`] listing the accepted tokens.
    pub fn parse(token: &str) -> Result<Self, BenchError> {
        Self::ALL
            .iter()
            .copied()
            .find(|mode| mode.as_str() == token)
            .ok_or_else(|| BenchError::UnknownToken {
                vocabulary: "auth mode",
                token: token.to_owned(),
                accepted: Self::ALL
                    .iter()
                    .map(|mode| mode.as_str())
                    .collect::<Vec<_>>()
                    .join(", "),
            })
    }
}

impl fmt::Display for AuthKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The `Prefer: return=…` preference a write states.
///
/// The three tokens are the ones ITS-REST `docs/overview/Requests_and_responses`
/// §"Prefer minimal, identifier or full representation response" defines. The
/// same section notes that a client is encouraged to state the preference
/// explicitly, because a service MAY make its default configurable.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreferReturn {
    /// No `Prefer` header at all, which the same section says the service
    /// treats as `return=minimal`.
    Unstated,
    /// `return=minimal`: identifying headers, and typically no body.
    Minimal,
    /// `return=identifier`: a body carrying only the affected resource's
    /// `uid`, with `201 Created` or `200 OK` and never `204 No Content`.
    Identifier,
}

impl PreferReturn {
    /// Every preference, in the order the specification section lists them.
    pub const ALL: &[PreferReturn] = &[
        PreferReturn::Unstated,
        PreferReturn::Minimal,
        PreferReturn::Identifier,
    ];

    /// The header value to send, or `None` when no header is sent.
    #[must_use]
    pub const fn header_value(self) -> Option<&'static str> {
        match self {
            PreferReturn::Unstated => None,
            PreferReturn::Minimal => Some("return=minimal"),
            PreferReturn::Identifier => Some("return=identifier"),
        }
    }
}

impl fmt::Display for PreferReturn {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.header_value().unwrap_or("(unstated)"))
    }
}

/// One wire response, as much of it as the engine reads.
#[derive(Debug)]
pub struct BenchReply {
    /// The status the SUT answered with.
    pub status: reqwest::StatusCode,
    /// The `ETag` header, when one was sent.
    pub etag: Option<String>,
    /// The `Location` header, when one was sent.
    pub location: Option<String>,
    /// The `Content-Encoding` header, when one was sent. Read by the
    /// compression canary, and only meaningful on a client built by
    /// [`BenchClient::without_decompression`], because a decompressing client
    /// removes the header as it unwraps the body.
    pub content_encoding: Option<String>,
    /// The response body, drained so the pooled connection stays reusable.
    pub body: Vec<u8>,
}

/// The blocking client every bench exchange rides.
///
/// One connection pool, shared by every seeding worker and every measured
/// arrival, so the numbers describe the SUT rather than connection setup.
#[derive(Clone)]
pub struct BenchClient {
    client: reqwest::blocking::Client,
    base_url: String,
    authorization: Option<String>,
}

impl fmt::Debug for BenchClient {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("BenchClient")
            .field("base_url", &self.base_url)
            .finish_non_exhaustive()
    }
}

impl BenchClient {
    /// Builds the client from the command line's targeting arguments, taking
    /// the secret from the environment.
    ///
    /// # Errors
    /// [`BenchError::MissingUser`] when `--auth basic` carries no user,
    /// [`BenchError::Credential`] when the credential variable is unset, and
    /// [`BenchError::Client`] when the HTTP client cannot be built.
    pub fn new(base_url: &str, auth: AuthKind, user: Option<&str>) -> Result<Self, BenchError> {
        Self::with_credential(base_url, auth, user, None)
    }

    /// Builds the client with a secret supplied in process.
    ///
    /// The baseline orchestration composes the stack it then measures, so it
    /// already holds that stack's credential and must not reach into the
    /// operator's environment for a different one. `None` falls back to the
    /// environment variable the mode documents.
    ///
    /// # Errors
    /// [`BenchError::MissingUser`] when `--auth basic` carries no user,
    /// [`BenchError::Credential`] when no secret was supplied and the
    /// credential variable is unset, and [`BenchError::Client`] when the HTTP
    /// client cannot be built.
    pub fn with_credential(
        base_url: &str,
        auth: AuthKind,
        user: Option<&str>,
        secret: Option<&str>,
    ) -> Result<Self, BenchError> {
        let from_env = |name: &'static str| match secret {
            Some(secret) => Ok(secret.to_owned()),
            None => std::env::var(name).map_err(|source| BenchError::Credential { name, source }),
        };
        let authorization = match auth {
            AuthKind::None => None,
            AuthKind::Basic => {
                let user = user.ok_or(BenchError::MissingUser)?;
                let password = from_env(PASSWORD_ENV)?;
                let encoded = base64::engine::general_purpose::STANDARD
                    .encode(format!("{user}:{password}").as_bytes());
                Some(format!("Basic {encoded}"))
            }
            AuthKind::Bearer => Some(format!("Bearer {}", from_env(TOKEN_ENV)?)),
        };
        let client = reqwest::blocking::Client::builder()
            .timeout(CLIENT_TIMEOUT)
            .pool_max_idle_per_host(256)
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .map_err(|source| BenchError::Client { source })?;
        Ok(Self {
            client,
            base_url: base_url.trim_end_matches('/').to_owned(),
            authorization,
        })
    }

    /// The same target and credential over a client that never decompresses.
    ///
    /// A decompressing client removes `Content-Encoding` from the response as
    /// it unwraps the body (reqwest 0.13 `ClientBuilder::gzip`, whose contract
    /// is that a `gzip` value and `Content-Length` "are removed from the
    /// headers' set"), which is exactly the header the compression canary has
    /// to read. Turning both decoders off leaves the server's own answer
    /// visible.
    ///
    /// # Errors
    /// [`BenchError::Client`] when the HTTP client cannot be built.
    pub fn without_decompression(&self) -> Result<Self, BenchError> {
        let client = reqwest::blocking::Client::builder()
            .timeout(CLIENT_TIMEOUT)
            .pool_max_idle_per_host(4)
            .redirect(reqwest::redirect::Policy::none())
            .gzip(false)
            .brotli(false)
            .build()
            .map_err(|source| BenchError::Client { source })?;
        Ok(Self {
            client,
            base_url: self.base_url.clone(),
            authorization: self.authorization.clone(),
        })
    }

    /// The same target over a client that presents no credential at all.
    ///
    /// The authentication canary needs one request the run's own credential is
    /// deliberately absent from, because whether a server ENFORCES the mode a
    /// run declares is only visible when nothing is offered.
    ///
    /// # Errors
    /// [`BenchError::Client`] when the HTTP client cannot be built.
    pub fn without_credential(&self) -> Result<Self, BenchError> {
        let mut anonymous = self.without_decompression()?;
        anonymous.authorization = None;
        Ok(anonymous)
    }

    /// The base URL this client drives, with any userinfo removed.
    #[must_use]
    pub fn recorded_base_url(&self) -> String {
        strip_userinfo(&self.base_url)
    }

    /// Issues one request against an absolute path under the base URL.
    ///
    /// `exchange` names the call for the diagnostic a transport fault
    /// carries, which is what a refused preflight reports.
    ///
    /// # Errors
    /// [`BenchError::Transport`] when the request never reached a response.
    pub fn send(
        &self,
        exchange: &str,
        method: reqwest::Method,
        path: &str,
        body: Option<(&'static str, Vec<u8>)>,
        prefer: PreferReturn,
    ) -> Result<BenchReply, BenchError> {
        self.send_with_headers(exchange, method, path, body, prefer, &[])
    }

    /// Issues one request carrying additional request headers.
    ///
    /// The posture canaries are the only caller: they state an explicit
    /// `Accept-Encoding` so the server's own compression choice is visible,
    /// which the measured path never has to do.
    ///
    /// # Errors
    /// [`BenchError::Transport`] when the request never reached a response.
    pub fn send_with_headers(
        &self,
        exchange: &str,
        method: reqwest::Method,
        path: &str,
        body: Option<(&'static str, Vec<u8>)>,
        prefer: PreferReturn,
        extra: &[(&'static str, &'static str)],
    ) -> Result<BenchReply, BenchError> {
        let accept = match &body {
            Some((media_type, _)) if media_type.contains("xml") => "application/xml",
            _ => "application/json",
        };
        let mut request = self
            .client
            .request(method, format!("{}{path}", self.base_url))
            .header("Accept", accept);
        if let Some(authorization) = &self.authorization {
            request = request.header("Authorization", authorization);
        }
        if let Some(preference) = prefer.header_value() {
            request = request.header("Prefer", preference);
        }
        if let Some((media_type, bytes)) = body {
            request = request.header("Content-Type", media_type).body(bytes);
        }
        for (name, value) in extra {
            request = request.header(*name, *value);
        }
        let response = request.send().map_err(|source| BenchError::Transport {
            exchange: exchange.to_owned(),
            source,
        })?;
        let header = |name: &str| {
            response
                .headers()
                .get(name)
                .and_then(|value| value.to_str().ok())
                .map(str::to_owned)
        };
        let status = response.status();
        let etag = header("etag");
        let location = header("location");
        let content_encoding = header("content-encoding");
        let mut sink = Vec::new();
        let mut reader = response;
        let _drained = reader.read_to_end(&mut sink);
        Ok(BenchReply {
            status,
            etag,
            location,
            content_encoding,
            body: sink,
        })
    }
}

/// Removes any `user:password@` from a URL's authority.
///
/// A recorded target must not republish a credential someone typed into a
/// base URL. Returns the URL unchanged when it carries no userinfo.
#[must_use]
pub fn strip_userinfo(url: &str) -> String {
    let Some((scheme, rest)) = url.split_once("://") else {
        return url.to_owned();
    };
    let (authority, path) = match rest.find('/') {
        Some(cut) => rest.split_at(cut),
        None => (rest, ""),
    };
    let Some((_userinfo, host)) = authority.rsplit_once('@') else {
        return url.to_owned();
    };
    format!("{scheme}://{host}{path}")
}

/// `W/"uid"` or `"uid"` becomes `uid` (ITS-REST overview §Concurrency
/// control describes the weak-validator form the create answers with).
#[must_use]
pub fn strip_weak_quotes(etag: &str) -> String {
    etag.trim_start_matches("W/").trim_matches('"').to_owned()
}

/// The last path segment of a `Location` header, which is the identifier a
/// `return=minimal` create discloses.
#[must_use]
pub fn location_last_segment(location: &str) -> Option<String> {
    location
        .rsplit('/')
        .next()
        .filter(|segment| !segment.is_empty())
        .map(str::to_owned)
}

/// The identifier a create discloses, whichever way it discloses one.
///
/// A `return=identifier` reply carries a body of one `uid` attribute
/// (ITS-REST `docs/overview/Requests_and_responses` §"Prefer only
/// identifier"), while `return=minimal` carries the identifying headers
/// instead. The body is read first, because it is the only source the
/// identifier preference guarantees. Returns `None` when the reply discloses
/// no identifier at all.
#[must_use]
#[expect(
    clippy::disallowed_types,
    reason = "the approved wire-body seam: an identifier reply is a JSON document read for one attribute"
)]
pub fn created_identifier(reply: &BenchReply) -> Option<String> {
    let from_body = serde_json::from_slice::<serde_json::Value>(&reply.body)
        .ok()
        .and_then(|document| {
            document.pointer("/uid").and_then(|uid| {
                uid.as_str().map(str::to_owned).or_else(|| {
                    uid.pointer("/value")
                        .and_then(serde_json::Value::as_str)
                        .map(str::to_owned)
                })
            })
        })
        .filter(|uid| !uid.is_empty());
    from_body
        .or_else(|| reply.etag.as_deref().map(strip_weak_quotes))
        .or_else(|| reply.location.as_deref().and_then(location_last_segment))
        .filter(|identifier| !identifier.is_empty())
}

/// One query-parameter value, percent-encoded for a URL.
///
/// An RFC 3339 instant carries `:` and `+`, and `+` in a query means a space
/// unless it is encoded (<https://url.spec.whatwg.org/#urlencoded-parsing>).
#[must_use]
pub fn query_value(value: &str) -> String {
    urlencoding::encode(value).into_owned()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A credential typed into the base URL never reaches the artifact.
    #[test]
    fn userinfo_is_stripped_from_a_recorded_target() {
        assert_eq!(
            strip_userinfo("https://alice:s3cret@cdr.example/openehr/v1"),
            "https://cdr.example/openehr/v1"
        );
        assert_eq!(
            strip_userinfo("https://alice@cdr.example"),
            "https://cdr.example"
        );
        assert_eq!(
            strip_userinfo("http://127.0.0.1:8080/rest/openehr/v1"),
            "http://127.0.0.1:8080/rest/openehr/v1"
        );
        assert_eq!(strip_userinfo("not-a-url"), "not-a-url");
    }

    /// The two identifier captures follow the same forms the functional
    /// bindings capture from.
    #[test]
    fn identifier_captures_match_the_wire_forms() {
        assert_eq!(strip_weak_quotes("W/\"abc::sys::1\""), "abc::sys::1");
        assert_eq!(strip_weak_quotes("\"abc\""), "abc");
        assert_eq!(
            location_last_segment("http://sut/ehr/42").as_deref(),
            Some("42")
        );
        assert_eq!(location_last_segment("http://sut/ehr/"), None);
    }

    /// An unknown `--auth` token is refused rather than defaulting to `none`,
    /// which would silently measure an unauthenticated surface.
    #[test]
    fn an_unknown_auth_token_is_refused() {
        assert_eq!(AuthKind::parse("bearer").ok(), Some(AuthKind::Bearer));
        let error = AuthKind::parse("Bearer").unwrap_err();
        assert!(error.to_string().contains("none, basic, bearer"), "{error}");
    }

    /// Each preference sends the header value the specification names, and
    /// the unstated preference sends no header at all.
    #[test]
    fn every_preference_sends_its_own_header_value() {
        assert_eq!(PreferReturn::Unstated.header_value(), None);
        assert_eq!(PreferReturn::Minimal.header_value(), Some("return=minimal"));
        assert_eq!(
            PreferReturn::Identifier.header_value(),
            Some("return=identifier")
        );
        assert_eq!(PreferReturn::ALL.len(), 3);
    }

    /// A `return=identifier` body wins over the headers, and a reply that
    /// discloses nothing yields `None` rather than an empty identifier.
    #[test]
    fn a_created_identifier_reads_the_body_before_the_headers() {
        let reply = |body: &str, etag: Option<&str>, location: Option<&str>| BenchReply {
            status: reqwest::StatusCode::CREATED,
            etag: etag.map(str::to_owned),
            location: location.map(str::to_owned),
            content_encoding: None,
            body: body.as_bytes().to_vec(),
        };
        assert_eq!(
            created_identifier(&reply(
                r#"{"uid":"body::sys::1"}"#,
                Some("\"etag::sys::1\""),
                Some("http://sut/ehr/loc")
            ))
            .as_deref(),
            Some("body::sys::1")
        );
        assert_eq!(
            created_identifier(&reply(r#"{"uid":{"value":"nested::sys::1"}}"#, None, None))
                .as_deref(),
            Some("nested::sys::1")
        );
        assert_eq!(
            created_identifier(&reply("", Some("W/\"etag::sys::1\""), None)).as_deref(),
            Some("etag::sys::1")
        );
        assert_eq!(
            created_identifier(&reply("", None, Some("http://sut/ehr/EHR-9"))).as_deref(),
            Some("EHR-9")
        );
        assert_eq!(created_identifier(&reply("", None, None)), None);
    }

    /// An RFC 3339 instant survives a query parameter intact.
    #[test]
    fn a_query_value_is_percent_encoded() {
        assert_eq!(
            query_value("2026-08-29T10:11:12.5Z"),
            "2026-08-29T10%3A11%3A12.5Z"
        );
        assert!(!query_value("2026-08-29T10:11:12+02:00").contains('+'));
    }

    /// `--auth basic` without `--user` is refused before any request.
    #[test]
    fn basic_without_a_user_is_refused() {
        let error = BenchClient::new("http://stub", AuthKind::Basic, None).unwrap_err();
        assert!(matches!(error, BenchError::MissingUser), "{error}");
    }
}