draupnir 0.1.9

Draupnir — the nordisk boot/provisioning library: fire up a runtime from one BootSpec across three backends (KVM via tunnr · OCI container · Redfish bare-metal virtual-media) and drive its power lifecycle. Odin's ring that drips eight identical copies → boot a fleet of identical machines from one ISO.
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
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
//! **The Redfish wire vocabulary — ONE writer, both ends.**
//!
//! Draupnir owns both sides of this protocol: the [client](crate::redfish)
//! (`backend-redfish`, which drives a real iDRAC/iLO/OpenBMC) and the
//! [server](crate::redfish_server) (`redfish-server`, draupnir's own BMC fronting
//! KVM). The resource **paths**, the JSON **property names**, the **action** names
//! and the enum **tokens** are therefore written down exactly once, here, and both
//! ends read them from this module.
//!
//! That is not tidiness — it is the only thing that keeps the two from drifting into
//! a private dialect. A server that routed on a hand-typed
//! `"/redfish/v1/Systems/{}/Actions/ComputerSystem.Reset"` would pass every test
//! against a client that hand-typed the same string, and fail against a real BMC the
//! first time either string was edited. Here the client composes `host + path()` and
//! the server matches on `path()`, so "the two agree" is true by construction rather
//! than by coincidence.
//!
//! # What this module is NOT
//!
//! It is not a claim of conformance. The tokens below are transcribed from DMTF's
//! published schema and the shapes are pinned against DMTF's published mockup by
//! `tests/redfish_server_conformance.rs`; *that* test is the anchor, and it reads
//! DMTF's own JSON, not this file. See `tests/fixtures/dmtf/PROVENANCE.md`.
//!
//! Path/vocabulary is pure `std` and always compiles. The JSON body builders and
//! readers need `serde_json`, so they ride whichever end pulled it in.

use crate::BootTarget;

// ---------------------------------------------------------------------------
// Resource paths — the ABSOLUTE path half of every URL. The client prefixes the
// BMC host; the server routes on these verbatim.
// ---------------------------------------------------------------------------

/// The protocol-version probe (`GET /redfish` → `{"v1": "/redfish/v1/"}`) — DSP0266
/// §"Protocol version". The one URI in the whole service whose shape is fixed by the
/// specification rather than by a schema.
pub const PROTOCOL_VERSION_PATH: &str = "/redfish";

/// The service root, WITH its trailing slash. DMTF's own mockup gives the
/// `ServiceRoot` an `@odata.id` of `/redfish/v1/`, so this is the canonical spelling;
/// a server should also answer the slash-less form.
pub const SERVICE_ROOT_PATH: &str = "/redfish/v1/";

/// The service root without the trailing slash — the spelling a client is most
/// likely to type, and the one every BMC also answers.
pub const SERVICE_ROOT_PATH_BARE: &str = "/redfish/v1";

/// The `ComputerSystemCollection` path.
pub const SYSTEMS_PATH: &str = "/redfish/v1/Systems";

/// The `ComputerSystem` resource path: `/redfish/v1/Systems/{system_id}`.
pub fn system_path(system_id: &str) -> String {
    format!("{SYSTEMS_PATH}/{system_id}")
}

/// The `VirtualMediaCollection` path for a system.
pub fn virtual_media_collection_path(system_id: &str) -> String {
    format!("{}/VirtualMedia", system_path(system_id))
}

/// A `VirtualMedia` resource path (the CD/DVD slot), e.g.
/// `/redfish/v1/Systems/{system_id}/VirtualMedia/CD`.
pub fn virtual_media_path(system_id: &str, slot: &str) -> String {
    format!("{}/{slot}", virtual_media_collection_path(system_id))
}

/// A `VirtualMedia` action path, e.g.
/// `/redfish/v1/Systems/{system_id}/VirtualMedia/{slot}/Actions/VirtualMedia.InsertMedia`.
pub fn virtual_media_action_path(system_id: &str, slot: &str, action: &str) -> String {
    format!(
        "{}/Actions/VirtualMedia.{action}",
        virtual_media_path(system_id, slot)
    )
}

/// The `ComputerSystem.Reset` action path.
pub fn reset_path(system_id: &str) -> String {
    format!("{}/Actions/ComputerSystem.Reset", system_path(system_id))
}

/// The `SessionCollection` path the `ServiceRoot`'s **required** `Links.Sessions`
/// points at.
pub const SESSIONS_PATH: &str = "/redfish/v1/SessionService/Sessions";

// ---------------------------------------------------------------------------
// Action names.
// ---------------------------------------------------------------------------

/// The `VirtualMedia` action that mounts an image.
pub const INSERT_MEDIA: &str = "InsertMedia";
/// The `VirtualMedia` action that unmounts it.
pub const EJECT_MEDIA: &str = "EjectMedia";

/// The `Actions` key naming `ComputerSystem.Reset` on a `ComputerSystem`.
pub const ACTION_RESET: &str = "#ComputerSystem.Reset";
/// The `Actions` key naming `VirtualMedia.InsertMedia` on a `VirtualMedia`.
pub const ACTION_INSERT_MEDIA: &str = "#VirtualMedia.InsertMedia";
/// The `Actions` key naming `VirtualMedia.EjectMedia` on a `VirtualMedia`.
pub const ACTION_EJECT_MEDIA: &str = "#VirtualMedia.EjectMedia";

// ---------------------------------------------------------------------------
// Property names — the client WRITES them, the server READS them.
// ---------------------------------------------------------------------------

/// The JSON property names this protocol exchanges. Constants, not literals, so a
/// rename is one edit and both ends move together.
pub mod prop {
    /// `VirtualMedia.Image` — the media URI (the `InsertMedia` parameter, and the
    /// property the mounted image is readable back from).
    pub const IMAGE: &str = "Image";
    /// `VirtualMedia.Inserted`.
    pub const INSERTED: &str = "Inserted";
    /// `VirtualMedia.WriteProtected`.
    pub const WRITE_PROTECTED: &str = "WriteProtected";
    /// `VirtualMedia.ImageName`.
    pub const IMAGE_NAME: &str = "ImageName";
    /// `ComputerSystem.Boot`.
    pub const BOOT: &str = "Boot";
    /// `ComputerSystem.Boot.BootSourceOverrideEnabled`.
    pub const BOOT_SOURCE_OVERRIDE_ENABLED: &str = "BootSourceOverrideEnabled";
    /// `ComputerSystem.Boot.BootSourceOverrideTarget`.
    pub const BOOT_SOURCE_OVERRIDE_TARGET: &str = "BootSourceOverrideTarget";
    /// `ComputerSystem.Boot.BootSourceOverrideMode`.
    pub const BOOT_SOURCE_OVERRIDE_MODE: &str = "BootSourceOverrideMode";
    /// The `ComputerSystem.Reset` action parameter.
    pub const RESET_TYPE: &str = "ResetType";
    /// `ComputerSystem.PowerState`.
    pub const POWER_STATE: &str = "PowerState";
    /// The `@Redfish.AllowableValues` annotation suffix — appended to a property or
    /// parameter name (`ResetType@Redfish.AllowableValues`).
    pub const ALLOWABLE_VALUES_SUFFIX: &str = "@Redfish.AllowableValues";
    /// An action object's `target` (the URI a POST goes to).
    pub const TARGET: &str = "target";
}

/// `{name}@Redfish.AllowableValues` — the annotation a service publishes so a client
/// can discover the legal values of a property or action parameter *before* sending
/// one. Both ends build the key here so a bad `ResetType` refusal can be checked
/// against the very annotation that advertised the good ones.
pub fn allowable_values_key(name: &str) -> String {
    format!("{name}{}", prop::ALLOWABLE_VALUES_SUFFIX)
}

// ---------------------------------------------------------------------------
// Enum tokens.
// ---------------------------------------------------------------------------

/// `BootSourceOverrideEnabled: "Once"` — a ONE-TIME override, consumed by the next
/// boot. The only value draupnir's client ever sets.
pub const OVERRIDE_ONCE: &str = "Once";
/// `BootSourceOverrideEnabled: "Disabled"` — no override; the node's own boot list.
pub const OVERRIDE_DISABLED: &str = "Disabled";
/// `BootSourceOverrideEnabled: "Continuous"`.
pub const OVERRIDE_CONTINUOUS: &str = "Continuous";

/// The `BootSourceOverrideEnabled` values a service accepts (DMTF
/// `ComputerSystem.v1_22_0.json#/definitions/BootSourceOverrideEnabled`).
pub const OVERRIDE_ENABLED_ALLOWABLE: &[&str] =
    &[OVERRIDE_DISABLED, OVERRIDE_ONCE, OVERRIDE_CONTINUOUS];

/// `ResetType: "On"` — power the node up.
pub const RESET_ON: &str = "On";
/// `ResetType: "ForceOn"`.
pub const RESET_FORCE_ON: &str = "ForceOn";
/// `ResetType: "ForceOff"` — cut power.
pub const RESET_FORCE_OFF: &str = "ForceOff";
/// `ResetType: "GracefulShutdown"`.
pub const RESET_GRACEFUL_SHUTDOWN: &str = "GracefulShutdown";
/// `ResetType: "ForceRestart"`.
pub const RESET_FORCE_RESTART: &str = "ForceRestart";
/// `ResetType: "GracefulRestart"`.
pub const RESET_GRACEFUL_RESTART: &str = "GracefulRestart";

/// The `ResetType` values draupnir's BMC accepts, in the order it publishes them as
/// `ResetType@Redfish.AllowableValues`.
///
/// This is a **subset** of DMTF's `Resource.json#/definitions/ResetType` enum, which
/// is exactly what the specification asks of an implementation: advertise what you
/// can actually do. `tests/redfish_server_conformance.rs` proves the subset relation
/// against DMTF's own enum, so a token that is merely *plausible* (`"PowerOn"`,
/// `"Reset"`) cannot survive here.
pub const RESET_TYPE_ALLOWABLE: &[&str] = &[
    RESET_ON,
    RESET_FORCE_ON,
    RESET_FORCE_OFF,
    RESET_GRACEFUL_SHUTDOWN,
    RESET_FORCE_RESTART,
    RESET_GRACEFUL_RESTART,
];

/// Whether `reset_type` is one this service will act on.
pub fn is_allowable_reset_type(reset_type: &str) -> bool {
    RESET_TYPE_ALLOWABLE.contains(&reset_type)
}

/// The `BootSourceOverrideTarget` tokens draupnir models — a subset of DMTF's
/// `ComputerSystem.json#/definitions/BootSource` enum, one per [`BootTarget`].
pub const BOOT_TARGET_ALLOWABLE: &[&str] = &["None", "Pxe", "Cd", "Hdd", "BiosSetup"];

/// `BootSourceOverrideTarget: "None"` — the override target after it is consumed or
/// disabled.
pub const BOOT_TARGET_NONE: &str = "None";

/// Map a [`BootTarget`] onto its Redfish `BootSourceOverrideTarget` token.
pub fn target_str(target: BootTarget) -> &'static str {
    match target {
        BootTarget::Cd => "Cd",
        BootTarget::Pxe => "Pxe",
        BootTarget::Hdd => "Hdd",
        BootTarget::BiosSetup => "BiosSetup",
    }
}

/// The inverse of [`target_str`] — the SERVER's half. `"None"` and any token draupnir
/// does not model map to [`None`], which is the honest answer: an unmodelled target
/// must be refused by name, never silently treated as `Cd`.
pub fn target_from_str(s: &str) -> Option<BootTarget> {
    match s {
        "Cd" => Some(BootTarget::Cd),
        "Pxe" => Some(BootTarget::Pxe),
        "Hdd" => Some(BootTarget::Hdd),
        "BiosSetup" => Some(BootTarget::BiosSetup),
        _ => None,
    }
}

// ---------------------------------------------------------------------------
// HTTP Basic auth — the encoder the client uses and the decoder the server uses,
// side by side, over ONE alphabet.
// ---------------------------------------------------------------------------

/// The standard base64 alphabet.
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

/// Minimal std-only base64 (standard alphabet, padded) — the client's Basic-auth
/// header encoder. Avoids pulling a crate for four bytes of encoding.
pub fn base64_encode(input: &[u8]) -> String {
    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
    for chunk in input.chunks(3) {
        let b0 = chunk[0];
        let b1 = *chunk.get(1).unwrap_or(&0);
        let b2 = *chunk.get(2).unwrap_or(&0);
        let n = ((b0 as u32) << 16) | ((b1 as u32) << 8) | (b2 as u32);
        out.push(B64[((n >> 18) & 63) as usize] as char);
        out.push(B64[((n >> 12) & 63) as usize] as char);
        out.push(if chunk.len() > 1 {
            B64[((n >> 6) & 63) as usize] as char
        } else {
            '='
        });
        out.push(if chunk.len() > 2 {
            B64[(n & 63) as usize] as char
        } else {
            '='
        });
    }
    out
}

/// The inverse — the SERVER's half of the Basic-auth header. Strict: any character
/// outside the alphabet (including whitespace) is a decode failure, because a
/// credential check that silently skipped junk would accept a mangled header.
pub fn base64_decode(input: &str) -> Option<Vec<u8>> {
    let bytes = input.as_bytes();
    if bytes.len() % 4 != 0 {
        return None;
    }
    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
    for chunk in bytes.chunks(4) {
        let mut n: u32 = 0;
        let mut pad = 0usize;
        for (i, &c) in chunk.iter().enumerate() {
            let v = if c == b'=' {
                // Padding is legal only in the last two positions, and never before
                // a non-padding byte.
                if i < 2 {
                    return None;
                }
                pad += 1;
                0
            } else {
                if pad > 0 {
                    return None;
                }
                B64.iter().position(|&t| t == c)? as u32
            };
            n |= v << (18 - 6 * i);
        }
        out.push((n >> 16) as u8);
        if pad < 2 {
            out.push((n >> 8) as u8);
        }
        if pad < 1 {
            out.push(n as u8);
        }
    }
    Some(out)
}

/// The `Authorization: Basic …` header value for `username`/`password`.
pub fn basic_auth_header(username: &str, password: &str) -> String {
    format!("Basic {}", base64_encode(format!("{username}:{password}").as_bytes()))
}

/// Split an `Authorization` header value back into `(username, password)` — the
/// exact inverse of [`basic_auth_header`]. `None` unless it is a well-formed
/// `Basic` credential; the scheme name is matched case-insensitively (RFC 7235).
pub fn parse_basic_auth(header: &str) -> Option<(String, String)> {
    let rest = header.strip_prefix("Basic ").or_else(|| {
        let (scheme, rest) = header.split_once(' ')?;
        scheme.eq_ignore_ascii_case("basic").then_some(rest)
    })?;
    let decoded = base64_decode(rest.trim())?;
    let text = String::from_utf8(decoded).ok()?;
    let (user, pass) = text.split_once(':')?;
    Some((user.to_string(), pass.to_string()))
}

// ---------------------------------------------------------------------------
// Action BODIES — the writer and the reader of each, adjacent, so a field name
// can never be added on one side only.
// ---------------------------------------------------------------------------

/// The `VirtualMedia.InsertMedia` request body — mount `iso` read-only.
#[cfg(any(feature = "backend-redfish", feature = "redfish-server"))]
pub fn insert_media_body(iso: &str) -> serde_json::Value {
    serde_json::json!({
        prop::IMAGE: iso,
        prop::INSERTED: true,
        prop::WRITE_PROTECTED: true,
    })
}

/// What an `InsertMedia` body asked for, as the server reads it back.
#[cfg(any(feature = "backend-redfish", feature = "redfish-server"))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InsertMediaRequest {
    /// The media URI to mount (`Image`) — the only REQUIRED parameter.
    pub image: String,
    /// `Inserted`; DMTF's default when the parameter is omitted is `true`.
    pub inserted: bool,
    /// `WriteProtected`; DMTF's default when omitted is `true`.
    pub write_protected: bool,
}

/// Read an `InsertMedia` body. `Err` names the missing REQUIRED parameter —
/// `Image` — so the server can answer with `Base.ActionParameterMissing` rather
/// than inventing an empty media URI.
#[cfg(any(feature = "backend-redfish", feature = "redfish-server"))]
pub fn read_insert_media_body(v: &serde_json::Value) -> Result<InsertMediaRequest, &'static str> {
    let image = v
        .get(prop::IMAGE)
        .and_then(|i| i.as_str())
        .ok_or(prop::IMAGE)?;
    Ok(InsertMediaRequest {
        image: image.to_string(),
        inserted: v
            .get(prop::INSERTED)
            .and_then(|i| i.as_bool())
            .unwrap_or(true),
        write_protected: v
            .get(prop::WRITE_PROTECTED)
            .and_then(|i| i.as_bool())
            .unwrap_or(true),
    })
}

/// The one-time `Boot` override PATCH body for `target`.
#[cfg(any(feature = "backend-redfish", feature = "redfish-server"))]
pub fn boot_override_body(target: BootTarget) -> serde_json::Value {
    serde_json::json!({
        prop::BOOT: {
            prop::BOOT_SOURCE_OVERRIDE_ENABLED: OVERRIDE_ONCE,
            prop::BOOT_SOURCE_OVERRIDE_TARGET: target_str(target),
        }
    })
}

/// What a `Boot` PATCH asked for, as the server reads it back. Both members are
/// optional because Redfish PATCH is a partial update: a body may set only the
/// target, only the enablement, or both.
#[cfg(any(feature = "backend-redfish", feature = "redfish-server"))]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct BootOverrideRequest {
    /// The raw `BootSourceOverrideTarget` token, if present.
    pub target: Option<String>,
    /// The raw `BootSourceOverrideEnabled` token, if present.
    pub enabled: Option<String>,
}

/// Read the `Boot` object out of a `ComputerSystem` PATCH body. `None` when the body
/// carries no `Boot` at all — which is a legal (if pointless) PATCH, not an error.
#[cfg(any(feature = "backend-redfish", feature = "redfish-server"))]
pub fn read_boot_override_body(v: &serde_json::Value) -> Option<BootOverrideRequest> {
    let boot = v.get(prop::BOOT)?;
    Some(BootOverrideRequest {
        target: boot
            .get(prop::BOOT_SOURCE_OVERRIDE_TARGET)
            .and_then(|t| t.as_str())
            .map(str::to_string),
        enabled: boot
            .get(prop::BOOT_SOURCE_OVERRIDE_ENABLED)
            .and_then(|t| t.as_str())
            .map(str::to_string),
    })
}

/// The `ComputerSystem.Reset` body for a Redfish `ResetType` (`On` / `ForceOff` / …).
#[cfg(any(feature = "backend-redfish", feature = "redfish-server"))]
pub fn reset_body(reset_type: &str) -> serde_json::Value {
    serde_json::json!({ prop::RESET_TYPE: reset_type })
}

/// Read a `ComputerSystem.Reset` body. `Err` names the missing parameter.
#[cfg(any(feature = "backend-redfish", feature = "redfish-server"))]
pub fn read_reset_body(v: &serde_json::Value) -> Result<String, &'static str> {
    v.get(prop::RESET_TYPE)
        .and_then(|r| r.as_str())
        .map(str::to_string)
        .ok_or(prop::RESET_TYPE)
}

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

    // The paths are the routing table on the server side and the URL suffix on the
    // client side. Pinning the literals here is what makes the two ends' agreement
    // checkable at all — everything else in this module derives from them.
    #[test]
    fn resource_paths_are_the_dmtf_shapes() {
        assert_eq!(system_path("System.Embedded.1"),
                   "/redfish/v1/Systems/System.Embedded.1");
        assert_eq!(virtual_media_collection_path("437XR1138R2"),
                   "/redfish/v1/Systems/437XR1138R2/VirtualMedia");
        assert_eq!(virtual_media_path("437XR1138R2", "CD1"),
                   "/redfish/v1/Systems/437XR1138R2/VirtualMedia/CD1");
        assert_eq!(
            virtual_media_action_path("437XR1138R2", "CD1", INSERT_MEDIA),
            "/redfish/v1/Systems/437XR1138R2/VirtualMedia/CD1/Actions/VirtualMedia.InsertMedia"
        );
        assert_eq!(
            reset_path("437XR1138R2"),
            "/redfish/v1/Systems/437XR1138R2/Actions/ComputerSystem.Reset"
        );
    }

    /// Every path is a child of the collection above it — the containment DMTF's
    /// `@odata.id` graph asserts. RED-when-broken: build one path from a literal
    /// instead of from its parent and the chain breaks here.
    #[test]
    fn the_paths_nest_the_way_the_odata_graph_does() {
        let sys = system_path("s1");
        assert!(sys.starts_with(SYSTEMS_PATH));
        let vmc = virtual_media_collection_path("s1");
        assert!(vmc.starts_with(&sys), "{vmc} under {sys}");
        let vm = virtual_media_path("s1", "CD");
        assert!(vm.starts_with(&vmc));
        assert!(virtual_media_action_path("s1", "CD", INSERT_MEDIA).starts_with(&vm));
        assert!(reset_path("s1").starts_with(&sys));
    }

    #[test]
    fn boot_targets_round_trip_through_their_redfish_tokens() {
        for t in [
            BootTarget::Cd,
            BootTarget::Pxe,
            BootTarget::Hdd,
            BootTarget::BiosSetup,
        ] {
            assert_eq!(target_from_str(target_str(t)), Some(t), "{t:?}");
            assert!(
                BOOT_TARGET_ALLOWABLE.contains(&target_str(t)),
                "{t:?} is advertised as allowable"
            );
        }
        // An unmodelled target is REFUSED, never coerced. RED-when-broken: add a
        // `_ => Some(BootTarget::Cd)` fallback and this fails.
        assert_eq!(target_from_str("Usb"), None);
        assert_eq!(target_from_str(BOOT_TARGET_NONE), None);
        assert_eq!(target_from_str("cd"), None, "the tokens are case-sensitive");
    }

    #[test]
    fn the_reset_types_the_client_sends_are_the_ones_the_server_advertises() {
        // The client's two live calls (`power_on` → On, `power_off` → ForceOff) must
        // both be in the advertised set, or draupnir's own client would be refused by
        // draupnir's own BMC.
        assert!(is_allowable_reset_type(RESET_ON));
        assert!(is_allowable_reset_type(RESET_FORCE_OFF));
        assert!(!is_allowable_reset_type("PowerOn"), "a plausible non-token");
        assert!(!is_allowable_reset_type("on"), "case-sensitive");
        assert!(!is_allowable_reset_type(""));
    }

    #[test]
    fn allowable_values_annotation_key_is_the_odata_spelling() {
        assert_eq!(
            allowable_values_key(prop::RESET_TYPE),
            "ResetType@Redfish.AllowableValues"
        );
    }

    #[test]
    fn base64_matches_known_vectors_and_round_trips() {
        for (raw, enc) in [
            (&b""[..], ""),
            (&b"f"[..], "Zg=="),
            (&b"fo"[..], "Zm8="),
            (&b"foo"[..], "Zm9v"),
            (&b"foob"[..], "Zm9vYg=="),
            (&b"admin:secret"[..], "YWRtaW46c2VjcmV0"),
        ] {
            assert_eq!(base64_encode(raw), enc);
            assert_eq!(base64_decode(enc).as_deref(), Some(raw), "decode {enc}");
        }
        // Every byte value survives the round trip (the alphabet is complete).
        let all: Vec<u8> = (0u8..=255).collect();
        assert_eq!(base64_decode(&base64_encode(&all)).unwrap(), all);
    }

    #[test]
    fn base64_decode_refuses_junk_instead_of_skipping_it() {
        // RED-when-broken: a lenient decoder that ignores unknown characters would
        // accept a mangled credential header.
        assert_eq!(base64_decode("Zm9v!"), None, "bad length");
        assert_eq!(base64_decode("Zm9 v"), None, "space is not in the alphabet");
        assert_eq!(base64_decode("Z==="), None, "padding cannot start at index 1");
        assert_eq!(base64_decode("Zm=v"), None, "padding cannot precede data");
        assert_eq!(base64_decode("Zg=/"), None);
    }

    #[test]
    fn basic_auth_round_trips_between_the_client_and_the_server_halves() {
        let h = basic_auth_header("admin", "hunter2:with:colons");
        assert_eq!(h, "Basic YWRtaW46aHVudGVyMjp3aXRoOmNvbG9ucw==");
        // The password may contain ':' — only the FIRST separates it from the user.
        assert_eq!(
            parse_basic_auth(&h),
            Some(("admin".into(), "hunter2:with:colons".into()))
        );
        // RFC 7235: the scheme token is case-insensitive.
        assert_eq!(
            parse_basic_auth("basic YWRtaW46c2VjcmV0"),
            Some(("admin".into(), "secret".into()))
        );
        assert_eq!(parse_basic_auth("Bearer abc"), None);
        assert_eq!(parse_basic_auth("Basic !!!!"), None);
        // No colon at all is not a credential.
        assert_eq!(parse_basic_auth(&format!("Basic {}", base64_encode(b"admin"))), None);
    }

    #[cfg(any(feature = "backend-redfish", feature = "redfish-server"))]
    mod bodies {
        use super::*;

        /// THE anti-drift proof: what the client writes is exactly what the server
        /// reads. RED-when-broken: rename a field on one side only and this fails
        /// instead of the two ends silently disagreeing at runtime.
        #[test]
        fn every_action_body_the_client_writes_is_read_back_by_the_server_half() {
            let insert = read_insert_media_body(&insert_media_body("https://d/x.iso")).unwrap();
            assert_eq!(insert.image, "https://d/x.iso");
            assert!(insert.inserted);
            assert!(insert.write_protected, "the install medium is read-only");

            let ovr = read_boot_override_body(&boot_override_body(BootTarget::Cd)).unwrap();
            assert_eq!(ovr.target.as_deref(), Some("Cd"));
            assert_eq!(ovr.enabled.as_deref(), Some(OVERRIDE_ONCE));

            assert_eq!(read_reset_body(&reset_body(RESET_ON)).unwrap(), RESET_ON);
        }

        #[test]
        fn a_body_missing_its_required_parameter_names_the_parameter() {
            let e = read_insert_media_body(&serde_json::json!({ "Inserted": true })).unwrap_err();
            assert_eq!(e, prop::IMAGE);
            let e = read_reset_body(&serde_json::json!({})).unwrap_err();
            assert_eq!(e, prop::RESET_TYPE);
            // A non-string Image is missing, not silently stringified.
            assert!(read_insert_media_body(&serde_json::json!({ "Image": 7 })).is_err());
        }

        #[test]
        fn insert_media_defaults_match_the_dmtf_parameter_defaults() {
            // DMTF: omitted `Inserted`/`WriteProtected` default to true.
            let r = read_insert_media_body(&serde_json::json!({ "Image": "u" })).unwrap();
            assert!(r.inserted && r.write_protected);
            let r = read_insert_media_body(
                &serde_json::json!({ "Image": "u", "Inserted": false, "WriteProtected": false }),
            )
            .unwrap();
            assert!(!r.inserted && !r.write_protected);
        }

        #[test]
        fn a_patch_with_no_boot_object_is_read_as_no_override_not_as_an_error() {
            assert_eq!(read_boot_override_body(&serde_json::json!({})), None);
            // A partial PATCH carries only what it names.
            let partial = read_boot_override_body(
                &serde_json::json!({ "Boot": { "BootSourceOverrideTarget": "Hdd" } }),
            )
            .unwrap();
            assert_eq!(partial.target.as_deref(), Some("Hdd"));
            assert_eq!(partial.enabled, None);
        }
    }
}